[feat](trx-server): add APRS-IS IGate uplink (aprs.fi integration)
Forwards CRC-valid RF APRS packets to APRS-IS via plain TCP using the TNC2 line format, making them visible on aprs.fi and other APRS-IS consumers. Mirrors the pskreporter module in structure. - New aprsfi.rs: IGate task with reconnect loop (exponential backoff 1s→60s), login/logresp, 60s keepalive, 60s stats, passcode auto-computation from callsign (standard APRS hash algorithm) - config.rs: AprsFiConfig struct with enabled/host/port/passcode fields and validation; default host rotate.aprs.net:14580 - main.rs: mod aprsfi; spawn task inside audio block when aprsfi.enabled - trx-server.toml.example, CONFIGURATION.md: document [aprsfi] section - Remove APRSFI_IMPLEMENTATION.rs planning artifact Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
//! APRS-IS IGate uplink — forwards RF-decoded APRS packets to APRS-IS (aprs.fi etc.).
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::time::{self, Duration};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use trx_core::decode::{AprsPacket, DecodedMessage};
|
||||
|
||||
use crate::config::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.
|
||||
fn format_tnc2(pkt: &AprsPacket) -> String {
|
||||
if pkt.path.is_empty() {
|
||||
format!("{}>{}:{}\r\n", pkt.src_call, pkt.dest_call, pkt.info)
|
||||
} else {
|
||||
format!(
|
||||
"{}>{},{}:{}\r\n",
|
||||
pkt.src_call, pkt.dest_call, pkt.path, pkt.info
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
mut decode_rx: broadcast::Receiver<DecodedMessage>,
|
||||
) {
|
||||
let passcode: u16 = if cfg.passcode == -1 {
|
||||
compute_passcode(&callsign)
|
||||
} else {
|
||||
(cfg.passcode as u16) & 0x7fff
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
let mut reader = BufReader::new(read_half);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Login
|
||||
// ----------------------------------------------------------------
|
||||
let login = format!(
|
||||
"user {} pass {} vers trx-server {}\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 = time::Instant::now() + period;
|
||||
let mut keepalive_tick = time::interval_at(first_at, period);
|
||||
let mut stats_tick = time::interval_at(first_at, period);
|
||||
|
||||
'forward: loop {
|
||||
tokio::select! {
|
||||
_ = keepalive_tick.tick() => {
|
||||
if let Err(e) = write_half.write_all(b"# trx-server 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
|
||||
);
|
||||
}
|
||||
|
||||
recv = decode_rx.recv() => {
|
||||
match recv {
|
||||
Ok(DecodedMessage::Aprs(pkt)) => {
|
||||
stats_received += 1;
|
||||
if !pkt.crc_ok {
|
||||
stats_skipped += 1;
|
||||
continue 'forward;
|
||||
}
|
||||
let tnc2 = format_tnc2(&pkt);
|
||||
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 — 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 {
|
||||
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() {
|
||||
let pkt = make_pkt(
|
||||
"N0CALL-9",
|
||||
"APRS",
|
||||
"WIDE1-1,WIDE2-1",
|
||||
"!1234.56N/01234.56E-Test",
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
format_tnc2(&pkt),
|
||||
"N0CALL-9>APRS,WIDE1-1,WIDE2-1:!1234.56N/01234.56E-Test\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tnc2_without_path() {
|
||||
let pkt = make_pkt("W1AW", "BEACON", "", ">Test status", true);
|
||||
assert_eq!(format_tnc2(&pkt), "W1AW>BEACON:>Test status\r\n");
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ pub struct ServerConfig {
|
||||
pub audio: AudioConfig,
|
||||
/// PSK Reporter uplink configuration
|
||||
pub pskreporter: PskReporterConfig,
|
||||
/// APRS-IS IGate uplink configuration
|
||||
pub aprsfi: AprsFiConfig,
|
||||
/// Decoder file logging configuration
|
||||
pub decode_logs: DecodeLogsConfig,
|
||||
}
|
||||
@@ -233,6 +235,31 @@ impl Default for PskReporterConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 [general].callsign.
|
||||
pub passcode: i32,
|
||||
}
|
||||
|
||||
impl Default for AprsFiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
host: "rotate.aprs.net".to_string(),
|
||||
port: 14580,
|
||||
passcode: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_decode_logs_dir() -> String {
|
||||
if let Some(data_dir) = dirs::data_dir() {
|
||||
return data_dir
|
||||
@@ -351,6 +378,15 @@ impl ServerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
if self.aprsfi.enabled {
|
||||
if self.aprsfi.host.trim().is_empty() {
|
||||
return Err("[aprsfi].host must not be empty".to_string());
|
||||
}
|
||||
if self.aprsfi.port == 0 {
|
||||
return Err("[aprsfi].port must be > 0".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if self.decode_logs.enabled {
|
||||
if self.decode_logs.dir.trim().is_empty() {
|
||||
return Err("[decode_logs].dir must not be empty when enabled".to_string());
|
||||
@@ -405,6 +441,7 @@ impl ServerConfig {
|
||||
listen: ListenConfig::default(),
|
||||
audio: AudioConfig::default(),
|
||||
pskreporter: PskReporterConfig::default(),
|
||||
aprsfi: AprsFiConfig::default(),
|
||||
decode_logs: DecodeLogsConfig::default(),
|
||||
};
|
||||
|
||||
@@ -538,6 +575,10 @@ mod tests {
|
||||
assert_eq!(config.audio.sample_rate, 48000);
|
||||
assert!(!config.pskreporter.enabled);
|
||||
assert_eq!(config.pskreporter.port, 4739);
|
||||
assert!(!config.aprsfi.enabled);
|
||||
assert_eq!(config.aprsfi.host, "rotate.aprs.net");
|
||||
assert_eq!(config.aprsfi.port, 14580);
|
||||
assert_eq!(config.aprsfi.passcode, -1);
|
||||
assert!(!config.decode_logs.enabled);
|
||||
assert!(
|
||||
std::path::Path::new(&config.decode_logs.dir)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
mod aprsfi;
|
||||
mod audio;
|
||||
mod config;
|
||||
mod decode;
|
||||
@@ -406,6 +407,23 @@ async fn main() -> DynResult<()> {
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.aprsfi.enabled {
|
||||
let callsign = resolved.callsign.clone().unwrap_or_default();
|
||||
if callsign.trim().is_empty() {
|
||||
warn!("APRS-IS IGate enabled but [general].callsign is empty; uplink disabled");
|
||||
} else {
|
||||
let ai_cfg = cfg.aprsfi.clone();
|
||||
let ai_decode_rx = decode_tx.subscribe();
|
||||
let ai_shutdown_rx = shutdown_rx.clone();
|
||||
task_handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = aprsfi::run_aprsfi_uplink(ai_cfg, callsign, ai_decode_rx) => {}
|
||||
_ = wait_for_shutdown(ai_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let decoder_logs = match DecoderLoggers::from_config(&cfg.decode_logs) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
|
||||
Reference in New Issue
Block a user