[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:
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user