[feat](trx-rs): add HF APRS decoder (300 baud, 1600/1800 Hz AFSK)

Adds a second APRS demodulator path tuned for the HF APRS standard
(300 baud Bell 103-style AFSK, mark=1600 Hz / space=1800 Hz), active
on RigMode::DIG.  Shares AX.25 framing, APRS parsing, APRS-IS uplink,
and frontend display with the existing VHF stack.

- trx-aprs: parameterise Demodulator::new(); add AprsDecoder::new_hf()
- trx-core: HfAprs variant in DecodedMessage; hf_aprs_decode_enabled /
  hf_aprs_decode_reset_seq in RigState/RigSnapshot; SetHfAprsDecodeEnabled
  and ResetHfAprsDecoder commands; handlers.rs fallback arm updated
- trx-protocol: client command variants + bidirectional mapping; test
  fixture updated
- trx-server: run_hf_aprs_decoder() task (activates on DIG mode);
  hf_aprs history in DecoderHistories; rig_task command dispatch;
  aprsfi uplink forwards HfAprs via OR-pattern
- trx-frontend: hf_aprs_history in FrontendRuntimeContext
- trx-frontend-http: prune/record/snapshot/clear helpers; SSE history
  replay; toggle_hf_aprs_decode + clear_hf_aprs_decode endpoints;
  /hf-aprs.js endpoint; HF APRS tab in web UI

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-08 20:17:37 +01:00
parent ee821a71b1
commit 19d6d2e50b
19 changed files with 740 additions and 11 deletions
+1 -1
View File
@@ -203,7 +203,7 @@ pub async fn run_aprsfi_uplink(
recv = decode_rx.recv() => {
match recv {
Ok(DecodedMessage::Aprs(pkt)) => {
Ok(DecodedMessage::Aprs(pkt)) | Ok(DecodedMessage::HfAprs(pkt)) => {
stats_received += 1;
if !pkt.crc_ok {
stats_skipped += 1;
+138 -1
View File
@@ -38,6 +38,7 @@ use crate::config::AudioConfig;
use trx_decode_log::DecoderLoggers;
const APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const HF_APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const AIS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const VDES_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const CW_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
@@ -145,6 +146,7 @@ pub struct DecoderHistories {
pub ais: Mutex<VecDeque<(Instant, AisMessage)>>,
pub vdes: Mutex<VecDeque<(Instant, VdesMessage)>>,
pub aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
pub hf_aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
pub cw: Mutex<VecDeque<(Instant, CwEvent)>>,
pub ft8: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
@@ -156,6 +158,7 @@ impl DecoderHistories {
ais: Mutex::new(VecDeque::new()),
vdes: Mutex::new(VecDeque::new()),
aprs: Mutex::new(VecDeque::new()),
hf_aprs: Mutex::new(VecDeque::new()),
cw: Mutex::new(VecDeque::new()),
ft8: Mutex::new(VecDeque::new()),
wspr: Mutex::new(VecDeque::new()),
@@ -256,6 +259,44 @@ impl DecoderHistories {
.clear();
}
// --- HF APRS ---
fn prune_hf_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
let cutoff = Instant::now() - HF_APRS_HISTORY_RETENTION;
while let Some((ts, _)) = history.front() {
if *ts < cutoff {
history.pop_front();
} else {
break;
}
}
}
pub fn record_hf_aprs_packet(&self, mut pkt: AprsPacket) {
if !pkt.crc_ok {
return;
}
if pkt.ts_ms.is_none() {
pkt.ts_ms = Some(current_timestamp_ms());
}
let mut h = self.hf_aprs.lock().expect("hf_aprs history mutex poisoned");
h.push_back((Instant::now(), pkt));
Self::prune_hf_aprs(&mut h);
}
pub fn snapshot_hf_aprs_history(&self) -> Vec<AprsPacket> {
let mut h = self.hf_aprs.lock().expect("hf_aprs history mutex poisoned");
Self::prune_hf_aprs(&mut h);
h.iter().map(|(_, pkt)| pkt.clone()).collect()
}
pub fn clear_hf_aprs_history(&self) {
self.hf_aprs
.lock()
.expect("hf_aprs history mutex poisoned")
.clear();
}
// --- CW ---
fn prune_cw(history: &mut VecDeque<(Instant, CwEvent)>) {
@@ -352,10 +393,11 @@ impl DecoderHistories {
let ais = self.ais.lock().map(|h| h.len()).unwrap_or(0);
let vdes = self.vdes.lock().map(|h| h.len()).unwrap_or(0);
let aprs = self.aprs.lock().map(|h| h.len()).unwrap_or(0);
let hf_aprs = self.hf_aprs.lock().map(|h| h.len()).unwrap_or(0);
let cw = self.cw.lock().map(|h| h.len()).unwrap_or(0);
let ft8 = self.ft8.lock().map(|h| h.len()).unwrap_or(0);
let wspr = self.wspr.lock().map(|h| h.len()).unwrap_or(0);
ais + vdes + aprs + cw + ft8 + wspr
ais + vdes + aprs + hf_aprs + cw + ft8 + wspr
}
}
@@ -945,6 +987,101 @@ pub async fn run_aprs_decoder(
}
}
pub async fn run_hf_aprs_decoder(
sample_rate: u32,
channels: u16,
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
mut state_rx: watch::Receiver<RigState>,
decode_tx: broadcast::Sender<DecodedMessage>,
decode_logs: Option<Arc<DecoderLoggers>>,
histories: Arc<DecoderHistories>,
) {
info!("HF APRS decoder started ({}Hz, {} ch)", sample_rate, channels);
let mut decoder = AprsDecoder::new_hf(sample_rate);
let mut was_active = false;
let mut last_reset_seq: u64 = 0;
let mut active = matches!(state_rx.borrow().status.mode, RigMode::DIG);
loop {
if !active {
match state_rx.changed().await {
Ok(()) => {
let state = state_rx.borrow();
active = matches!(state.status.mode, RigMode::DIG);
if active {
pcm_rx = pcm_rx.resubscribe();
}
if state.hf_aprs_decode_reset_seq != last_reset_seq {
last_reset_seq = state.hf_aprs_decode_reset_seq;
decoder.reset();
info!("HF APRS decoder reset (seq={})", last_reset_seq);
}
}
Err(_) => break,
}
continue;
}
tokio::select! {
recv = pcm_rx.recv() => {
match recv {
Ok(frame) => {
let state = state_rx.borrow();
if state.hf_aprs_decode_reset_seq != last_reset_seq {
last_reset_seq = state.hf_aprs_decode_reset_seq;
decoder.reset();
info!("HF APRS decoder reset (seq={})", last_reset_seq);
}
let mut mono = downmix_if_needed(frame, channels);
apply_decode_audio_gate(&mut mono);
was_active = true;
for mut pkt in decoder.process_samples(&mono) {
if let Some(logger) = decode_logs.as_ref() {
logger.log_aprs(&pkt);
}
if !pkt.crc_ok {
continue;
}
if pkt.ts_ms.is_none() {
pkt.ts_ms = Some(current_timestamp_ms());
}
histories.record_hf_aprs_packet(pkt.clone());
let _ = decode_tx.send(DecodedMessage::HfAprs(pkt));
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!("HF APRS decoder: dropped {} PCM frames", n);
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
changed = state_rx.changed() => {
match changed {
Ok(()) => {
let state = state_rx.borrow();
active = matches!(state.status.mode, RigMode::DIG);
if state.hf_aprs_decode_reset_seq != last_reset_seq {
last_reset_seq = state.hf_aprs_decode_reset_seq;
decoder.reset();
info!("HF APRS decoder reset (seq={})", last_reset_seq);
}
if !active && was_active {
decoder.reset();
was_active = false;
}
if active {
pcm_rx = pcm_rx.resubscribe();
}
}
Err(_) => break,
}
}
}
}
}
fn downmix_if_needed(frame: Vec<f32>, channels: u16) -> Vec<f32> {
if channels <= 1 {
return frame;
+16
View File
@@ -630,6 +630,22 @@ fn spawn_rig_audio_stack(
}
}));
// Spawn HF APRS decoder task
let hf_aprs_pcm_rx = pcm_tx.subscribe();
let hf_aprs_state_rx = state_rx.clone();
let hf_aprs_decode_tx = decode_tx.clone();
let hf_aprs_sr = rig_cfg.audio.sample_rate;
let hf_aprs_ch = rig_cfg.audio.channels;
let hf_aprs_shutdown_rx = shutdown_rx.clone();
let hf_aprs_logs = decoder_logs.clone();
let hf_aprs_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_hf_aprs_decoder(hf_aprs_sr, hf_aprs_ch as u16, hf_aprs_pcm_rx, hf_aprs_state_rx, hf_aprs_decode_tx, hf_aprs_logs, hf_aprs_histories) => {}
_ = wait_for_shutdown(hf_aprs_shutdown_rx) => {}
}
}));
if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) = sdr_ais_pcm_rx {
let ais_state_rx = state_rx.clone();
let ais_decode_tx = decode_tx.clone();
+11
View File
@@ -436,6 +436,17 @@ async fn process_command(
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetHfAprsDecodeEnabled(en) => {
ctx.state.hf_aprs_decode_enabled = en;
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::ResetHfAprsDecoder => {
ctx.histories.clear_hf_aprs_history();
ctx.state.hf_aprs_decode_reset_seq += 1;
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::ResetCwDecoder => {
ctx.histories.clear_cw_history();
ctx.state.cw_decode_reset_seq += 1;