From 40e99d3ea9d1aa58f31f3766a88d4a74e3cf827a Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 14 Mar 2026 09:49:29 +0100 Subject: [PATCH] [feat](trx-reporting): add APRS-IS position beaconing Add periodic IGate position beacon support to the APRS-IS uplink. New AprsFiConfig fields: beacon (bool), beacon_interval_secs (default 1200), beacon_symbol (default "/-"), latitude/longitude overrides. A beacon is sent immediately on connect then every beacon_interval_secs. Coordinates fall back from [aprsfi] to [general].latitude/longitude. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Stan Grams --- src/trx-reporting/src/aprsfi.rs | 104 +++++++++++++++++++++++++++++++- src/trx-reporting/src/lib.rs | 17 ++++++ src/trx-server/src/main.rs | 2 +- 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src/trx-reporting/src/aprsfi.rs b/src/trx-reporting/src/aprsfi.rs index 03e30e3..6f32d8b 100644 --- a/src/trx-reporting/src/aprsfi.rs +++ b/src/trx-reporting/src/aprsfi.rs @@ -9,7 +9,7 @@ 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}; +use tokio::time::{self, Duration, Instant}; use tracing::{debug, info, warn}; use trx_core::decode::{AprsPacket, DecodedMessage}; @@ -63,6 +63,42 @@ fn format_tnc2(pkt: &AprsPacket, igate_call: &str) -> String { } } +/// 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). +fn format_beacon(callsign: &str, lat: f64, lon: f64, symbol: &str) -> String { + let sym_table = symbol.chars().next().unwrap_or('/'); + let sym_code = symbol.chars().nth(1).unwrap_or('-'); + format!( + "{}>APRS,TCPIP*:!{}{}{}{}\r\n", + callsign, + format_aprs_lat(lat), + sym_table, + format_aprs_lon(lon), + sym_code, + ) +} + /// Run the APRS-IS IGate uplink task. /// /// Subscribes to the decoded-message broadcast channel and forwards every @@ -71,6 +107,8 @@ fn format_tnc2(pkt: &AprsPacket, igate_call: &str) -> String { pub async fn run_aprsfi_uplink( cfg: AprsFiConfig, callsign: String, + latitude: Option, + longitude: Option, mut decode_rx: broadcast::Receiver, ) { let passcode: u16 = if cfg.passcode == -1 { @@ -79,6 +117,30 @@ pub async fn run_aprsfi_uplink( (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 = if cfg.beacon { + match coords { + Some((lat, lon)) => Some(format_beacon(&callsign, lat, lon, &cfg.beacon_symbol)), + 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; @@ -191,9 +253,11 @@ pub async fn run_aprsfi_uplink( // Forward loop // ---------------------------------------------------------------- let period = Duration::from_secs(60); - let first_at = time::Instant::now() + period; + 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(); @@ -215,6 +279,17 @@ pub async fn run_aprsfi_uplink( ); } + _ = 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. @@ -365,4 +440,29 @@ mod tests { "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, "SP2SJG-10>APRS,TCPIP*:!5213.78N/02100.73E-\r\n"); + } + + #[test] + fn beacon_format_southern_western() { + let s = format_beacon("VK2ABC-10", -33.8688, 151.2093, "/-"); + assert!(s.contains('S')); + assert!(s.contains('E')); + } + + #[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"); + } } diff --git a/src/trx-reporting/src/lib.rs b/src/trx-reporting/src/lib.rs index fbd44e2..45f1dfb 100644 --- a/src/trx-reporting/src/lib.rs +++ b/src/trx-reporting/src/lib.rs @@ -49,6 +49,18 @@ pub struct AprsFiConfig { pub passcode: i32, /// IGate callsign. Overrides [general].callsign when set. pub callsign: Option, + /// 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 as a two-character string: symbol-table + symbol-code. + /// E.g. "/&" = diamond (gateway), "/-" = house. Default: "/-". + pub beacon_symbol: String, + /// Beacon latitude override (decimal degrees). Falls back to [general].latitude. + pub latitude: Option, + /// Beacon longitude override (decimal degrees). Falls back to [general].longitude. + pub longitude: Option, } impl Default for AprsFiConfig { @@ -59,6 +71,11 @@ impl Default for AprsFiConfig { port: 14580, passcode: -1, callsign: None, + beacon: false, + beacon_interval_secs: 1200, + beacon_symbol: "/-".to_string(), + latitude: None, + longitude: None, } } } diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index d132261..b0bfdd0 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -520,7 +520,7 @@ fn spawn_rig_audio_stack( let ai_shutdown_rx = shutdown_rx.clone(); handles.push(tokio::spawn(async move { tokio::select! { - _ = trx_reporting::aprsfi::run_aprsfi_uplink(ai_cfg, cs, ai_decode_rx) => {} + _ = trx_reporting::aprsfi::run_aprsfi_uplink(ai_cfg, cs, latitude, longitude, ai_decode_rx) => {} _ = wait_for_shutdown(ai_shutdown_rx) => {} } }));