[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 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-14 09:49:29 +01:00
parent 872e086763
commit 40e99d3ea9
3 changed files with 120 additions and 3 deletions
+102 -2
View File
@@ -9,7 +9,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tokio::time::{self, Duration}; use tokio::time::{self, Duration, Instant};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use trx_core::decode::{AprsPacket, DecodedMessage}; 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. /// Run the APRS-IS IGate uplink task.
/// ///
/// Subscribes to the decoded-message broadcast channel and forwards every /// 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( pub async fn run_aprsfi_uplink(
cfg: AprsFiConfig, cfg: AprsFiConfig,
callsign: String, callsign: String,
latitude: Option<f64>,
longitude: Option<f64>,
mut decode_rx: broadcast::Receiver<DecodedMessage>, mut decode_rx: broadcast::Receiver<DecodedMessage>,
) { ) {
let passcode: u16 = if cfg.passcode == -1 { let passcode: u16 = if cfg.passcode == -1 {
@@ -79,6 +117,30 @@ pub async fn run_aprsfi_uplink(
(cfg.passcode as u16) & 0x7fff (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)),
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_received: u64 = 0;
let mut stats_forwarded: u64 = 0; let mut stats_forwarded: u64 = 0;
let mut stats_skipped: u64 = 0; let mut stats_skipped: u64 = 0;
@@ -191,9 +253,11 @@ pub async fn run_aprsfi_uplink(
// Forward loop // Forward loop
// ---------------------------------------------------------------- // ----------------------------------------------------------------
let period = Duration::from_secs(60); 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 keepalive_tick = time::interval_at(first_at, period);
let mut stats_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. // Reuse a single allocation for draining server-sent lines.
let mut server_line = String::new(); 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; // Drain the server feed. The server sends a full APRS stream;
// if we never read it the TCP receive buffer fills and stalls // if we never read it the TCP receive buffer fills and stalls
// the connection via flow control. EOF triggers reconnect. // the connection via flow control. EOF triggers reconnect.
@@ -365,4 +440,29 @@ mod tests {
"W1AW>BEACON,qAR,SP2SJG:>Test status\r\n" "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");
}
} }
+17
View File
@@ -49,6 +49,18 @@ pub struct AprsFiConfig {
pub passcode: i32, pub passcode: i32,
/// IGate callsign. Overrides [general].callsign when set. /// IGate callsign. Overrides [general].callsign when set.
pub callsign: Option<String>, 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 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<f64>,
/// Beacon longitude override (decimal degrees). Falls back to [general].longitude.
pub longitude: Option<f64>,
} }
impl Default for AprsFiConfig { impl Default for AprsFiConfig {
@@ -59,6 +71,11 @@ impl Default for AprsFiConfig {
port: 14580, port: 14580,
passcode: -1, passcode: -1,
callsign: None, callsign: None,
beacon: false,
beacon_interval_secs: 1200,
beacon_symbol: "/-".to_string(),
latitude: None,
longitude: None,
} }
} }
} }
+1 -1
View File
@@ -520,7 +520,7 @@ fn spawn_rig_audio_stack(
let ai_shutdown_rx = shutdown_rx.clone(); let ai_shutdown_rx = shutdown_rx.clone();
handles.push(tokio::spawn(async move { handles.push(tokio::spawn(async move {
tokio::select! { 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) => {} _ = wait_for_shutdown(ai_shutdown_rx) => {}
} }
})); }));