Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-reporting"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
trx-core = { path = "../trx-core" }
|
||||
@@ -0,0 +1,483 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! APRS-IS IGate uplink — forwards RF-decoded APRS packets to APRS-IS (aprs.fi etc.).
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::time::{self, Duration, Instant};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use trx_core::decode::{AprsPacket, DecodedMessage};
|
||||
|
||||
use crate::AprsFiConfig;
|
||||
|
||||
/// Compute the APRS-IS passcode for a callsign.
|
||||
///
|
||||
/// Algorithm matches the canonical JS/Python reference implementations:
|
||||
/// - Strip SSID (everything from `-` onwards)
|
||||
/// - Take the first 10 characters, uppercased
|
||||
/// - XOR hash initialised at 0x73E2, processed in 2-byte pairs
|
||||
/// - Mask result with 0x7FFF
|
||||
pub fn compute_passcode(callsign: &str) -> u16 {
|
||||
// Strip SSID
|
||||
let base = callsign.split('-').next().unwrap_or(callsign);
|
||||
// First 10 chars, uppercase
|
||||
let upper: String = base
|
||||
.chars()
|
||||
.take(10)
|
||||
.map(|c| c.to_ascii_uppercase())
|
||||
.collect();
|
||||
let bytes = upper.as_bytes();
|
||||
|
||||
let mut hash: u16 = 0x73e2;
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
hash ^= (bytes[i] as u16) << 8;
|
||||
if i + 1 < bytes.len() {
|
||||
hash ^= bytes[i + 1] as u16;
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
hash & 0x7fff
|
||||
}
|
||||
|
||||
/// Format an [`AprsPacket`] as a TNC2 line (CRLF-terminated) for APRS-IS,
|
||||
/// appending the mandatory `,qAR,<igate_call>` q-construct that identifies
|
||||
/// the RF entry point per the APRS-IS IGate specification.
|
||||
fn format_tnc2(pkt: &AprsPacket, igate_call: &str) -> String {
|
||||
if pkt.path.is_empty() {
|
||||
format!(
|
||||
"{}>{},qAR,{}:{}\r\n",
|
||||
pkt.src_call, pkt.dest_call, igate_call, pkt.info
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}>{},{},qAR,{}:{}\r\n",
|
||||
pkt.src_call, pkt.dest_call, pkt.path, igate_call, pkt.info
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format decimal-degree latitude as `DDMM.mmN` / `DDMM.mmS`.
|
||||
fn format_aprs_lat(lat: f64) -> String {
|
||||
let ns = if lat >= 0.0 { 'N' } else { 'S' };
|
||||
let lat = lat.abs();
|
||||
let deg = lat as u32;
|
||||
let min = (lat - deg as f64) * 60.0;
|
||||
format!("{:02}{:05.2}{}", deg, min, ns)
|
||||
}
|
||||
|
||||
/// Format decimal-degree longitude as `DDDMM.mmE` / `DDDMM.mmW`.
|
||||
fn format_aprs_lon(lon: f64) -> String {
|
||||
let ew = if lon >= 0.0 { 'E' } else { 'W' };
|
||||
let lon = lon.abs();
|
||||
let deg = lon as u32;
|
||||
let min = (lon - deg as f64) * 60.0;
|
||||
format!("{:03}{:05.2}{}", deg, min, ew)
|
||||
}
|
||||
|
||||
/// Build a position beacon TNC2 line for this IGate station.
|
||||
///
|
||||
/// Uses APRS uncompressed position format (`!`) with path `TCPIP*`.
|
||||
/// The two-character `symbol` string sets the symbol-table and symbol-code
|
||||
/// (e.g. `/-` = house, `/&` = diamond/gateway), followed by a software
|
||||
/// comment identifying this IGate instance.
|
||||
fn format_beacon(callsign: &str, lat: f64, lon: f64, sym_table: char, sym_code: char) -> String {
|
||||
let comment = format!("trx-rs v{} by SP2SJG", env!("CARGO_PKG_VERSION"));
|
||||
format!(
|
||||
"{}>APRS,TCPIP*:!{}{}{}{}{}\r\n",
|
||||
callsign,
|
||||
format_aprs_lat(lat),
|
||||
sym_table,
|
||||
format_aprs_lon(lon),
|
||||
sym_code,
|
||||
comment,
|
||||
)
|
||||
}
|
||||
|
||||
/// Run the APRS-IS IGate uplink task.
|
||||
///
|
||||
/// Subscribes to the decoded-message broadcast channel and forwards every
|
||||
/// CRC-valid APRS packet to the configured APRS-IS server as a TNC2 line.
|
||||
/// Reconnects automatically with exponential backoff (1 s → 2 s → … → 60 s).
|
||||
pub async fn run_aprsfi_uplink(
|
||||
cfg: AprsFiConfig,
|
||||
callsign: String,
|
||||
latitude: Option<f64>,
|
||||
longitude: Option<f64>,
|
||||
mut decode_rx: broadcast::Receiver<DecodedMessage>,
|
||||
) {
|
||||
let passcode: u16 = if cfg.passcode == -1 {
|
||||
compute_passcode(&callsign)
|
||||
} else {
|
||||
(cfg.passcode as u16) & 0x7fff
|
||||
};
|
||||
|
||||
// Resolve coordinates: [aprsfi] overrides [general].
|
||||
let coords = cfg
|
||||
.latitude
|
||||
.zip(cfg.longitude)
|
||||
.or_else(|| latitude.zip(longitude));
|
||||
|
||||
// Pre-build the beacon packet (None if beaconing disabled or no coords).
|
||||
let beacon_packet: Option<String> = if cfg.beacon {
|
||||
match coords {
|
||||
Some((lat, lon)) => Some(format_beacon(
|
||||
&callsign,
|
||||
lat,
|
||||
lon,
|
||||
cfg.beacon_symbol_table,
|
||||
cfg.beacon_symbol_code,
|
||||
)),
|
||||
None => {
|
||||
warn!(
|
||||
"APRS-IS IGate: beacon enabled but no coordinates available \
|
||||
(set [aprsfi].latitude/longitude or [general].latitude/longitude)"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let beacon_interval = Duration::from_secs(cfg.beacon_interval_secs.max(60));
|
||||
|
||||
let mut stats_received: u64 = 0;
|
||||
let mut stats_forwarded: u64 = 0;
|
||||
let mut stats_skipped: u64 = 0;
|
||||
let mut stats_write_errors: u64 = 0;
|
||||
let mut stats_reconnects: u64 = 0;
|
||||
let mut backoff_secs: u64 = 1;
|
||||
|
||||
'reconnect: loop {
|
||||
// ----------------------------------------------------------------
|
||||
// TCP connect
|
||||
// ----------------------------------------------------------------
|
||||
let stream = match TcpStream::connect((cfg.host.as_str(), cfg.port)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"APRS-IS IGate: connection to {}:{} failed: {}, retrying in {}s",
|
||||
cfg.host, cfg.port, e, backoff_secs
|
||||
);
|
||||
time::sleep(Duration::from_secs(backoff_secs)).await;
|
||||
backoff_secs = (backoff_secs * 2).min(60);
|
||||
stats_reconnects += 1;
|
||||
continue 'reconnect;
|
||||
}
|
||||
};
|
||||
|
||||
// Disable Nagle algorithm for low-latency packet forwarding.
|
||||
if let Err(e) = stream.set_nodelay(true) {
|
||||
warn!("APRS-IS IGate: set_nodelay failed: {}", e);
|
||||
}
|
||||
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
let mut reader = BufReader::new(read_half);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Login
|
||||
// ----------------------------------------------------------------
|
||||
let login = format!(
|
||||
"user {} pass {} vers trx-rs {}\r\n",
|
||||
callsign,
|
||||
passcode,
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
if let Err(e) = write_half.write_all(login.as_bytes()).await {
|
||||
warn!(
|
||||
"APRS-IS IGate: login write to {}:{} failed: {}, retrying in {}s",
|
||||
cfg.host, cfg.port, e, backoff_secs
|
||||
);
|
||||
time::sleep(Duration::from_secs(backoff_secs)).await;
|
||||
backoff_secs = (backoff_secs * 2).min(60);
|
||||
stats_reconnects += 1;
|
||||
continue 'reconnect;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Read logresp (up to 10 lines)
|
||||
// ----------------------------------------------------------------
|
||||
let mut verified = false;
|
||||
let mut got_logresp = false;
|
||||
let mut line = String::new();
|
||||
for _ in 0..10 {
|
||||
line.clear();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) => {
|
||||
warn!(
|
||||
"APRS-IS IGate: connection closed before logresp from {}:{}",
|
||||
cfg.host, cfg.port
|
||||
);
|
||||
break;
|
||||
}
|
||||
Ok(_) => {
|
||||
if line.starts_with("# logresp") {
|
||||
verified = !line.contains("unverified");
|
||||
got_logresp = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"APRS-IS IGate: error reading logresp from {}:{}: {}",
|
||||
cfg.host, cfg.port, e
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !got_logresp {
|
||||
warn!(
|
||||
"APRS-IS IGate: no logresp from {}:{}, retrying in {}s",
|
||||
cfg.host, cfg.port, backoff_secs
|
||||
);
|
||||
time::sleep(Duration::from_secs(backoff_secs)).await;
|
||||
backoff_secs = (backoff_secs * 2).min(60);
|
||||
stats_reconnects += 1;
|
||||
continue 'reconnect;
|
||||
}
|
||||
|
||||
info!(
|
||||
"APRS-IS IGate connected ({}:{} as {}, {})",
|
||||
cfg.host,
|
||||
cfg.port,
|
||||
callsign,
|
||||
if verified { "verified" } else { "unverified" }
|
||||
);
|
||||
|
||||
// Successful connection — reset backoff
|
||||
backoff_secs = 1;
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Forward loop
|
||||
// ----------------------------------------------------------------
|
||||
let period = Duration::from_secs(60);
|
||||
let first_at = Instant::now() + period;
|
||||
let mut keepalive_tick = time::interval_at(first_at, period);
|
||||
let mut stats_tick = time::interval_at(first_at, period);
|
||||
// Beacon fires immediately on connect, then every beacon_interval.
|
||||
let mut beacon_tick = time::interval_at(Instant::now(), beacon_interval);
|
||||
// Reuse a single allocation for draining server-sent lines.
|
||||
let mut server_line = String::new();
|
||||
|
||||
'forward: loop {
|
||||
tokio::select! {
|
||||
_ = keepalive_tick.tick() => {
|
||||
if let Err(e) = write_half.write_all(b"# trx-rs keepalive\r\n").await {
|
||||
warn!("APRS-IS IGate: keepalive write failed: {}", e);
|
||||
stats_write_errors += 1;
|
||||
break 'forward;
|
||||
}
|
||||
}
|
||||
|
||||
_ = stats_tick.tick() => {
|
||||
info!(
|
||||
"APRS-IS stats: received={}, forwarded={}, skipped={}, write_errors={}, reconnects={}",
|
||||
stats_received, stats_forwarded, stats_skipped,
|
||||
stats_write_errors, stats_reconnects
|
||||
);
|
||||
}
|
||||
|
||||
_ = beacon_tick.tick() => {
|
||||
if let Some(pkt) = &beacon_packet {
|
||||
if let Err(e) = write_half.write_all(pkt.as_bytes()).await {
|
||||
warn!("APRS-IS IGate: beacon write failed: {}", e);
|
||||
stats_write_errors += 1;
|
||||
break 'forward;
|
||||
}
|
||||
debug!("APRS-IS IGate: sent position beacon");
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the server feed. The server sends a full APRS stream;
|
||||
// if we never read it the TCP receive buffer fills and stalls
|
||||
// the connection via flow control. EOF triggers reconnect.
|
||||
result = reader.read_line(&mut server_line) => {
|
||||
server_line.clear();
|
||||
match result {
|
||||
Ok(0) | Err(_) => {
|
||||
warn!("APRS-IS IGate: server closed connection");
|
||||
break 'forward;
|
||||
}
|
||||
Ok(_) => {} // discard — we do not gate IS→RF
|
||||
}
|
||||
}
|
||||
|
||||
recv = decode_rx.recv() => {
|
||||
match recv {
|
||||
Ok(DecodedMessage::Aprs(pkt)) | Ok(DecodedMessage::HfAprs(pkt)) => {
|
||||
stats_received += 1;
|
||||
if !pkt.crc_ok {
|
||||
stats_skipped += 1;
|
||||
continue 'forward;
|
||||
}
|
||||
// Guard against history replays: skip packets whose timestamp
|
||||
// indicates they are older than 2 minutes. Live RF-decoded
|
||||
// packets arrive within milliseconds; history items can be
|
||||
// up to 24 hours old.
|
||||
if let Some(ts_ms) = pkt.ts_ms {
|
||||
let now_ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
if now_ms.saturating_sub(ts_ms) > 120_000 {
|
||||
stats_skipped += 1;
|
||||
continue 'forward;
|
||||
}
|
||||
}
|
||||
// Loop prevention: do not re-gate packets that already
|
||||
// passed through APRS-IS (TCPIP or TCPXX in path).
|
||||
if pkt.path.contains("TCPIP") || pkt.path.contains("TCPXX") {
|
||||
stats_skipped += 1;
|
||||
continue 'forward;
|
||||
}
|
||||
let tnc2 = format_tnc2(&pkt, &callsign);
|
||||
debug!("APRS-IS: forwarded {}>{},...", pkt.src_call, pkt.dest_call);
|
||||
if let Err(e) = write_half.write_all(tnc2.as_bytes()).await {
|
||||
warn!("APRS-IS IGate: packet write failed: {}", e);
|
||||
stats_write_errors += 1;
|
||||
break 'forward;
|
||||
}
|
||||
stats_forwarded += 1;
|
||||
}
|
||||
Ok(_) => {
|
||||
// Non-APRS messages (FT8, WSPR, CW) are silently skipped
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("APRS-IS IGate: dropped {} decode events (channel lagged)", n);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forward loop exited due to a write error or server EOF — reconnect with backoff
|
||||
stats_reconnects += 1;
|
||||
warn!(
|
||||
"APRS-IS IGate: disconnected from {}:{}, reconnecting in {}s",
|
||||
cfg.host, cfg.port, backoff_secs
|
||||
);
|
||||
time::sleep(Duration::from_secs(backoff_secs)).await;
|
||||
backoff_secs = (backoff_secs * 2).min(60);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use trx_core::decode::AprsPacket;
|
||||
|
||||
fn make_pkt(src: &str, dest: &str, path: &str, info: &str, crc_ok: bool) -> AprsPacket {
|
||||
AprsPacket {
|
||||
rig_id: None,
|
||||
ts_ms: None,
|
||||
src_call: src.to_string(),
|
||||
dest_call: dest.to_string(),
|
||||
path: path.to_string(),
|
||||
info: info.to_string(),
|
||||
info_bytes: vec![],
|
||||
packet_type: "Unknown".to_string(),
|
||||
crc_ok,
|
||||
lat: None,
|
||||
lon: None,
|
||||
symbol_table: None,
|
||||
symbol_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passcode_result_in_valid_range() {
|
||||
assert!(compute_passcode("N0CALL") <= 0x7fff);
|
||||
assert!(compute_passcode("W1AW") <= 0x7fff);
|
||||
assert!(compute_passcode("SP2SJG") <= 0x7fff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passcode_strips_ssid() {
|
||||
assert_eq!(compute_passcode("N0CALL"), compute_passcode("N0CALL-9"));
|
||||
assert_eq!(compute_passcode("W1AW"), compute_passcode("W1AW-5"));
|
||||
assert_eq!(compute_passcode("SP2SJG"), compute_passcode("SP2SJG-15"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passcode_case_insensitive() {
|
||||
assert_eq!(compute_passcode("n0call"), compute_passcode("N0CALL"));
|
||||
assert_eq!(compute_passcode("sp2sjg"), compute_passcode("SP2SJG"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passcode_truncates_to_ten_chars() {
|
||||
// Callsigns are at most 10 chars after stripping SSID; extra chars must be ignored
|
||||
assert_eq!(
|
||||
compute_passcode("ABCDEFGHIJ"),
|
||||
compute_passcode("ABCDEFGHIJKL")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tnc2_with_path_adds_qar() {
|
||||
let pkt = make_pkt(
|
||||
"N0CALL-9",
|
||||
"APRS",
|
||||
"WIDE1-1,WIDE2-1",
|
||||
"!1234.56N/01234.56E-Test",
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
format_tnc2(&pkt, "SP2SJG"),
|
||||
"N0CALL-9>APRS,WIDE1-1,WIDE2-1,qAR,SP2SJG:!1234.56N/01234.56E-Test\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tnc2_without_path_adds_qar() {
|
||||
let pkt = make_pkt("W1AW", "BEACON", "", ">Test status", true);
|
||||
assert_eq!(
|
||||
format_tnc2(&pkt, "SP2SJG"),
|
||||
"W1AW>BEACON,qAR,SP2SJG:>Test status\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beacon_format_house_symbol() {
|
||||
let s = format_beacon("SP2SJG-10", 52.2297, 21.0122, '/', '-');
|
||||
assert_eq!(
|
||||
s,
|
||||
format!(
|
||||
"SP2SJG-10>APRS,TCPIP*:!5213.78N/02100.73E-trx-rs v{} by SP2SJG\r\n",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beacon_format_southern_western() {
|
||||
let s = format_beacon("VK2ABC-10", -33.8688, 151.2093, '/', '-');
|
||||
assert!(s.contains('S'));
|
||||
assert!(s.contains('E'));
|
||||
assert!(s.contains("trx-rs v"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aprs_lat_format() {
|
||||
assert_eq!(format_aprs_lat(52.2297), "5213.78N");
|
||||
assert_eq!(format_aprs_lat(-33.8688), "3352.13S");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aprs_lon_format() {
|
||||
assert_eq!(format_aprs_lon(21.0122), "02100.73E");
|
||||
assert_eq!(format_aprs_lon(-87.6298), "08737.79W");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Reporting uplink tasks: PSK Reporter and APRS-IS IGate.
|
||||
|
||||
pub mod aprsfi;
|
||||
pub mod pskreporter;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// PSK Reporter uplink configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PskReporterConfig {
|
||||
/// Whether PSK Reporter uplink is enabled
|
||||
pub enabled: bool,
|
||||
/// PSK Reporter host
|
||||
pub host: String,
|
||||
/// PSK Reporter UDP port
|
||||
pub port: u16,
|
||||
/// Receiver locator (Maidenhead, 4 or 6 chars). If omitted, derived from
|
||||
/// [general].latitude/[general].longitude when available.
|
||||
pub receiver_locator: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for PskReporterConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
host: "report.pskreporter.info".to_string(),
|
||||
port: 4739,
|
||||
receiver_locator: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// APRS-IS IGate uplink configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AprsFiConfig {
|
||||
/// Whether APRS-IS IGate uplink is enabled
|
||||
pub enabled: bool,
|
||||
/// APRS-IS server hostname
|
||||
pub host: String,
|
||||
/// APRS-IS server port
|
||||
pub port: u16,
|
||||
/// APRS-IS passcode. -1 = auto-compute from callsign.
|
||||
pub passcode: i32,
|
||||
/// IGate callsign. Overrides [general].callsign when set.
|
||||
pub callsign: Option<String>,
|
||||
/// Send periodic position beacons for this IGate station.
|
||||
/// Requires [general].latitude/longitude (or [aprsfi].latitude/longitude).
|
||||
pub beacon: bool,
|
||||
/// How often to send a position beacon, in seconds. Default: 1200 (20 min).
|
||||
pub beacon_interval_secs: u64,
|
||||
/// APRS symbol table identifier: "/" = primary, "\\" = alternate.
|
||||
pub beacon_symbol_table: char,
|
||||
/// APRS symbol code. E.g. '-' = house, '&' = diamond/gateway, 'I' = IGate.
|
||||
pub beacon_symbol_code: char,
|
||||
/// Beacon latitude override (decimal degrees). Falls back to [general].latitude.
|
||||
pub latitude: Option<f64>,
|
||||
/// Beacon longitude override (decimal degrees). Falls back to [general].longitude.
|
||||
pub longitude: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for AprsFiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
host: "rotate.aprs.net".to_string(),
|
||||
port: 14580,
|
||||
passcode: -1,
|
||||
callsign: None,
|
||||
beacon: false,
|
||||
beacon_interval_secs: 1200,
|
||||
beacon_symbol_table: '/',
|
||||
beacon_symbol_code: '-',
|
||||
latitude: None,
|
||||
longitude: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::{broadcast, watch};
|
||||
use tokio::time::{self, Duration, Instant};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use trx_core::decode::DecodedMessage;
|
||||
use trx_core::rig::state::RigState;
|
||||
|
||||
use crate::PskReporterConfig;
|
||||
|
||||
const PSK_REPORTER_IDENTIFIER: u16 = 0x000A;
|
||||
const RECEIVER_FLOWSET: u16 = 0x9992;
|
||||
const SENDER_FLOWSET: u16 = 0x9993;
|
||||
|
||||
// Receiver: Options Template Set (FlowSetID=0x0003, 36 bytes total)
|
||||
// Template ID 0x9992 with 3 fields, scope count 1.
|
||||
// Fields: receiverCallsign (30351.2), receiverLocator (30351.4), decoderSoftware (30351.8)
|
||||
const RECEIVER_TEMPLATE: &[u8] = &[
|
||||
0x00, 0x03, 0x00, 0x24, // FlowSetID=3, Length=36
|
||||
0x99, 0x92, 0x00, 0x03, 0x00, 0x01, // TemplateID=0x9992, FieldCount=3, ScopeCount=1
|
||||
0x80, 0x02, 0xFF, 0xFF, 0x00, 0x00, 0x76, 0x8F, // receiverCallsign (30351.2), variable
|
||||
0x80, 0x04, 0xFF, 0xFF, 0x00, 0x00, 0x76, 0x8F, // receiverLocator (30351.4), variable
|
||||
0x80, 0x08, 0xFF, 0xFF, 0x00, 0x00, 0x76, 0x8F, // decoderSoftware (30351.8), variable
|
||||
0x00, 0x00, // padding to 4-byte boundary
|
||||
];
|
||||
|
||||
// Sender: Template Set (FlowSetID=0x0002, 68 bytes total)
|
||||
// Template ID 0x9993 with 8 fields.
|
||||
// Fields: senderCallsign, frequency, sNR, iMD, mode, informationSource, senderLocator,
|
||||
// flowStartSeconds
|
||||
const SENDER_TEMPLATE: &[u8] = &[
|
||||
0x00, 0x02, 0x00, 0x44, // FlowSetID=2, Length=68
|
||||
0x99, 0x93, 0x00, 0x08, // TemplateID=0x9993, FieldCount=8
|
||||
0x80, 0x01, 0xFF, 0xFF, 0x00, 0x00, 0x76, 0x8F, // senderCallsign (30351.1), variable
|
||||
0x80, 0x05, 0x00, 0x04, 0x00, 0x00, 0x76, 0x8F, // frequency (30351.5), 4 bytes
|
||||
0x80, 0x06, 0x00, 0x01, 0x00, 0x00, 0x76, 0x8F, // sNR (30351.6), 1 byte
|
||||
0x80, 0x07, 0x00, 0x01, 0x00, 0x00, 0x76, 0x8F, // iMD (30351.7), 1 byte
|
||||
0x80, 0x0A, 0xFF, 0xFF, 0x00, 0x00, 0x76, 0x8F, // mode (30351.10), variable
|
||||
0x80, 0x0B, 0x00, 0x01, 0x00, 0x00, 0x76, 0x8F, // informationSource (30351.11), 1 byte
|
||||
0x80, 0x03, 0xFF, 0xFF, 0x00, 0x00, 0x76, 0x8F, // senderLocator (30351.3), variable
|
||||
0x00, 0x96, 0x00, 0x04, // flowStartSeconds (150), 4 bytes
|
||||
];
|
||||
|
||||
// Send at most one packet every 5 minutes per PSKReporter spec.
|
||||
const FLUSH_INTERVAL_SECS: u64 = 300;
|
||||
// Retransmit template descriptors once per hour (plus first 3 packets on startup).
|
||||
const TEMPLATE_RESEND_SECS: u64 = 3600;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Spot {
|
||||
sender_callsign: String,
|
||||
sender_locator: Option<String>,
|
||||
mode: &'static str,
|
||||
snr_db: f32,
|
||||
abs_freq_hz: u64,
|
||||
flow_start_seconds: u32,
|
||||
}
|
||||
|
||||
pub async fn run_pskreporter_uplink(
|
||||
cfg: PskReporterConfig,
|
||||
receiver_callsign: String,
|
||||
latitude: Option<f64>,
|
||||
longitude: Option<f64>,
|
||||
mut state_rx: watch::Receiver<RigState>,
|
||||
mut decode_rx: broadcast::Receiver<DecodedMessage>,
|
||||
) {
|
||||
let receiver_locator = match cfg.receiver_locator.clone().or_else(|| {
|
||||
if let (Some(lat), Some(lon)) = (latitude, longitude) {
|
||||
Some(maidenhead_from_lat_lon(lat, lon))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
Some(locator) => locator,
|
||||
None => {
|
||||
warn!(
|
||||
"PSK Reporter enabled but receiver locator is missing \
|
||||
([pskreporter].receiver_locator or [general].latitude/longitude)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let software = format!("trx-rs v{} by SP2SJG", env!("CARGO_PKG_VERSION"));
|
||||
let mut client = match PskReporterClient::connect(
|
||||
&cfg.host,
|
||||
cfg.port,
|
||||
receiver_callsign.clone(),
|
||||
receiver_locator.clone(),
|
||||
software,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
warn!("PSK Reporter init failed: {}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"PSK Reporter uplink active ({}:{} as {} / {})",
|
||||
cfg.host, cfg.port, receiver_callsign, receiver_locator
|
||||
);
|
||||
|
||||
let mut current_freq_hz = state_rx.borrow().status.freq.hz;
|
||||
// Deduplicated pending spots: callsign → most-recent Spot.
|
||||
let mut pending: HashMap<String, Spot> = HashMap::new();
|
||||
let mut stats_received: u64 = 0;
|
||||
let mut stats_sent: u64 = 0;
|
||||
let mut stats_skipped: u64 = 0;
|
||||
let mut stats_send_err: u64 = 0;
|
||||
let mut stats_tick = time::interval(Duration::from_secs(60));
|
||||
// Delay first flush by FLUSH_INTERVAL_SECS so we accumulate a useful batch.
|
||||
let mut flush_tick = time::interval_at(
|
||||
Instant::now() + Duration::from_secs(FLUSH_INTERVAL_SECS),
|
||||
Duration::from_secs(FLUSH_INTERVAL_SECS),
|
||||
);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = stats_tick.tick() => {
|
||||
info!(
|
||||
"PSK Reporter stats: received={}, sent={}, skipped={}, \
|
||||
send_errors={}, pending={}",
|
||||
stats_received, stats_sent, stats_skipped, stats_send_err,
|
||||
pending.len()
|
||||
);
|
||||
}
|
||||
_ = flush_tick.tick() => {
|
||||
if !pending.is_empty() {
|
||||
let spots: Vec<Spot> = pending.drain().map(|(_, v)| v).collect();
|
||||
let n = spots.len() as u64;
|
||||
if let Err(err) = client.send_spots(&spots).await {
|
||||
warn!("PSK Reporter send failed: {}", err);
|
||||
stats_send_err += 1;
|
||||
} else {
|
||||
stats_sent += n;
|
||||
}
|
||||
}
|
||||
}
|
||||
changed = state_rx.changed() => {
|
||||
if changed.is_err() {
|
||||
break;
|
||||
}
|
||||
current_freq_hz = state_rx.borrow().status.freq.hz;
|
||||
}
|
||||
recv = decode_rx.recv() => {
|
||||
let decoded = match recv {
|
||||
Ok(v) => v,
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("PSK Reporter: dropped {} decode events", n);
|
||||
continue;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
};
|
||||
stats_received += 1;
|
||||
|
||||
let spot = match decoded_to_spot(decoded, current_freq_hz) {
|
||||
Some(spot) => spot,
|
||||
None => {
|
||||
stats_skipped += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Guard against history replays: reject any message whose timestamp
|
||||
// is older than the flush window. Live FT8/WSPR messages are at most
|
||||
// a few seconds old; history items can be up to 24 hours old.
|
||||
let age = now_unix_seconds().saturating_sub(spot.flow_start_seconds);
|
||||
if age > FLUSH_INTERVAL_SECS as u32 {
|
||||
stats_skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// Keep only the most-recent spot per callsign within the window.
|
||||
pending.insert(spot.sender_callsign.clone(), spot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decoded_to_spot(decoded: DecodedMessage, base_freq_hz: u64) -> Option<Spot> {
|
||||
match decoded {
|
||||
DecodedMessage::Ft8(msg) => {
|
||||
let sender_callsign = parse_sender_callsign_ft8(&msg.message)?;
|
||||
let sender_locator = parse_locator(&msg.message);
|
||||
let abs_freq_hz = offset_to_abs(base_freq_hz, msg.freq_hz);
|
||||
Some(Spot {
|
||||
sender_callsign,
|
||||
sender_locator,
|
||||
mode: "FT8",
|
||||
snr_db: msg.snr_db,
|
||||
abs_freq_hz,
|
||||
flow_start_seconds: ts_ms_to_secs(msg.ts_ms),
|
||||
})
|
||||
}
|
||||
DecodedMessage::Wspr(msg) => {
|
||||
let sender_callsign = parse_sender_callsign_wspr(&msg.message)?;
|
||||
let sender_locator = parse_locator(&msg.message);
|
||||
let abs_freq_hz = offset_to_abs(base_freq_hz, msg.freq_hz);
|
||||
Some(Spot {
|
||||
sender_callsign,
|
||||
sender_locator,
|
||||
mode: "WSPR",
|
||||
snr_db: msg.snr_db,
|
||||
abs_freq_hz,
|
||||
flow_start_seconds: ts_ms_to_secs(msg.ts_ms),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_ms_to_secs(ts_ms: i64) -> u32 {
|
||||
if ts_ms <= 0 {
|
||||
return now_unix_seconds();
|
||||
}
|
||||
(ts_ms / 1000).clamp(0, u32::MAX as i64) as u32
|
||||
}
|
||||
|
||||
fn now_unix_seconds() -> u32 {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
secs.min(u32::MAX as u64) as u32
|
||||
}
|
||||
|
||||
fn offset_to_abs(base_freq_hz: u64, offset_hz: f32) -> u64 {
|
||||
// Accept both legacy decoder offsets (~kHz audio tones) and already-absolute RF Hz.
|
||||
let raw = offset_hz as f64;
|
||||
if raw.is_finite() && raw >= 100_000.0 {
|
||||
return raw.round() as u64;
|
||||
}
|
||||
let freq = base_freq_hz as f64 + raw;
|
||||
if freq.is_finite() && freq > 0.0 {
|
||||
freq.round() as u64
|
||||
} else {
|
||||
base_freq_hz
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_sender_callsign_ft8(message: &str) -> Option<String> {
|
||||
let tokens: Vec<String> = message.split_whitespace().map(normalize_token).collect();
|
||||
if tokens.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let head = tokens[0].as_str();
|
||||
if matches!(head, "CQ" | "QRZ" | "DE") {
|
||||
if let Some(second) = tokens.get(1) {
|
||||
if is_callsign(second) {
|
||||
return Some(second.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Directed FT8/FT4-style messages are usually "<target> <source> ...".
|
||||
if let (Some(first), Some(second)) = (tokens.first(), tokens.get(1)) {
|
||||
if is_callsign(first) && is_callsign(second) {
|
||||
return Some(second.clone());
|
||||
}
|
||||
}
|
||||
tokens.into_iter().find(|t| is_callsign(t))
|
||||
}
|
||||
|
||||
fn parse_sender_callsign_wspr(message: &str) -> Option<String> {
|
||||
message
|
||||
.split_whitespace()
|
||||
.map(normalize_token)
|
||||
.find(|t| is_callsign(t))
|
||||
}
|
||||
|
||||
fn normalize_token(token: &str) -> String {
|
||||
token
|
||||
.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '/')
|
||||
.to_ascii_uppercase()
|
||||
}
|
||||
|
||||
fn parse_locator(message: &str) -> Option<String> {
|
||||
message.split_whitespace().find_map(|raw| {
|
||||
let t = normalize_token(raw);
|
||||
if !is_ftx_farewell_token(&t) && is_locator(&t) {
|
||||
Some(t)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn is_callsign(token: &str) -> bool {
|
||||
if token.len() < 3 || token.len() > 13 {
|
||||
return false;
|
||||
}
|
||||
if matches!(token, "CQ" | "QRZ" | "DE") {
|
||||
return false;
|
||||
}
|
||||
if !token
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '/')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
token.chars().any(|c| c.is_ascii_uppercase()) && token.chars().any(|c| c.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn is_locator(token: &str) -> bool {
|
||||
if !(token.len() == 4 || token.len() == 6) {
|
||||
return false;
|
||||
}
|
||||
let b = token.as_bytes();
|
||||
if !(b[0].is_ascii_uppercase()
|
||||
&& (b[0] as char) >= 'A'
|
||||
&& (b[0] as char) <= 'R'
|
||||
&& b[1].is_ascii_uppercase()
|
||||
&& (b[1] as char) >= 'A'
|
||||
&& (b[1] as char) <= 'R')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !(b[2].is_ascii_digit() && b[3].is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
if token.len() == 6 {
|
||||
(b[4] as char).is_ascii_uppercase()
|
||||
&& (b[4] as char) >= 'A'
|
||||
&& (b[4] as char) <= 'X'
|
||||
&& (b[5] as char).is_ascii_uppercase()
|
||||
&& (b[5] as char) >= 'A'
|
||||
&& (b[5] as char) <= 'X'
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ftx_farewell_token(token: &str) -> bool {
|
||||
matches!(token, "RR73" | "73" | "RR")
|
||||
}
|
||||
|
||||
fn maidenhead_from_lat_lon(lat: f64, lon: f64) -> String {
|
||||
let lat = lat.clamp(-90.0, 90.0 - f64::EPSILON);
|
||||
let lon = lon.clamp(-180.0, 180.0 - f64::EPSILON);
|
||||
let mut adj_lon = lon + 180.0;
|
||||
let mut adj_lat = lat + 90.0;
|
||||
|
||||
let field_lon = (adj_lon / 20.0).floor() as u8;
|
||||
let field_lat = (adj_lat / 10.0).floor() as u8;
|
||||
adj_lon -= (field_lon as f64) * 20.0;
|
||||
adj_lat -= (field_lat as f64) * 10.0;
|
||||
|
||||
let square_lon = (adj_lon / 2.0).floor() as u8;
|
||||
let square_lat = adj_lat.floor() as u8;
|
||||
adj_lon -= (square_lon as f64) * 2.0;
|
||||
adj_lat -= square_lat as f64;
|
||||
|
||||
let subsquare_lon = (adj_lon / (5.0 / 60.0)).floor() as u8;
|
||||
let subsquare_lat = (adj_lat / (2.5 / 60.0)).floor() as u8;
|
||||
|
||||
format!(
|
||||
"{}{}{}{}{}{}",
|
||||
(b'A' + field_lon) as char,
|
||||
(b'A' + field_lat) as char,
|
||||
(b'0' + square_lon) as char,
|
||||
(b'0' + square_lat) as char,
|
||||
(b'A' + subsquare_lon.min(23)) as char,
|
||||
(b'A' + subsquare_lat.min(23)) as char
|
||||
)
|
||||
}
|
||||
|
||||
struct PskReporterClient {
|
||||
socket: UdpSocket,
|
||||
receiver_callsign: String,
|
||||
receiver_locator: String,
|
||||
software: String,
|
||||
sequence: u32,
|
||||
session: u32,
|
||||
packets_sent: u32,
|
||||
last_template_instant: Option<Instant>,
|
||||
}
|
||||
|
||||
impl PskReporterClient {
|
||||
async fn connect(
|
||||
host: &str,
|
||||
port: u16,
|
||||
receiver_callsign: String,
|
||||
receiver_locator: String,
|
||||
software: String,
|
||||
) -> Result<Self, String> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")
|
||||
.await
|
||||
.map_err(|e| format!("bind failed: {e}"))?;
|
||||
socket
|
||||
.connect((host, port))
|
||||
.await
|
||||
.map_err(|e| format!("connect {}:{} failed: {}", host, port, e))?;
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let session = (now.as_secs() as u32) ^ now.subsec_nanos() ^ (std::process::id() << 7);
|
||||
|
||||
Ok(Self {
|
||||
socket,
|
||||
receiver_callsign,
|
||||
receiver_locator,
|
||||
software,
|
||||
sequence: 1,
|
||||
session,
|
||||
packets_sent: 0,
|
||||
last_template_instant: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_spots(&mut self, spots: &[Spot]) -> Result<(), String> {
|
||||
// Include template descriptors in first 3 packets and once per hour thereafter.
|
||||
let include_templates = self.packets_sent < 3
|
||||
|| self
|
||||
.last_template_instant
|
||||
.is_none_or(|t| t.elapsed() >= Duration::from_secs(TEMPLATE_RESEND_SECS));
|
||||
|
||||
let packet = self.make_packet(spots, include_templates)?;
|
||||
self.socket
|
||||
.send(&packet)
|
||||
.await
|
||||
.map_err(|e| format!("send failed: {e}"))?;
|
||||
|
||||
self.packets_sent += 1;
|
||||
// Sequence number = count of reports submitted (not packets).
|
||||
self.sequence = self.sequence.wrapping_add(spots.len() as u32);
|
||||
if include_templates {
|
||||
self.last_template_instant = Some(Instant::now());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_packet(&self, spots: &[Spot], include_templates: bool) -> Result<Vec<u8>, String> {
|
||||
let now = now_unix_seconds();
|
||||
let mut out = Vec::with_capacity(512);
|
||||
|
||||
// IPFIX message header (16 bytes) — total length patched at the end.
|
||||
push_u16_be(&mut out, PSK_REPORTER_IDENTIFIER); // version 0x000A
|
||||
push_u16_be(&mut out, 0); // length — patched later
|
||||
push_u32_be(&mut out, now);
|
||||
push_u32_be(&mut out, self.sequence);
|
||||
push_u32_be(&mut out, self.session);
|
||||
|
||||
// Template descriptor blocks (optional after first 3 packets).
|
||||
if include_templates {
|
||||
out.extend_from_slice(RECEIVER_TEMPLATE);
|
||||
out.extend_from_slice(SENDER_TEMPLATE);
|
||||
}
|
||||
|
||||
// Receiver information data record (FlowSetID 0x9992).
|
||||
let mut rx_data: Vec<u8> = Vec::new();
|
||||
push_prefixed_string(&mut rx_data, &self.receiver_callsign)?;
|
||||
push_prefixed_string(&mut rx_data, &self.receiver_locator)?;
|
||||
push_prefixed_string(&mut rx_data, &self.software)?;
|
||||
pad_to_4(&mut rx_data);
|
||||
push_u16_be(&mut out, RECEIVER_FLOWSET);
|
||||
push_u16_be(&mut out, (rx_data.len() + 4) as u16); // length includes 4-byte set header
|
||||
out.extend_from_slice(&rx_data);
|
||||
|
||||
// Sender information data records (FlowSetID 0x9993).
|
||||
// Field order must match SENDER_TEMPLATE:
|
||||
// senderCallsign, frequency, sNR, iMD, mode, informationSource,
|
||||
// senderLocator, flowStartSeconds
|
||||
let mut tx_data: Vec<u8> = Vec::new();
|
||||
for spot in spots {
|
||||
push_prefixed_string(&mut tx_data, &spot.sender_callsign)?;
|
||||
push_u32_be(&mut tx_data, spot.abs_freq_hz.min(u32::MAX as u64) as u32);
|
||||
tx_data.push(spot.snr_db.round().clamp(-128.0, 127.0) as i8 as u8);
|
||||
tx_data.push(0u8); // iMD — not available from FT8/WSPR decoders
|
||||
push_prefixed_string(&mut tx_data, spot.mode)?;
|
||||
tx_data.push(1u8); // informationSource = 1 (automatically extracted)
|
||||
push_prefixed_string(&mut tx_data, spot.sender_locator.as_deref().unwrap_or(""))?;
|
||||
push_u32_be(&mut tx_data, spot.flow_start_seconds);
|
||||
}
|
||||
pad_to_4(&mut tx_data);
|
||||
push_u16_be(&mut out, SENDER_FLOWSET);
|
||||
push_u16_be(&mut out, (tx_data.len() + 4) as u16);
|
||||
out.extend_from_slice(&tx_data);
|
||||
|
||||
// Patch total packet length into header bytes [2..3].
|
||||
let len = out.len();
|
||||
if len > u16::MAX as usize {
|
||||
return Err("PSK Reporter packet too large".to_string());
|
||||
}
|
||||
let be = (len as u16).to_be_bytes();
|
||||
out[2] = be[0];
|
||||
out[3] = be[1];
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pad `buf` with null bytes until its length is a multiple of 4.
|
||||
fn pad_to_4(buf: &mut Vec<u8>) {
|
||||
let rem = buf.len() % 4;
|
||||
if rem != 0 {
|
||||
buf.extend(std::iter::repeat_n(0u8, 4 - rem));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_u16_be(buf: &mut Vec<u8>, value: u16) {
|
||||
buf.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn push_u32_be(buf: &mut Vec<u8>, value: u32) {
|
||||
buf.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn push_prefixed_string(buf: &mut Vec<u8>, value: &str) -> Result<(), String> {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.len() > 254 {
|
||||
return Err(format!("string too long for PSK Reporter field: {}", value));
|
||||
}
|
||||
buf.push(bytes.len() as u8);
|
||||
buf.extend_from_slice(bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_ft8_sender_and_locator() {
|
||||
assert_eq!(
|
||||
parse_sender_callsign_ft8("CQ SP2SJG JO93"),
|
||||
Some("SP2SJG".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_sender_callsign_ft8("K1ABC SP2SJG JO93"),
|
||||
Some("SP2SJG".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_sender_callsign_ft8("K1ABC SP2SJG -07"),
|
||||
Some("SP2SJG".to_string())
|
||||
);
|
||||
assert_eq!(parse_locator("CQ SP2SJG JO93"), Some("JO93".to_string()));
|
||||
assert_eq!(parse_locator("CQ SP2SJG RR73"), None);
|
||||
assert_eq!(parse_locator("SP2SJG RR 73"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_wspr_sender() {
|
||||
assert_eq!(
|
||||
parse_sender_callsign_wspr("SP2SJG JO93 37"),
|
||||
Some("SP2SJG".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maidenhead_is_six_chars() {
|
||||
let grid = maidenhead_from_lat_lon(52.2297, 21.0122);
|
||||
assert_eq!(grid.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_to_abs_accepts_offset_and_absolute() {
|
||||
assert_eq!(offset_to_abs(14_074_000, 1_237.0), 14_075_237);
|
||||
assert_eq!(offset_to_abs(14_074_000, 14_075_237.0), 14_075_237);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receiver_template_length_correct() {
|
||||
assert_eq!(RECEIVER_TEMPLATE.len(), 36);
|
||||
let len = u16::from_be_bytes([RECEIVER_TEMPLATE[2], RECEIVER_TEMPLATE[3]]);
|
||||
assert_eq!(len as usize, RECEIVER_TEMPLATE.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sender_template_length_correct() {
|
||||
assert_eq!(SENDER_TEMPLATE.len(), 68);
|
||||
let len = u16::from_be_bytes([SENDER_TEMPLATE[2], SENDER_TEMPLATE[3]]);
|
||||
assert_eq!(len as usize, SENDER_TEMPLATE.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maidenhead_known_grids() {
|
||||
// Warsaw, Poland → JO91 subsquare
|
||||
let grid = maidenhead_from_lat_lon(52.2297, 21.0122);
|
||||
assert!(grid.starts_with("KO02"));
|
||||
// New York → FN20
|
||||
let grid = maidenhead_from_lat_lon(40.7128, -74.0060);
|
||||
assert!(grid.starts_with("FN20"));
|
||||
// Sydney → QF56
|
||||
let grid = maidenhead_from_lat_lon(-33.8688, 151.2093);
|
||||
assert!(grid.starts_with("QF56"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_to_spot_ft8() {
|
||||
use trx_core::decode::{DecodedMessage, Ft8Message};
|
||||
let msg = Ft8Message {
|
||||
rig_id: None,
|
||||
ts_ms: 1_700_000_000_000,
|
||||
snr_db: -12.0,
|
||||
dt_s: 0.1,
|
||||
freq_hz: 1234.0,
|
||||
message: "CQ SP2SJG JO93".to_string(),
|
||||
};
|
||||
let spot = decoded_to_spot(DecodedMessage::Ft8(msg), 14_074_000).unwrap();
|
||||
assert_eq!(spot.sender_callsign, "SP2SJG");
|
||||
assert_eq!(spot.sender_locator, Some("JO93".to_string()));
|
||||
assert_eq!(spot.mode, "FT8");
|
||||
assert_eq!(spot.abs_freq_hz, 14_075_234);
|
||||
assert_eq!(spot.snr_db, -12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_to_spot_wspr() {
|
||||
use trx_core::decode::{DecodedMessage, WsprMessage};
|
||||
let msg = WsprMessage {
|
||||
rig_id: None,
|
||||
ts_ms: 1_700_000_000_000,
|
||||
snr_db: -24.0,
|
||||
dt_s: 0.0,
|
||||
freq_hz: 500.0,
|
||||
message: "SP2SJG JO93 37".to_string(),
|
||||
};
|
||||
let spot = decoded_to_spot(DecodedMessage::Wspr(msg), 7_040_000).unwrap();
|
||||
assert_eq!(spot.sender_callsign, "SP2SJG");
|
||||
assert_eq!(spot.mode, "WSPR");
|
||||
assert_eq!(spot.abs_freq_hz, 7_040_500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_to_spot_cw_returns_none() {
|
||||
use trx_core::decode::{CwEvent, DecodedMessage};
|
||||
let evt = CwEvent {
|
||||
rig_id: None,
|
||||
text: "CQ".to_string(),
|
||||
wpm: 20,
|
||||
tone_hz: 700,
|
||||
signal_on: false,
|
||||
};
|
||||
assert!(decoded_to_spot(DecodedMessage::Cw(evt), 7_000_000).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_callsign_accepts_valid() {
|
||||
assert!(is_callsign("SP2SJG"));
|
||||
assert!(is_callsign("W1AW"));
|
||||
assert!(is_callsign("VK2ABC"));
|
||||
assert!(is_callsign("JA1ABC"));
|
||||
assert!(is_callsign("4X1RF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_callsign_rejects_invalid() {
|
||||
assert!(!is_callsign("CQ"));
|
||||
assert!(!is_callsign("QRZ"));
|
||||
assert!(!is_callsign("DE"));
|
||||
assert!(!is_callsign("AB")); // too short
|
||||
assert!(!is_callsign("ABCDEFGHIJKLMN")); // too long
|
||||
assert!(!is_callsign("HELLO")); // no digits
|
||||
assert!(!is_callsign("12345")); // no letters
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_locator_valid() {
|
||||
assert!(is_locator("JO93"));
|
||||
assert!(is_locator("FN30"));
|
||||
assert!(is_locator("JO93AB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_locator_invalid() {
|
||||
assert!(!is_locator("ZZ99")); // Z > R
|
||||
assert!(!is_locator("JO9")); // too short
|
||||
assert!(!is_locator("JO93ABX")); // too long
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_to_4_pads_correctly() {
|
||||
let mut v = vec![1, 2, 3];
|
||||
pad_to_4(&mut v);
|
||||
assert_eq!(v.len(), 4);
|
||||
assert_eq!(v[3], 0);
|
||||
|
||||
let mut v = vec![1, 2, 3, 4];
|
||||
pad_to_4(&mut v);
|
||||
assert_eq!(v.len(), 4); // already aligned
|
||||
|
||||
let mut v = vec![1];
|
||||
pad_to_4(&mut v);
|
||||
assert_eq!(v.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_prefixed_string_encodes_length() {
|
||||
let mut buf = Vec::new();
|
||||
push_prefixed_string(&mut buf, "SP2SJG").unwrap();
|
||||
assert_eq!(buf[0], 6); // length prefix
|
||||
assert_eq!(&buf[1..], b"SP2SJG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_prefixed_string_rejects_too_long() {
|
||||
let mut buf = Vec::new();
|
||||
let long = "A".repeat(255);
|
||||
assert!(push_prefixed_string(&mut buf, &long).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ft8_callsign_directed_message() {
|
||||
// "K1ABC SP2SJG R-07" → sender is SP2SJG (second token in directed msg)
|
||||
assert_eq!(
|
||||
parse_sender_callsign_ft8("K1ABC SP2SJG R-07"),
|
||||
Some("SP2SJG".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ft8_callsign_cq_with_region() {
|
||||
// Some FT8 messages have "CQ DX SP2SJG JO93"
|
||||
// The second token "DX" is not a callsign, so it should find SP2SJG
|
||||
assert_eq!(
|
||||
parse_sender_callsign_ft8("CQ DX SP2SJG JO93"),
|
||||
Some("SP2SJG".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user