[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:
@@ -144,6 +144,8 @@ pub struct FrontendRuntimeContext {
|
||||
pub vdes_history: Arc<Mutex<VecDeque<(Instant, VdesMessage)>>>,
|
||||
/// APRS decode history (timestamp, packet)
|
||||
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)
|
||||
pub cw_history: Arc<Mutex<VecDeque<(Instant, CwEvent)>>>,
|
||||
/// FT8 decode history (timestamp, message)
|
||||
@@ -207,6 +209,7 @@ impl FrontendRuntimeContext {
|
||||
ais_history: Arc::new(Mutex::new(VecDeque::new())),
|
||||
vdes_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())),
|
||||
ft8_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 === "vdes" && window.onServerVdes) window.onServerVdes(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 === "ft8" && window.onServerFt8) window.onServerFt8(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="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="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>
|
||||
<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="vdes">VDES</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="ft8">FT8</button>
|
||||
<button class="sub-tab" data-subtab="wspr">WSPR</button>
|
||||
@@ -529,6 +531,42 @@
|
||||
</div>
|
||||
<div id="aprs-packets"></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 class="ft8-controls">
|
||||
<button id="ft8-decode-toggle-btn" type="button">Enable FT8</button>
|
||||
@@ -640,6 +678,7 @@
|
||||
<script src="/ais.js"></script>
|
||||
<script src="/vdes.js"></script>
|
||||
<script src="/aprs.js"></script>
|
||||
<script src="/hf-aprs.js"></script>
|
||||
<script src="/ft8.js"></script>
|
||||
<script src="/wspr.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 += "<";
|
||||
else if (ch === ">") out += ">";
|
||||
else if (ch === "&") out += "&";
|
||||
else if (ch === '"') out += """;
|
||||
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 += "<";
|
||||
else if (ch === ">") out += ">";
|
||||
else if (ch === "&") out += "&";
|
||||
else if (ch === '"') out += """;
|
||||
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>>${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()
|
||||
.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(
|
||||
crate::server::audio::snapshot_cw_history(context.get_ref())
|
||||
.into_iter()
|
||||
@@ -638,6 +643,15 @@ pub async fn toggle_aprs_decode(
|
||||
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")]
|
||||
pub async fn toggle_cw_decode(
|
||||
state: web::Data<watch::Receiver<RigState>>,
|
||||
@@ -731,6 +745,15 @@ pub async fn clear_aprs_decode(
|
||||
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")]
|
||||
pub async fn clear_ais_decode(
|
||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||
@@ -1009,6 +1032,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(set_wfm_stereo)
|
||||
.service(set_wfm_denoise)
|
||||
.service(toggle_aprs_decode)
|
||||
.service(toggle_hf_aprs_decode)
|
||||
.service(toggle_cw_decode)
|
||||
.service(set_cw_auto)
|
||||
.service(set_cw_wpm)
|
||||
@@ -1018,6 +1042,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(clear_ais_decode)
|
||||
.service(clear_vdes_decode)
|
||||
.service(clear_aprs_decode)
|
||||
.service(clear_hf_aprs_decode)
|
||||
.service(clear_cw_decode)
|
||||
.service(clear_ft8_decode)
|
||||
.service(clear_wspr_decode)
|
||||
@@ -1038,6 +1063,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(ais_js)
|
||||
.service(vdes_js)
|
||||
.service(aprs_js)
|
||||
.service(hf_aprs_js)
|
||||
.service(ft8_js)
|
||||
.service(wspr_js)
|
||||
.service(cw_js)
|
||||
@@ -1123,6 +1149,16 @@ async fn aprs_js() -> impl Responder {
|
||||
.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")]
|
||||
async fn ais_js() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
@@ -1256,6 +1292,7 @@ async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot,
|
||||
server_longitude: state.server_longitude,
|
||||
pskreporter_status: state.pskreporter_status,
|
||||
aprs_decode_enabled: state.aprs_decode_enabled,
|
||||
hf_aprs_decode_enabled: state.hf_aprs_decode_enabled,
|
||||
cw_decode_enabled: state.cw_decode_enabled,
|
||||
cw_auto: state.cw_auto,
|
||||
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)>) {
|
||||
while let Some((ts, _)) = history.front() {
|
||||
if ts.elapsed() <= HISTORY_RETENTION {
|
||||
@@ -125,6 +134,18 @@ fn record_aprs(context: &FrontendRuntimeContext, mut pkt: AprsPacket) {
|
||||
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) {
|
||||
let mut history = context
|
||||
.cw_history
|
||||
@@ -161,6 +182,15 @@ pub fn snapshot_aprs_history(context: &FrontendRuntimeContext) -> Vec<AprsPacket
|
||||
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> {
|
||||
let mut history = context
|
||||
.ais_history
|
||||
@@ -214,6 +244,14 @@ pub fn clear_aprs_history(context: &FrontendRuntimeContext) {
|
||||
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) {
|
||||
let mut history = context
|
||||
.ais_history
|
||||
@@ -280,6 +318,7 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
|
||||
DecodedMessage::Ais(msg) => record_ais(&context, msg),
|
||||
DecodedMessage::Vdes(msg) => record_vdes(&context, msg),
|
||||
DecodedMessage::Aprs(pkt) => record_aprs(&context, pkt),
|
||||
DecodedMessage::HfAprs(pkt) => record_hf_aprs(&context, pkt),
|
||||
DecodedMessage::Cw(evt) => record_cw(&context, evt),
|
||||
DecodedMessage::Ft8(msg) => record_ft8(&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 VDES_JS: &str = include_str!("../assets/web/plugins/vdes.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 WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js");
|
||||
pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js");
|
||||
|
||||
Reference in New Issue
Block a user