[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
+17 -9
View File
@@ -44,9 +44,6 @@ fn crc16ccitt(bytes: &[u8]) -> u16 {
// Correlation demodulator (one instance) // Correlation demodulator (one instance)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const BAUD: f32 = 1200.0;
const MARK: f32 = 1200.0;
const SPACE: f32 = 2200.0;
const TWO_PI: f32 = std::f32::consts::TAU; const TWO_PI: f32 = std::f32::consts::TAU;
const PLL_GAIN: f32 = 0.4; const PLL_GAIN: f32 = 0.4;
@@ -98,9 +95,9 @@ struct RawFrame {
} }
impl Demodulator { impl Demodulator {
fn new(sample_rate: u32, window_factor: f32) -> Self { fn new(sample_rate: u32, baud: f32, mark_hz: f32, space_hz: f32, window_factor: f32) -> Self {
let sr = sample_rate as f32; let sr = sample_rate as f32;
let samples_per_bit = sr / BAUD; let samples_per_bit = sr / baud;
let corr_len = (samples_per_bit * window_factor).round().max(2.0) as usize; let corr_len = (samples_per_bit * window_factor).round().max(2.0) as usize;
let energy_window = (sr * 0.05).round() as usize; let energy_window = (sr * 0.05).round() as usize;
@@ -111,8 +108,8 @@ impl Demodulator {
energy_window, energy_window,
mark_phase: 0.0, mark_phase: 0.0,
space_phase: 0.0, space_phase: 0.0,
mark_phase_inc: TWO_PI * MARK / sr, mark_phase_inc: TWO_PI * mark_hz / sr,
space_phase_inc: TWO_PI * SPACE / sr, space_phase_inc: TWO_PI * space_hz / sr,
corr_len, corr_len,
mark_i_buf: vec![0.0; corr_len], mark_i_buf: vec![0.0; corr_len],
mark_q_buf: vec![0.0; corr_len], mark_q_buf: vec![0.0; corr_len],
@@ -541,11 +538,22 @@ pub struct AprsDecoder {
} }
impl AprsDecoder { impl AprsDecoder {
/// VHF APRS: Bell 202, 1200 baud, mark=1200 Hz, space=2200 Hz.
pub fn new(sample_rate: u32) -> Self { pub fn new(sample_rate: u32) -> Self {
Self { Self {
demodulators: vec![ demodulators: vec![
Demodulator::new(sample_rate, 1.0), Demodulator::new(sample_rate, 1200.0, 1200.0, 2200.0, 1.0),
Demodulator::new(sample_rate, 0.5), Demodulator::new(sample_rate, 1200.0, 1200.0, 2200.0, 0.5),
],
}
}
/// HF APRS: 300 baud, mark=1600 Hz, space=1800 Hz (200 Hz shift).
pub fn new_hf(sample_rate: u32) -> Self {
Self {
demodulators: vec![
Demodulator::new(sample_rate, 300.0, 1600.0, 1800.0, 1.0),
Demodulator::new(sample_rate, 300.0, 1600.0, 1800.0, 0.5),
], ],
} }
} }
+3
View File
@@ -144,6 +144,8 @@ pub struct FrontendRuntimeContext {
pub vdes_history: Arc<Mutex<VecDeque<(Instant, VdesMessage)>>>, pub vdes_history: Arc<Mutex<VecDeque<(Instant, VdesMessage)>>>,
/// APRS decode history (timestamp, packet) /// APRS decode history (timestamp, packet)
pub aprs_history: Arc<Mutex<VecDeque<(Instant, AprsPacket)>>>, pub aprs_history: Arc<Mutex<VecDeque<(Instant, AprsPacket)>>>,
/// HF APRS decode history (timestamp, packet)
pub hf_aprs_history: Arc<Mutex<VecDeque<(Instant, AprsPacket)>>>,
/// CW decode history (timestamp, event) /// CW decode history (timestamp, event)
pub cw_history: Arc<Mutex<VecDeque<(Instant, CwEvent)>>>, pub cw_history: Arc<Mutex<VecDeque<(Instant, CwEvent)>>>,
/// FT8 decode history (timestamp, message) /// FT8 decode history (timestamp, message)
@@ -207,6 +209,7 @@ impl FrontendRuntimeContext {
ais_history: Arc::new(Mutex::new(VecDeque::new())), ais_history: Arc::new(Mutex::new(VecDeque::new())),
vdes_history: Arc::new(Mutex::new(VecDeque::new())), vdes_history: Arc::new(Mutex::new(VecDeque::new())),
aprs_history: Arc::new(Mutex::new(VecDeque::new())), aprs_history: Arc::new(Mutex::new(VecDeque::new())),
hf_aprs_history: Arc::new(Mutex::new(VecDeque::new())),
cw_history: Arc::new(Mutex::new(VecDeque::new())), cw_history: Arc::new(Mutex::new(VecDeque::new())),
ft8_history: Arc::new(Mutex::new(VecDeque::new())), ft8_history: Arc::new(Mutex::new(VecDeque::new())),
wspr_history: Arc::new(Mutex::new(VecDeque::new())), wspr_history: Arc::new(Mutex::new(VecDeque::new())),
@@ -5986,6 +5986,7 @@ function dispatchDecodeMessage(msg) {
if (msg.type === "ais" && window.onServerAis) window.onServerAis(msg); if (msg.type === "ais" && window.onServerAis) window.onServerAis(msg);
if (msg.type === "vdes" && window.onServerVdes) window.onServerVdes(msg); if (msg.type === "vdes" && window.onServerVdes) window.onServerVdes(msg);
if (msg.type === "aprs" && window.onServerAprs) window.onServerAprs(msg); if (msg.type === "aprs" && window.onServerAprs) window.onServerAprs(msg);
if (msg.type === "hf_aprs" && window.onServerHfAprs) window.onServerHfAprs(msg);
if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg); if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg);
if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg); if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg);
if (msg.type === "wspr" && window.onServerWspr) window.onServerWspr(msg); if (msg.type === "wspr" && window.onServerWspr) window.onServerWspr(msg);
@@ -85,6 +85,7 @@
<div id="vdes-bar-overlay" aria-live="polite" aria-label="Recent VDES bursts"></div> <div id="vdes-bar-overlay" aria-live="polite" aria-label="Recent VDES bursts"></div>
<div id="ft8-bar-overlay" aria-live="polite" aria-label="Recent FT8 decodes"></div> <div id="ft8-bar-overlay" aria-live="polite" aria-label="Recent FT8 decodes"></div>
<div id="aprs-bar-overlay" aria-live="polite" aria-label="Recent APRS frames"></div> <div id="aprs-bar-overlay" aria-live="polite" aria-label="Recent APRS frames"></div>
<div id="hf-aprs-bar-overlay" aria-live="polite" aria-label="Recent HF APRS frames"></div>
<div id="cw-bar-overlay" aria-live="polite" aria-label="Recent CW decodes"></div> <div id="cw-bar-overlay" aria-live="polite" aria-label="Recent CW decodes"></div>
</div> </div>
<div id="spectrum-panel" style="display:none;"> <div id="spectrum-panel" style="display:none;">
@@ -373,6 +374,7 @@
<button class="sub-tab" data-subtab="ais">AIS</button> <button class="sub-tab" data-subtab="ais">AIS</button>
<button class="sub-tab" data-subtab="vdes">VDES</button> <button class="sub-tab" data-subtab="vdes">VDES</button>
<button class="sub-tab" data-subtab="aprs">APRS</button> <button class="sub-tab" data-subtab="aprs">APRS</button>
<button class="sub-tab" data-subtab="hf-aprs">HF APRS</button>
<button class="sub-tab" data-subtab="cw">CW</button> <button class="sub-tab" data-subtab="cw">CW</button>
<button class="sub-tab" data-subtab="ft8">FT8</button> <button class="sub-tab" data-subtab="ft8">FT8</button>
<button class="sub-tab" data-subtab="wspr">WSPR</button> <button class="sub-tab" data-subtab="wspr">WSPR</button>
@@ -529,6 +531,42 @@
</div> </div>
<div id="aprs-packets"></div> <div id="aprs-packets"></div>
</div> </div>
<div id="subtab-hf-aprs" class="sub-tab-panel" style="display:none;">
<div class="ft8-controls aprs-controls">
<button id="hf-aprs-pause-btn" type="button">Pause</button>
<button id="hf-aprs-clear-btn" type="button">Clear</button>
<input id="hf-aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
<small id="hf-aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
</div>
<div class="aprs-summary">
<div class="aprs-summary-card">
<span class="aprs-summary-label">Frames</span>
<span id="hf-aprs-total-count" class="aprs-summary-value">0 total</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Visible</span>
<span id="hf-aprs-visible-count" class="aprs-summary-value">0 shown</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Latest</span>
<span id="hf-aprs-latest-seen" class="aprs-summary-value">No packets yet</span>
</div>
</div>
<div class="aprs-filter-row">
<button id="hf-aprs-type-all" class="aprs-chip active" type="button">All</button>
<button id="hf-aprs-type-position" class="aprs-chip" type="button">Pos</button>
<button id="hf-aprs-type-message" class="aprs-chip" type="button">Msg</button>
<button id="hf-aprs-type-weather" class="aprs-chip" type="button">Wx</button>
<button id="hf-aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
<button id="hf-aprs-type-other" class="aprs-chip" type="button">Other</button>
</div>
<div class="aprs-filter-row">
<button id="hf-aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
<button id="hf-aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
<button id="hf-aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
</div>
<div id="hf-aprs-packets"></div>
</div>
<div id="subtab-ft8" class="sub-tab-panel" style="display:none;"> <div id="subtab-ft8" class="sub-tab-panel" style="display:none;">
<div class="ft8-controls"> <div class="ft8-controls">
<button id="ft8-decode-toggle-btn" type="button">Enable FT8</button> <button id="ft8-decode-toggle-btn" type="button">Enable FT8</button>
@@ -640,6 +678,7 @@
<script src="/ais.js"></script> <script src="/ais.js"></script>
<script src="/vdes.js"></script> <script src="/vdes.js"></script>
<script src="/aprs.js"></script> <script src="/aprs.js"></script>
<script src="/hf-aprs.js"></script>
<script src="/ft8.js"></script> <script src="/ft8.js"></script>
<script src="/wspr.js"></script> <script src="/wspr.js"></script>
<script src="/cw.js"></script> <script src="/cw.js"></script>
@@ -0,0 +1,409 @@
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
const hfAprsStatus = document.getElementById("hf-aprs-status");
const hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
const hfAprsFilterInput = document.getElementById("hf-aprs-filter");
const hfAprsPauseBtn = document.getElementById("hf-aprs-pause-btn");
const hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
const hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
const hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
const hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
const hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
const hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
const HF_APRS_MAX_PACKETS = 100;
let hfAprsFilterText = "";
let hfAprsPacketHistory = [];
let hfAprsPaused = false;
let hfAprsBufferedWhilePaused = 0;
let hfAprsOnlyPos = false;
let hfAprsHideCrc = false;
let hfAprsCollapseDup = false;
let hfAprsTypeFilter = "all";
function hfAprsPacketCategory(pkt) {
const type = String(pkt.type || "").toLowerCase();
const info = String(pkt.info || "").toLowerCase();
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
if (type.includes("message") || info.startsWith(":")) return "message";
if (type.includes("weather") || info.startsWith("_")) return "weather";
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
return "other";
}
function hfAprsCategoryLabel(category) {
switch (category) {
case "position": return "Position";
case "message": return "Message";
case "weather": return "Weather";
case "telemetry": return "Telemetry";
default: return "Other";
}
}
function hfAprsAgeText(tsMs) {
if (!Number.isFinite(tsMs)) return "just now";
const deltaMs = Math.max(0, Date.now() - tsMs);
const seconds = Math.round(deltaMs / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
return `${hours}h ago`;
}
function hfAprsDistanceText(pkt) {
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`;
}
function hfAprsPacketSignature(pkt) {
return [
pkt.srcCall || "",
pkt.destCall || "",
pkt.path || "",
pkt.info || "",
pkt.type || "",
pkt.lat != null ? pkt.lat.toFixed(4) : "",
pkt.lon != null ? pkt.lon.toFixed(4) : "",
].join("|");
}
function hfAprsHexBytes(bytes) {
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
}
function hfAprsFilterMatch(pkt) {
if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
if (hfAprsHideCrc && !pkt.crcOk) return false;
if (hfAprsTypeFilter !== "all" && hfAprsPacketCategory(pkt) !== hfAprsTypeFilter) return false;
if (!hfAprsFilterText) return true;
const haystack = [
pkt.srcCall,
pkt.destCall,
pkt.path,
pkt.info,
pkt.type,
pkt.lat != null ? pkt.lat.toFixed(4) : "",
pkt.lon != null ? pkt.lon.toFixed(4) : "",
hfAprsPacketCategory(pkt),
]
.filter(Boolean)
.join(" ")
.toUpperCase();
return haystack.includes(hfAprsFilterText);
}
function hfAprsVisiblePackets() {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
return packets.filter(hfAprsFilterMatch);
}
function collapseHfAprsDuplicates(packets) {
const seen = new Set();
const out = [];
for (const pkt of packets) {
const key = hfAprsPacketSignature(pkt);
if (seen.has(key)) continue;
seen.add(key);
out.push(pkt);
}
return out;
}
function updateHfAprsSummary() {
const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
}
if (hfAprsVisibleCountEl) {
let text = `${visible.length} shown`;
if (hfAprsPaused && hfAprsBufferedWhilePaused > 0) {
text += ` · ${hfAprsBufferedWhilePaused} buffered`;
}
hfAprsVisibleCountEl.textContent = text;
}
if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0];
if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet";
} else {
hfAprsLatestSeenEl.textContent = `${latest.srcCall} ${hfAprsAgeText(latest._tsMs)}`;
}
}
}
function updateHfAprsChipState() {
document.querySelectorAll("[id^='hf-aprs-type-']").forEach((btn) => {
btn.classList.toggle("active", btn.id === `hf-aprs-type-${hfAprsTypeFilter}`);
});
hfAprsOnlyPosBtn?.classList.toggle("active", hfAprsOnlyPos);
hfAprsHideCrcBtn?.classList.toggle("active", hfAprsHideCrc);
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
if (hfAprsPauseBtn) {
hfAprsPauseBtn.textContent = hfAprsPaused ? "Resume" : "Pause";
hfAprsPauseBtn.classList.toggle("active", hfAprsPaused);
}
}
function renderHfAprsInfo(pkt) {
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
if (bytes && bytes.length > 0) {
let out = "";
for (let i = 0; i < bytes.length; i++) {
const b = bytes[i];
if (b >= 0x20 && b <= 0x7e) {
const ch = String.fromCharCode(b);
if (ch === "<") out += "&lt;";
else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;";
else if (ch === '"') out += "&quot;";
else out += ch;
} else {
const hex = b.toString(16).toUpperCase().padStart(2, "0");
out += `<span class="aprs-byte">0x${hex}</span>`;
}
}
return out;
}
const str = pkt.info || "";
let out = "";
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code >= 0x20 && code <= 0x7e) {
const ch = str[i];
if (ch === "<") out += "&lt;";
else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;";
else if (ch === '"') out += "&quot;";
else out += ch;
} else {
const hex = code.toString(16).toUpperCase().padStart(2, "0");
out += `<span class="aprs-byte">0x${hex}</span>`;
}
}
return out;
}
function renderHfAprsRow(pkt, isFresh) {
const row = document.createElement("div");
row.className = "aprs-packet";
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
if (isFresh) row.classList.add("aprs-packet-new");
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const age = hfAprsAgeText(pkt._tsMs);
const category = hfAprsPacketCategory(pkt);
const categoryLabel = hfAprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
let symbolHtml = "";
if (pkt.symbolTable && pkt.symbolCode) {
const sheet = pkt.symbolTable === "/" ? 0 : 1;
const code = pkt.symbolCode.charCodeAt(0) - 33;
const col = code % 16;
const row2 = Math.floor(code / 16);
const bgX = -(col * 24);
const bgY = -(row2 * 24);
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
}
const posLink = pkt.lat != null && pkt.lon != null
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
: "";
const distance = hfAprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML =
`<div class="aprs-row-head">` +
`<span class="aprs-time">${ts}</span>` +
hfBadge +
symbolHtml +
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` +
`<span>&gt;${escapeMapHtml(pkt.destCall || "")}</span>` +
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
pathBadge +
crcBadge +
`</div>` +
`<div class="aprs-row-meta">` +
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
`</div>` +
`<div class="aprs-row-detail">` +
`<span title="${escapeMapHtml(pkt.type || "")}">${renderHfAprsInfo(pkt)}</span>` +
(posLink ? `<span>${posLink}</span>` : "") +
`</div>` +
`<div class="aprs-row-actions">` +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
`</div>` +
`<details class="aprs-details">` +
`<summary>Details</summary>` +
`<div class="aprs-details-grid">` +
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span>` +
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` +
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` +
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` +
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` +
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span>` +
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(hfAprsHexBytes(pkt.info_bytes))}</span>` +
`</div>` +
`</details>`;
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = String(el.dataset.aprsMap || "");
const [lat, lon] = raw.split(",").map(Number);
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
window.navigateToAprsMap(lat, lon);
}
});
});
const copyBtn = row.querySelector("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
const raw = String(copyBtn.dataset.aprsCopy || "");
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(raw);
showHint("Coordinates copied", 1200);
}
} catch (_e) {
showHint("Copy failed", 1500);
}
});
}
return row;
}
function renderHfAprsHistory() {
if (!hfAprsPacketsEl || hfAprsPaused) {
updateHfAprsSummary();
updateHfAprsChipState();
return;
}
const visible = hfAprsVisiblePackets();
hfAprsPacketsEl.innerHTML = "";
for (let i = 0; i < visible.length; i++) {
hfAprsPacketsEl.appendChild(renderHfAprsRow(visible[i], i === 0));
}
updateHfAprsSummary();
updateHfAprsChipState();
}
window.resetHfAprsHistoryView = function() {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = [];
hfAprsBufferedWhilePaused = 0;
renderHfAprsHistory();
};
function addHfAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
hfAprsPacketHistory.unshift(pkt);
if (hfAprsPacketHistory.length > HF_APRS_MAX_PACKETS) hfAprsPacketHistory.length = HF_APRS_MAX_PACKETS;
if (hfAprsPaused) {
hfAprsBufferedWhilePaused += 1;
updateHfAprsSummary();
updateHfAprsChipState();
return;
}
renderHfAprsHistory();
}
document.getElementById("hf-aprs-clear-btn")?.addEventListener("click", async () => {
try {
await postPath("/clear_hf_aprs_decode");
window.resetHfAprsHistoryView();
} catch (e) {
console.error("HF APRS clear failed", e);
}
});
if (hfAprsPauseBtn) {
hfAprsPauseBtn.addEventListener("click", () => {
hfAprsPaused = !hfAprsPaused;
if (!hfAprsPaused) {
hfAprsBufferedWhilePaused = 0;
renderHfAprsHistory();
} else {
updateHfAprsSummary();
updateHfAprsChipState();
}
});
}
if (hfAprsOnlyPosBtn) {
hfAprsOnlyPosBtn.addEventListener("click", () => {
hfAprsOnlyPos = !hfAprsOnlyPos;
renderHfAprsHistory();
});
}
if (hfAprsHideCrcBtn) {
hfAprsHideCrcBtn.addEventListener("click", () => {
hfAprsHideCrc = !hfAprsHideCrc;
renderHfAprsHistory();
});
}
if (hfAprsCollapseDupBtn) {
hfAprsCollapseDupBtn.addEventListener("click", () => {
hfAprsCollapseDup = !hfAprsCollapseDup;
renderHfAprsHistory();
});
}
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
const btn = document.getElementById(`hf-aprs-type-${type}`);
if (!btn) return;
btn.addEventListener("click", () => {
hfAprsTypeFilter = type;
renderHfAprsHistory();
});
});
if (hfAprsFilterInput) {
hfAprsFilterInput.addEventListener("input", () => {
hfAprsFilterText = hfAprsFilterInput.value.trim().toUpperCase();
renderHfAprsHistory();
});
}
// --- Server-side HF APRS decode handler ---
window.onServerHfAprs = function(pkt) {
if (hfAprsStatus) hfAprsStatus.textContent = hfAprsPaused ? "Paused" : "Receiving";
addHfAprsPacket({
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
srcCall: pkt.src_call,
destCall: pkt.dest_call,
path: pkt.path,
info: pkt.info,
info_bytes: pkt.info_bytes,
type: pkt.packet_type,
crcOk: pkt.crc_ok,
ts_ms: pkt.ts_ms,
lat: pkt.lat,
lon: pkt.lon,
symbolTable: pkt.symbol_table,
symbolCode: pkt.symbol_code,
});
};
renderHfAprsHistory();
@@ -283,6 +283,11 @@ pub async fn decode_events(
.into_iter() .into_iter()
.map(trx_core::decode::DecodedMessage::Aprs), .map(trx_core::decode::DecodedMessage::Aprs),
); );
out.extend(
crate::server::audio::snapshot_hf_aprs_history(context.get_ref())
.into_iter()
.map(trx_core::decode::DecodedMessage::HfAprs),
);
out.extend( out.extend(
crate::server::audio::snapshot_cw_history(context.get_ref()) crate::server::audio::snapshot_cw_history(context.get_ref())
.into_iter() .into_iter()
@@ -638,6 +643,15 @@ pub async fn toggle_aprs_decode(
send_command(&rig_tx, RigCommand::SetAprsDecodeEnabled(!enabled)).await send_command(&rig_tx, RigCommand::SetAprsDecodeEnabled(!enabled)).await
} }
#[post("/toggle_hf_aprs_decode")]
pub async fn toggle_hf_aprs_decode(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().hf_aprs_decode_enabled;
send_command(&rig_tx, RigCommand::SetHfAprsDecodeEnabled(!enabled)).await
}
#[post("/toggle_cw_decode")] #[post("/toggle_cw_decode")]
pub async fn toggle_cw_decode( pub async fn toggle_cw_decode(
state: web::Data<watch::Receiver<RigState>>, state: web::Data<watch::Receiver<RigState>>,
@@ -731,6 +745,15 @@ pub async fn clear_aprs_decode(
send_command(&rig_tx, RigCommand::ResetAprsDecoder).await send_command(&rig_tx, RigCommand::ResetAprsDecoder).await
} }
#[post("/clear_hf_aprs_decode")]
pub async fn clear_hf_aprs_decode(
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_hf_aprs_history(context.get_ref());
send_command(&rig_tx, RigCommand::ResetHfAprsDecoder).await
}
#[post("/clear_ais_decode")] #[post("/clear_ais_decode")]
pub async fn clear_ais_decode( pub async fn clear_ais_decode(
context: web::Data<Arc<FrontendRuntimeContext>>, context: web::Data<Arc<FrontendRuntimeContext>>,
@@ -1009,6 +1032,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(set_wfm_stereo) .service(set_wfm_stereo)
.service(set_wfm_denoise) .service(set_wfm_denoise)
.service(toggle_aprs_decode) .service(toggle_aprs_decode)
.service(toggle_hf_aprs_decode)
.service(toggle_cw_decode) .service(toggle_cw_decode)
.service(set_cw_auto) .service(set_cw_auto)
.service(set_cw_wpm) .service(set_cw_wpm)
@@ -1018,6 +1042,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(clear_ais_decode) .service(clear_ais_decode)
.service(clear_vdes_decode) .service(clear_vdes_decode)
.service(clear_aprs_decode) .service(clear_aprs_decode)
.service(clear_hf_aprs_decode)
.service(clear_cw_decode) .service(clear_cw_decode)
.service(clear_ft8_decode) .service(clear_ft8_decode)
.service(clear_wspr_decode) .service(clear_wspr_decode)
@@ -1038,6 +1063,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(ais_js) .service(ais_js)
.service(vdes_js) .service(vdes_js)
.service(aprs_js) .service(aprs_js)
.service(hf_aprs_js)
.service(ft8_js) .service(ft8_js)
.service(wspr_js) .service(wspr_js)
.service(cw_js) .service(cw_js)
@@ -1123,6 +1149,16 @@ async fn aprs_js() -> impl Responder {
.body(status::APRS_JS) .body(status::APRS_JS)
} }
#[get("/hf-aprs.js")]
async fn hf_aprs_js() -> impl Responder {
HttpResponse::Ok()
.insert_header((
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
))
.body(status::HF_APRS_JS)
}
#[get("/ais.js")] #[get("/ais.js")]
async fn ais_js() -> impl Responder { async fn ais_js() -> impl Responder {
HttpResponse::Ok() HttpResponse::Ok()
@@ -1256,6 +1292,7 @@ async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot,
server_longitude: state.server_longitude, server_longitude: state.server_longitude,
pskreporter_status: state.pskreporter_status, pskreporter_status: state.pskreporter_status,
aprs_decode_enabled: state.aprs_decode_enabled, aprs_decode_enabled: state.aprs_decode_enabled,
hf_aprs_decode_enabled: state.hf_aprs_decode_enabled,
cw_decode_enabled: state.cw_decode_enabled, cw_decode_enabled: state.cw_decode_enabled,
cw_auto: state.cw_auto, cw_auto: state.cw_auto,
cw_wpm: state.cw_wpm, cw_wpm: state.cw_wpm,
@@ -44,6 +44,15 @@ fn prune_aprs_history(history: &mut VecDeque<(Instant, AprsPacket)>) {
} }
} }
fn prune_hf_aprs_history(history: &mut VecDeque<(Instant, AprsPacket)>) {
while let Some((ts, _)) = history.front() {
if ts.elapsed() <= HISTORY_RETENTION {
break;
}
history.pop_front();
}
}
fn prune_ais_history(history: &mut VecDeque<(Instant, AisMessage)>) { fn prune_ais_history(history: &mut VecDeque<(Instant, AisMessage)>) {
while let Some((ts, _)) = history.front() { while let Some((ts, _)) = history.front() {
if ts.elapsed() <= HISTORY_RETENTION { if ts.elapsed() <= HISTORY_RETENTION {
@@ -125,6 +134,18 @@ fn record_aprs(context: &FrontendRuntimeContext, mut pkt: AprsPacket) {
prune_aprs_history(&mut history); prune_aprs_history(&mut history);
} }
fn record_hf_aprs(context: &FrontendRuntimeContext, mut pkt: AprsPacket) {
if pkt.ts_ms.is_none() {
pkt.ts_ms = Some(current_timestamp_ms());
}
let mut history = context
.hf_aprs_history
.lock()
.expect("hf_aprs history mutex poisoned");
history.push_back((Instant::now(), pkt));
prune_hf_aprs_history(&mut history);
}
fn record_cw(context: &FrontendRuntimeContext, event: CwEvent) { fn record_cw(context: &FrontendRuntimeContext, event: CwEvent) {
let mut history = context let mut history = context
.cw_history .cw_history
@@ -161,6 +182,15 @@ pub fn snapshot_aprs_history(context: &FrontendRuntimeContext) -> Vec<AprsPacket
history.iter().map(|(_, pkt)| pkt.clone()).collect() history.iter().map(|(_, pkt)| pkt.clone()).collect()
} }
pub fn snapshot_hf_aprs_history(context: &FrontendRuntimeContext) -> Vec<AprsPacket> {
let mut history = context
.hf_aprs_history
.lock()
.expect("hf_aprs history mutex poisoned");
prune_hf_aprs_history(&mut history);
history.iter().map(|(_, pkt)| pkt.clone()).collect()
}
pub fn snapshot_ais_history(context: &FrontendRuntimeContext) -> Vec<AisMessage> { pub fn snapshot_ais_history(context: &FrontendRuntimeContext) -> Vec<AisMessage> {
let mut history = context let mut history = context
.ais_history .ais_history
@@ -214,6 +244,14 @@ pub fn clear_aprs_history(context: &FrontendRuntimeContext) {
history.clear(); history.clear();
} }
pub fn clear_hf_aprs_history(context: &FrontendRuntimeContext) {
let mut history = context
.hf_aprs_history
.lock()
.expect("hf_aprs history mutex poisoned");
history.clear();
}
pub fn clear_ais_history(context: &FrontendRuntimeContext) { pub fn clear_ais_history(context: &FrontendRuntimeContext) {
let mut history = context let mut history = context
.ais_history .ais_history
@@ -280,6 +318,7 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
DecodedMessage::Ais(msg) => record_ais(&context, msg), DecodedMessage::Ais(msg) => record_ais(&context, msg),
DecodedMessage::Vdes(msg) => record_vdes(&context, msg), DecodedMessage::Vdes(msg) => record_vdes(&context, msg),
DecodedMessage::Aprs(pkt) => record_aprs(&context, pkt), DecodedMessage::Aprs(pkt) => record_aprs(&context, pkt),
DecodedMessage::HfAprs(pkt) => record_hf_aprs(&context, pkt),
DecodedMessage::Cw(evt) => record_cw(&context, evt), DecodedMessage::Cw(evt) => record_cw(&context, evt),
DecodedMessage::Ft8(msg) => record_ft8(&context, msg), DecodedMessage::Ft8(msg) => record_ft8(&context, msg),
DecodedMessage::Wspr(msg) => record_wspr(&context, msg), DecodedMessage::Wspr(msg) => record_wspr(&context, msg),
@@ -15,6 +15,7 @@ pub const LEAFLET_AIS_TRACKSYMBOL_JS: &str =
pub const AIS_JS: &str = include_str!("../assets/web/plugins/ais.js"); pub const AIS_JS: &str = include_str!("../assets/web/plugins/ais.js");
pub const VDES_JS: &str = include_str!("../assets/web/plugins/vdes.js"); pub const VDES_JS: &str = include_str!("../assets/web/plugins/vdes.js");
pub const APRS_JS: &str = include_str!("../assets/web/plugins/aprs.js"); pub const APRS_JS: &str = include_str!("../assets/web/plugins/aprs.js");
pub const HF_APRS_JS: &str = include_str!("../assets/web/plugins/hf-aprs.js");
pub const FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js"); pub const FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js");
pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js"); pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js");
pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js"); pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js");
+2
View File
@@ -16,6 +16,8 @@ pub enum DecodedMessage {
Vdes(VdesMessage), Vdes(VdesMessage),
#[serde(rename = "aprs")] #[serde(rename = "aprs")]
Aprs(AprsPacket), Aprs(AprsPacket),
#[serde(rename = "hf_aprs")]
HfAprs(AprsPacket),
#[serde(rename = "cw")] #[serde(rename = "cw")]
Cw(CwEvent), Cw(CwEvent),
#[serde(rename = "ft8")] #[serde(rename = "ft8")]
+2
View File
@@ -22,6 +22,7 @@ pub enum RigCommand {
Lock, Lock,
Unlock, Unlock,
SetAprsDecodeEnabled(bool), SetAprsDecodeEnabled(bool),
SetHfAprsDecodeEnabled(bool),
SetCwDecodeEnabled(bool), SetCwDecodeEnabled(bool),
SetCwAuto(bool), SetCwAuto(bool),
SetCwWpm(u32), SetCwWpm(u32),
@@ -29,6 +30,7 @@ pub enum RigCommand {
SetFt8DecodeEnabled(bool), SetFt8DecodeEnabled(bool),
SetWsprDecodeEnabled(bool), SetWsprDecodeEnabled(bool),
ResetAprsDecoder, ResetAprsDecoder,
ResetHfAprsDecoder,
ResetCwDecoder, ResetCwDecoder,
ResetFt8Decoder, ResetFt8Decoder,
ResetWsprDecoder, ResetWsprDecoder,
@@ -510,6 +510,8 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
| RigCommand::SetCwToneHz(_) | RigCommand::SetCwToneHz(_)
| RigCommand::SetFt8DecodeEnabled(_) | RigCommand::SetFt8DecodeEnabled(_)
| RigCommand::SetWsprDecodeEnabled(_) | RigCommand::SetWsprDecodeEnabled(_)
| RigCommand::SetHfAprsDecodeEnabled(_)
| RigCommand::ResetHfAprsDecoder
| RigCommand::ResetAprsDecoder | RigCommand::ResetAprsDecoder
| RigCommand::ResetCwDecoder | RigCommand::ResetCwDecoder
| RigCommand::ResetFt8Decoder | RigCommand::ResetFt8Decoder
+11
View File
@@ -31,6 +31,8 @@ pub struct RigState {
#[serde(default)] #[serde(default)]
pub aprs_decode_enabled: bool, pub aprs_decode_enabled: bool,
#[serde(default)] #[serde(default)]
pub hf_aprs_decode_enabled: bool,
#[serde(default)]
pub cw_decode_enabled: bool, pub cw_decode_enabled: bool,
#[serde(default)] #[serde(default)]
pub ft8_decode_enabled: bool, pub ft8_decode_enabled: bool,
@@ -53,6 +55,8 @@ pub struct RigState {
#[serde(default, skip_serializing)] #[serde(default, skip_serializing)]
pub aprs_decode_reset_seq: u64, pub aprs_decode_reset_seq: u64,
#[serde(default, skip_serializing)] #[serde(default, skip_serializing)]
pub hf_aprs_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub cw_decode_reset_seq: u64, pub cw_decode_reset_seq: u64,
#[serde(default, skip_serializing)] #[serde(default, skip_serializing)]
pub ft8_decode_reset_seq: u64, pub ft8_decode_reset_seq: u64,
@@ -132,6 +136,7 @@ impl RigState {
server_longitude: None, server_longitude: None,
pskreporter_status: None, pskreporter_status: None,
aprs_decode_enabled: false, aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: true, cw_decode_enabled: true,
ft8_decode_enabled: false, ft8_decode_enabled: false,
wspr_decode_enabled: false, wspr_decode_enabled: false,
@@ -141,6 +146,7 @@ impl RigState {
filter: None, filter: None,
spectrum: None, spectrum: None,
aprs_decode_reset_seq: 0, aprs_decode_reset_seq: 0,
hf_aprs_decode_reset_seq: 0,
cw_decode_reset_seq: 0, cw_decode_reset_seq: 0,
ft8_decode_reset_seq: 0, ft8_decode_reset_seq: 0,
wspr_decode_reset_seq: 0, wspr_decode_reset_seq: 0,
@@ -193,6 +199,7 @@ impl RigState {
server_longitude: snapshot.server_longitude, server_longitude: snapshot.server_longitude,
pskreporter_status: snapshot.pskreporter_status, pskreporter_status: snapshot.pskreporter_status,
aprs_decode_enabled: snapshot.aprs_decode_enabled, aprs_decode_enabled: snapshot.aprs_decode_enabled,
hf_aprs_decode_enabled: snapshot.hf_aprs_decode_enabled,
cw_decode_enabled: snapshot.cw_decode_enabled, cw_decode_enabled: snapshot.cw_decode_enabled,
cw_auto: snapshot.cw_auto, cw_auto: snapshot.cw_auto,
cw_wpm: snapshot.cw_wpm, cw_wpm: snapshot.cw_wpm,
@@ -202,6 +209,7 @@ impl RigState {
filter: snapshot.filter, filter: snapshot.filter,
spectrum: None, // spectrum flows through /api/spectrum, not persistent state spectrum: None, // spectrum flows through /api/spectrum, not persistent state
aprs_decode_reset_seq: 0, aprs_decode_reset_seq: 0,
hf_aprs_decode_reset_seq: 0,
cw_decode_reset_seq: 0, cw_decode_reset_seq: 0,
ft8_decode_reset_seq: 0, ft8_decode_reset_seq: 0,
wspr_decode_reset_seq: 0, wspr_decode_reset_seq: 0,
@@ -232,6 +240,7 @@ impl RigState {
server_longitude: self.server_longitude, server_longitude: self.server_longitude,
pskreporter_status: self.pskreporter_status.clone(), pskreporter_status: self.pskreporter_status.clone(),
aprs_decode_enabled: self.aprs_decode_enabled, aprs_decode_enabled: self.aprs_decode_enabled,
hf_aprs_decode_enabled: self.hf_aprs_decode_enabled,
cw_decode_enabled: self.cw_decode_enabled, cw_decode_enabled: self.cw_decode_enabled,
cw_auto: self.cw_auto, cw_auto: self.cw_auto,
cw_wpm: self.cw_wpm, cw_wpm: self.cw_wpm,
@@ -384,6 +393,8 @@ pub struct RigSnapshot {
#[serde(default)] #[serde(default)]
pub aprs_decode_enabled: bool, pub aprs_decode_enabled: bool,
#[serde(default)] #[serde(default)]
pub hf_aprs_decode_enabled: bool,
#[serde(default)]
pub cw_decode_enabled: bool, pub cw_decode_enabled: bool,
#[serde(default)] #[serde(default)]
pub ft8_decode_enabled: bool, pub ft8_decode_enabled: bool,
+1
View File
@@ -425,6 +425,7 @@ mod tests {
server_longitude: None, server_longitude: None,
pskreporter_status: None, pskreporter_status: None,
aprs_decode_enabled: false, aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: false, cw_decode_enabled: false,
ft8_decode_enabled: false, ft8_decode_enabled: false,
wspr_decode_enabled: false, wspr_decode_enabled: false,
+8
View File
@@ -34,6 +34,9 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand {
ClientCommand::SetAprsDecodeEnabled { enabled } => { ClientCommand::SetAprsDecodeEnabled { enabled } => {
RigCommand::SetAprsDecodeEnabled(enabled) RigCommand::SetAprsDecodeEnabled(enabled)
} }
ClientCommand::SetHfAprsDecodeEnabled { enabled } => {
RigCommand::SetHfAprsDecodeEnabled(enabled)
}
ClientCommand::SetCwDecodeEnabled { enabled } => RigCommand::SetCwDecodeEnabled(enabled), ClientCommand::SetCwDecodeEnabled { enabled } => RigCommand::SetCwDecodeEnabled(enabled),
ClientCommand::SetCwAuto { enabled } => RigCommand::SetCwAuto(enabled), ClientCommand::SetCwAuto { enabled } => RigCommand::SetCwAuto(enabled),
ClientCommand::SetCwWpm { wpm } => RigCommand::SetCwWpm(wpm), ClientCommand::SetCwWpm { wpm } => RigCommand::SetCwWpm(wpm),
@@ -43,6 +46,7 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand {
RigCommand::SetWsprDecodeEnabled(enabled) RigCommand::SetWsprDecodeEnabled(enabled)
} }
ClientCommand::ResetAprsDecoder => RigCommand::ResetAprsDecoder, ClientCommand::ResetAprsDecoder => RigCommand::ResetAprsDecoder,
ClientCommand::ResetHfAprsDecoder => RigCommand::ResetHfAprsDecoder,
ClientCommand::ResetCwDecoder => RigCommand::ResetCwDecoder, ClientCommand::ResetCwDecoder => RigCommand::ResetCwDecoder,
ClientCommand::ResetFt8Decoder => RigCommand::ResetFt8Decoder, ClientCommand::ResetFt8Decoder => RigCommand::ResetFt8Decoder,
ClientCommand::ResetWsprDecoder => RigCommand::ResetWsprDecoder, ClientCommand::ResetWsprDecoder => RigCommand::ResetWsprDecoder,
@@ -88,6 +92,9 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand {
RigCommand::SetAprsDecodeEnabled(enabled) => { RigCommand::SetAprsDecodeEnabled(enabled) => {
ClientCommand::SetAprsDecodeEnabled { enabled } ClientCommand::SetAprsDecodeEnabled { enabled }
} }
RigCommand::SetHfAprsDecodeEnabled(enabled) => {
ClientCommand::SetHfAprsDecodeEnabled { enabled }
}
RigCommand::SetCwDecodeEnabled(enabled) => ClientCommand::SetCwDecodeEnabled { enabled }, RigCommand::SetCwDecodeEnabled(enabled) => ClientCommand::SetCwDecodeEnabled { enabled },
RigCommand::SetCwAuto(enabled) => ClientCommand::SetCwAuto { enabled }, RigCommand::SetCwAuto(enabled) => ClientCommand::SetCwAuto { enabled },
RigCommand::SetCwWpm(wpm) => ClientCommand::SetCwWpm { wpm }, RigCommand::SetCwWpm(wpm) => ClientCommand::SetCwWpm { wpm },
@@ -97,6 +104,7 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand {
ClientCommand::SetWsprDecodeEnabled { enabled } ClientCommand::SetWsprDecodeEnabled { enabled }
} }
RigCommand::ResetAprsDecoder => ClientCommand::ResetAprsDecoder, RigCommand::ResetAprsDecoder => ClientCommand::ResetAprsDecoder,
RigCommand::ResetHfAprsDecoder => ClientCommand::ResetHfAprsDecoder,
RigCommand::ResetCwDecoder => ClientCommand::ResetCwDecoder, RigCommand::ResetCwDecoder => ClientCommand::ResetCwDecoder,
RigCommand::ResetFt8Decoder => ClientCommand::ResetFt8Decoder, RigCommand::ResetFt8Decoder => ClientCommand::ResetFt8Decoder,
RigCommand::ResetWsprDecoder => ClientCommand::ResetWsprDecoder, RigCommand::ResetWsprDecoder => ClientCommand::ResetWsprDecoder,
+2
View File
@@ -27,6 +27,7 @@ pub enum ClientCommand {
GetTxLimit, GetTxLimit,
SetTxLimit { limit: u8 }, SetTxLimit { limit: u8 },
SetAprsDecodeEnabled { enabled: bool }, SetAprsDecodeEnabled { enabled: bool },
SetHfAprsDecodeEnabled { enabled: bool },
SetCwDecodeEnabled { enabled: bool }, SetCwDecodeEnabled { enabled: bool },
SetCwAuto { enabled: bool }, SetCwAuto { enabled: bool },
SetCwWpm { wpm: u32 }, SetCwWpm { wpm: u32 },
@@ -34,6 +35,7 @@ pub enum ClientCommand {
SetFt8DecodeEnabled { enabled: bool }, SetFt8DecodeEnabled { enabled: bool },
SetWsprDecodeEnabled { enabled: bool }, SetWsprDecodeEnabled { enabled: bool },
ResetAprsDecoder, ResetAprsDecoder,
ResetHfAprsDecoder,
ResetCwDecoder, ResetCwDecoder,
ResetFt8Decoder, ResetFt8Decoder,
ResetWsprDecoder, ResetWsprDecoder,
+1 -1
View File
@@ -203,7 +203,7 @@ pub async fn run_aprsfi_uplink(
recv = decode_rx.recv() => { recv = decode_rx.recv() => {
match recv { match recv {
Ok(DecodedMessage::Aprs(pkt)) => { Ok(DecodedMessage::Aprs(pkt)) | Ok(DecodedMessage::HfAprs(pkt)) => {
stats_received += 1; stats_received += 1;
if !pkt.crc_ok { if !pkt.crc_ok {
stats_skipped += 1; stats_skipped += 1;
+138 -1
View File
@@ -38,6 +38,7 @@ use crate::config::AudioConfig;
use trx_decode_log::DecoderLoggers; use trx_decode_log::DecoderLoggers;
const APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60); 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 AIS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const VDES_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); 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 ais: Mutex<VecDeque<(Instant, AisMessage)>>,
pub vdes: Mutex<VecDeque<(Instant, VdesMessage)>>, pub vdes: Mutex<VecDeque<(Instant, VdesMessage)>>,
pub aprs: Mutex<VecDeque<(Instant, AprsPacket)>>, pub aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
pub hf_aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
pub cw: Mutex<VecDeque<(Instant, CwEvent)>>, pub cw: Mutex<VecDeque<(Instant, CwEvent)>>,
pub ft8: Mutex<VecDeque<(Instant, Ft8Message)>>, pub ft8: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>, pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
@@ -156,6 +158,7 @@ impl DecoderHistories {
ais: Mutex::new(VecDeque::new()), ais: Mutex::new(VecDeque::new()),
vdes: Mutex::new(VecDeque::new()), vdes: Mutex::new(VecDeque::new()),
aprs: Mutex::new(VecDeque::new()), aprs: Mutex::new(VecDeque::new()),
hf_aprs: Mutex::new(VecDeque::new()),
cw: Mutex::new(VecDeque::new()), cw: Mutex::new(VecDeque::new()),
ft8: Mutex::new(VecDeque::new()), ft8: Mutex::new(VecDeque::new()),
wspr: Mutex::new(VecDeque::new()), wspr: Mutex::new(VecDeque::new()),
@@ -256,6 +259,44 @@ impl DecoderHistories {
.clear(); .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 --- // --- CW ---
fn prune_cw(history: &mut VecDeque<(Instant, CwEvent)>) { 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 ais = self.ais.lock().map(|h| h.len()).unwrap_or(0);
let vdes = self.vdes.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 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 cw = self.cw.lock().map(|h| h.len()).unwrap_or(0);
let ft8 = self.ft8.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); 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> { fn downmix_if_needed(frame: Vec<f32>, channels: u16) -> Vec<f32> {
if channels <= 1 { if channels <= 1 {
return frame; 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 { if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) = sdr_ais_pcm_rx {
let ais_state_rx = state_rx.clone(); let ais_state_rx = state_rx.clone();
let ais_decode_tx = decode_tx.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()); let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state); 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 => { RigCommand::ResetCwDecoder => {
ctx.histories.clear_cw_history(); ctx.histories.clear_cw_history();
ctx.state.cw_decode_reset_seq += 1; ctx.state.cw_decode_reset_seq += 1;