[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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user