Compare commits

..
Author SHA1 Message Date
sjgandClaude Opus 5 bf3bcc8a84 [fix](trx-frontend-http): show one rig at a time on the digital modes page
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 8m34s
CI / test (push) Successful in 7m47s
CI / frontend (pull_request) Successful in 4m28s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m21s
CI / frontend (push) Successful in 3m37s
CI / reuse (push) Successful in 6s
A client connected to several rigs decodes all of them at once, and the
decode stream carries every rig's traffic to every browser.  The decoder
panels listed all of it: a station a background rig copied on another band
appeared in the APRS list next to the selected rig's, the vessel counts and
the "latest seen" lines counted both, the status lines said "Receiving"
because some other rig was, and the CW pane interleaved two rigs into one
stream of text that read as neither.

The page is about the rig the operator selected — the one whose spectrum is
on screen and whose audio is playing — so each panel now shows what that rig
heard: rows, counts, latest-seen, status, the live picture a WEFAX or SSTV
frame is painting, and the CW pane.

Nothing is dropped on the way in.  The map is the whole station's view, has
its own rig filter, and would empty out if the plugins stopped feeding it, so
the histories still hold every rig and the map still plots them.  That also
means a switch loses nothing: the runtime gained a rerender hook, which the
rig switch calls, and switching back brings the other rig's traffic up again.
The CW pane is the exception — a running stream of text cannot be unpicked
after the fact — so it starts empty on the rig switched to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 08:11:13 +02:00
30 changed files with 516 additions and 124 deletions
+23
View File
@@ -454,6 +454,29 @@ listener and every rig's audio listener.
--- ---
## Multiple Rigs in the Web UI
A client connected to several rigs decodes all of them at once, whichever one
is on screen. The rig picker in the header decides which rig the UI is about,
and each page answers that differently:
| Page | Shows |
|------|-------|
| Radio | The selected rig: its spectrum, its audio, and the mini decode views over the waterfall. |
| Digital Modes | The selected rig: every decoder panel, its counts and its status line. |
| Map | The whole station — every rig's positions, with the map's own rig filter to narrow it. |
| Statistics | The whole station, including the per-rig comparison. |
Switching rigs repaints the radio and digital modes pages for the rig now
selected. Nothing is lost by switching: the traffic other rigs heard is still
held, and switching back brings it up again. The one exception is the CW pane,
which is a single running stream of copied text rather than a list of frames,
so it starts empty on the rig you switch to.
Each browser tab keeps its own selection, so two tabs can watch two rigs.
---
## Tune Links ## Tune Links
Every page of the web UI carries what the radio is doing in its address, so the Every page of the web UI carries what the radio is doing in its address, so the
@@ -1,6 +1,7 @@
import { import {
forActiveRig,
isActiveRigDecode isActiveRigDecode
} from "./chunk-BQQXSNLC.js"; } from "./chunk-S57W63QN.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -143,18 +144,22 @@ function aisLatestByVessel(messages) {
} }
return Array.from(byMmsi.values()); return Array.from(byMmsi.values());
} }
function aisRigMessages() {
return forActiveRig(aisMessageHistory);
}
function updateAisSummary() { function updateAisSummary() {
const plan = currentAisChannelPlan(); const plan = currentAisChannelPlan();
if (aisChannelSummaryEl) { if (aisChannelSummaryEl) {
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`; aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
} }
const vessels = aisLatestByVessel(aisMessageHistory); const rigMessages = aisRigMessages();
const vessels = aisLatestByVessel(rigMessages);
if (aisVesselCountEl) { if (aisVesselCountEl) {
const count = vessels.length; const count = vessels.length;
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`; aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
} }
if (aisLatestSeenEl) { if (aisLatestSeenEl) {
const latest = aisMessageHistory[0]; const latest = rigMessages[0];
if (!latest) { if (!latest) {
aisLatestSeenEl.textContent = "No traffic yet"; aisLatestSeenEl.textContent = "No traffic yet";
} else { } else {
@@ -272,7 +277,7 @@ function renderAisHistory() {
return; return;
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const message of aisMessageHistory) { for (const message of aisRigMessages()) {
fragment.appendChild(renderAisRow(message)); fragment.appendChild(renderAisRow(message));
} }
aisMessagesEl.replaceChildren(fragment); aisMessagesEl.replaceChildren(fragment);
@@ -304,10 +309,10 @@ function normalizeServerAisMessage(msg) {
} }
function onServerAisBatch(messages) { function onServerAisBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerAisMessage(msg); const next = normalizeServerAisMessage(msg);
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -347,8 +352,9 @@ if (aisFilterInput) {
}); });
} }
function onServerAis(msg) { function onServerAis(msg) {
if (aisStatus) aisStatus.textContent = "Receiving"; const message = normalizeServerAisMessage(msg);
addAisMessage(normalizeServerAisMessage(msg)); if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
addAisMessage(message);
} }
updateAisSummary(); updateAisSummary();
window.trxPluginRuntime.registerDecoder({ window.trxPluginRuntime.registerDecoder({
@@ -358,6 +364,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerAisBatch, restore: onServerAisBatch,
reset: resetAisHistoryView, reset: resetAisHistoryView,
prune: pruneAisHistoryView, prune: pruneAisHistoryView,
rerender: () => {
updateAisBar();
renderAisHistory();
},
// Oldest first, so vessel tracks are rebuilt in the order they happened. // Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => { syncMap: () => {
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
@@ -1065,6 +1065,9 @@ var runtime = {
plugin.prune(); plugin.prune();
return true; return true;
}, },
rerenderAll() {
for (const plugin of decoders.values()) plugin.rerender?.();
},
syncMapAll() { syncMapAll() {
for (const plugin of decoders.values()) plugin.syncMap?.(); for (const plugin of decoders.values()) plugin.syncMap?.();
}, },
@@ -3577,6 +3580,7 @@ function resetDecoderStateOnRigSwitch() {
if (el) el.textContent = "--"; if (el) el.textContent = "--";
}); });
refreshDecodeBars(); refreshDecodeBars();
window.trxPluginRuntime.rerenderAll();
} }
function resetWfmStereoIndicator() { function resetWfmStereoIndicator() {
if (!wfmStFlagEl) return; if (!wfmStFlagEl) return;
@@ -6,8 +6,9 @@ import {
renderAprsPacketRow renderAprsPacketRow
} from "./chunk-OPEIVJGD.js"; } from "./chunk-OPEIVJGD.js";
import { import {
forActiveRig,
isActiveRigDecode isActiveRigDecode
} from "./chunk-BQQXSNLC.js"; } from "./chunk-S57W63QN.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -85,20 +86,25 @@ function aprsFilterMatch(pkt) {
].filter(Boolean).join(" ").toUpperCase(); ].filter(Boolean).join(" ").toUpperCase();
return haystack.includes(aprsFilterText); return haystack.includes(aprsFilterText);
} }
function aprsRigPackets() {
return forActiveRig(aprsPacketHistory);
}
function aprsVisiblePackets() { function aprsVisiblePackets() {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory; const rigPackets = aprsRigPackets();
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(aprsFilterMatch); return packets.filter(aprsFilterMatch);
} }
function updateAprsSummary() { function updateAprsSummary() {
const rigPackets = aprsRigPackets();
const visible = aprsVisiblePackets(); const visible = aprsVisiblePackets();
if (aprsTotalCountEl) { if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`; aprsTotalCountEl.textContent = `${rigPackets.length} total`;
} }
if (aprsVisibleCountEl) { if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`; aprsVisibleCountEl.textContent = `${visible.length} shown`;
} }
if (aprsLatestSeenEl) { if (aprsLatestSeenEl) {
const latest = aprsPacketHistory[0]; const latest = rigPackets[0];
if (!latest) { if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet"; aprsLatestSeenEl.textContent = "No packets yet";
} else { } else {
@@ -220,11 +226,11 @@ function normalizeServerAprsPacket(pkt) {
} }
function onServerAprsBatch(packets) { function onServerAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return; if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
let hasCrcOk = false; let hasCrcOk = false;
for (const pkt of packets) { for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt); const next = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -282,8 +288,9 @@ if (aprsFilterInput) {
}); });
} }
function onServerAprs(pkt) { function onServerAprs(pkt) {
if (aprsStatus) aprsStatus.textContent = "Receiving"; const packet = normalizeServerAprsPacket(pkt);
addAprsPacket(normalizeServerAprsPacket(pkt)); if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
addAprsPacket(packet);
} }
renderAprsHistory(); renderAprsHistory();
window.trxPluginRuntime.registerDecoder({ window.trxPluginRuntime.registerDecoder({
@@ -293,6 +300,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerAprsBatch, restore: onServerAprsBatch,
reset: resetAprsHistoryView, reset: resetAprsHistoryView,
prune: pruneAprsHistoryView, prune: pruneAprsHistoryView,
rerender: () => {
updateAprsBar();
renderAprsHistory();
},
// Oldest first, so station tracks are rebuilt in the order they happened. // Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { syncMap: () => {
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
@@ -1,6 +1,7 @@
import { import {
forActiveRig,
isActiveRigDecode isActiveRigDecode
} from "./chunk-BQQXSNLC.js"; } from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -156,9 +157,10 @@ function initializeFtxDecoder(config) {
const render = () => { const render = () => {
prune(); prune();
if (!messagesElement) return; if (!messagesElement) return;
const rigMessages = forActiveRig(history);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
let count = 0; let count = 0;
for (const message of history) { for (const message of rigMessages) {
if (count >= 200) break; if (count >= 200) break;
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue; if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
fragment.appendChild(renderRow(message)); fragment.appendChild(renderRow(message));
@@ -194,7 +196,9 @@ function initializeFtxDecoder(config) {
}; };
const receiveBatch = (messages) => { const receiveBatch = (messages) => {
if (messages.length === 0) return; if (messages.length === 0) return;
if (status) status.textContent = "Receiving"; if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
status.textContent = "Receiving";
}
history = messages.map(normalize).reverse().concat(history); history = messages.map(normalize).reverse().concat(history);
prune(); prune();
bridge.setFt8FamilyBarDecoder?.(id); bridge.setFt8FamilyBarDecoder?.(id);
@@ -232,7 +236,11 @@ function initializeFtxDecoder(config) {
prune(); prune();
render(); render();
}, },
reset reset,
rerender: () => {
bridge.updateFt8Bar?.();
render();
}
}); });
bridge.registerFt8FamilyBarRenderer?.(id, barFrames); bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
const updatePeriod = () => { const updatePeriod = () => {
@@ -8,7 +8,11 @@ function isActiveRigDecode(rigId) {
if (!activeRigId || !rigId) return true; if (!activeRigId || !rigId) return true;
return rigId === activeRigId; return rigId === activeRigId;
} }
function forActiveRig(items) {
return items.filter((item) => isActiveRigDecode(item.rig_id));
}
export { export {
isActiveRigDecode isActiveRigDecode,
forActiveRig
}; };
@@ -1,6 +1,6 @@
import { import {
isActiveRigDecode isActiveRigDecode
} from "./chunk-BQQXSNLC.js"; } from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -329,8 +329,9 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
})(); })();
}); });
function onServerCw(evt) { function onServerCw(evt) {
if (cwStatusEl) cwStatusEl.textContent = "Receiving"; const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
if (evt.text && cwOutputEl) { if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl && forSelectedRig) {
const now = Date.now(); const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") { if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
const line = document.createElement("div"); const line = document.createElement("div");
@@ -370,10 +371,10 @@ function onServerCw(evt) {
} }
updateCwBar(); updateCwBar();
} }
if (cwSignalIndicator) { if (cwSignalIndicator && forSelectedRig) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off"; cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
} }
if (!cwAutoInput || cwAutoInput.checked) { if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) { if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = String(clampCwWpm(evt.wpm)); cwWpmInput.value = String(clampCwWpm(evt.wpm));
} }
@@ -398,7 +399,14 @@ cwWindow.trxPluginRuntime.registerDecoder({
id: "cw", id: "cw",
onMessage: onServerCw, onMessage: onServerCw,
restore: restoreCwHistory, restore: restoreCwHistory,
reset: resetCwHistoryView reset: resetCwHistoryView,
// The copied text of a rig that is no longer on screen cannot be unpicked
// from the pane, so the switch starts the new rig's stream from empty.
rerender: () => {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
updateCwBar();
}
}); });
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() { cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
ensureCwToneCanvasResolution(); ensureCwToneCanvasResolution();
@@ -1,7 +1,7 @@
import { import {
initializeFtxDecoder initializeFtxDecoder
} from "./chunk-K3D6FOP5.js"; } from "./chunk-PAKFJPA2.js";
import "./chunk-BQQXSNLC.js"; import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js"; import "./chunk-KL66PICH.js";
// src/plugins/ft2.ts // src/plugins/ft2.ts
@@ -1,7 +1,7 @@
import { import {
initializeFtxDecoder initializeFtxDecoder
} from "./chunk-K3D6FOP5.js"; } from "./chunk-PAKFJPA2.js";
import "./chunk-BQQXSNLC.js"; import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js"; import "./chunk-KL66PICH.js";
// src/plugins/ft4.ts // src/plugins/ft4.ts
@@ -2,8 +2,8 @@ import {
initializeFt8FamilyBar, initializeFt8FamilyBar,
initializeFtxDecoder, initializeFtxDecoder,
installFtxCompatibilityHelpers installFtxCompatibilityHelpers
} from "./chunk-K3D6FOP5.js"; } from "./chunk-PAKFJPA2.js";
import "./chunk-BQQXSNLC.js"; import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js"; import "./chunk-KL66PICH.js";
// src/plugins/ft8.ts // src/plugins/ft8.ts
@@ -5,6 +5,10 @@ import {
normalizeAprsPacket, normalizeAprsPacket,
renderAprsPacketRow renderAprsPacketRow
} from "./chunk-OPEIVJGD.js"; } from "./chunk-OPEIVJGD.js";
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -67,21 +71,26 @@ function hfAprsFilterMatch(pkt) {
].filter(Boolean).join(" ").toUpperCase(); ].filter(Boolean).join(" ").toUpperCase();
return haystack.includes(hfAprsFilterText); return haystack.includes(hfAprsFilterText);
} }
function hfAprsRigPackets() {
return forActiveRig(hfAprsPacketHistory);
}
function hfAprsVisiblePackets() { function hfAprsVisiblePackets() {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory; const rigPackets = hfAprsRigPackets();
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(hfAprsFilterMatch); return packets.filter(hfAprsFilterMatch);
} }
var collapseHfAprsDuplicates = collapseAprsDuplicates; var collapseHfAprsDuplicates = collapseAprsDuplicates;
function updateHfAprsSummary() { function updateHfAprsSummary() {
const rigPackets = hfAprsRigPackets();
const visible = hfAprsVisiblePackets(); const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) { if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`; hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
} }
if (hfAprsVisibleCountEl) { if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`; hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
} }
if (hfAprsLatestSeenEl) { if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0]; const latest = rigPackets[0];
if (!latest) { if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet"; hfAprsLatestSeenEl.textContent = "No packets yet";
} else { } else {
@@ -158,10 +167,10 @@ function normalizeServerHfAprsPacket(pkt) {
} }
function onServerHfAprsBatch(packets) { function onServerHfAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return; if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
for (const pkt of packets) { for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt); const next = normalizeServerHfAprsPacket(pkt);
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -227,8 +236,9 @@ if (hfAprsFilterInput) {
}); });
} }
function onServerHfAprs(pkt) { function onServerHfAprs(pkt) {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; const packet = normalizeServerHfAprsPacket(pkt);
addHfAprsPacket(normalizeServerHfAprsPacket(pkt)); if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(packet);
} }
renderHfAprsHistory(); renderHfAprsHistory();
window.trxPluginRuntime.registerDecoder({ window.trxPluginRuntime.registerDecoder({
@@ -237,5 +247,6 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerHfAprsBatch, onBatch: onServerHfAprsBatch,
restore: onServerHfAprsBatch, restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView, reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView prune: pruneHfAprsHistoryView,
rerender: renderHfAprsHistory
}); });
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -111,6 +115,7 @@ function paintRow(line, rgb) {
liveRows = Math.max(liveRows, line + 1); liveRows = Math.max(liveRows, line + 1);
} }
function onProgress(msg) { function onProgress(msg) {
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state) { if (msg.state) {
if (sstvDom.status) sstvDom.status.textContent = msg.state; if (sstvDom.status) sstvDom.status.textContent = msg.state;
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0); beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
@@ -131,15 +136,20 @@ function onImage(msg) {
sstvHistory.push(image); sstvHistory.push(image);
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift(); if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
pruneHistory(); pruneHistory();
if (!isActiveRigDecode(image.rig_id)) return;
if (sstvDom.status) { if (sstvDom.status) {
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"}${image.lines ?? 0} lines`; sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"}${image.lines ?? 0} lines`;
} }
scheduleUi("sstv-latest", renderLatestCard); scheduleUi("sstv-latest", renderLatestCard);
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable); if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
} }
function sstvRigImages() {
return forActiveRig(sstvHistory);
}
function renderLatestCard() { function renderLatestCard() {
if (!sstvDom.liveLatest) return; if (!sstvDom.liveLatest) return;
const latest = sstvHistory[sstvHistory.length - 1]; const rigImages = sstvRigImages();
const latest = rigImages[rigImages.length - 1];
if (!latest) { if (!latest) {
sstvDom.liveLatest.innerHTML = ""; sstvDom.liveLatest.innerHTML = "";
return; return;
@@ -160,8 +170,9 @@ function renderLatestCard() {
</div>`; </div>`;
} }
function filteredHistory() { function filteredHistory() {
const rigImages = sstvRigImages();
const text = filterText.trim().toLowerCase(); const text = filterText.trim().toLowerCase();
const matching = text ? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : sstvHistory.slice(); const matching = text ? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : rigImages;
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest"; const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0))); matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
return matching; return matching;
@@ -241,7 +252,11 @@ sstvWindow.trxPluginRuntime.registerDecoder({
onMessage: onImage, onMessage: onImage,
restore: restoreHistory, restore: restoreHistory,
prune: renderHistoryTable, prune: renderHistoryTable,
reset: resetHistoryView reset: resetHistoryView,
rerender: () => {
renderLatestCard();
renderHistoryTable();
}
}); });
sstvWindow.trxPluginRuntime.registerDecoder({ sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv_progress", id: "sstv_progress",
@@ -1,6 +1,7 @@
import { import {
forActiveRig,
isActiveRigDecode isActiveRigDecode
} from "./chunk-BQQXSNLC.js"; } from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -63,17 +64,21 @@ function vdesHexPreview(rawBytes) {
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--"; if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase(); return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
} }
function vdesRigMessages() {
return forActiveRig(vdesMessageHistory);
}
function updateVdesSummary() { function updateVdesSummary() {
pruneVdesMessageHistory(); pruneVdesMessageHistory();
if (vdesChannelSummaryEl) { if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText(); vdesChannelSummaryEl.textContent = currentVdesCenterText();
} }
const rigMessages = vdesRigMessages();
if (vdesFrameCountEl) { if (vdesFrameCountEl) {
const count = vdesMessageHistory.length; const count = rigMessages.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`; vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
} }
if (vdesLatestSeenEl) { if (vdesLatestSeenEl) {
const latest = vdesMessageHistory[0]; const latest = rigMessages[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet"; vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
} }
} }
@@ -185,7 +190,7 @@ function renderVdesHistory() {
return; return;
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const message of vdesMessageHistory) { for (const message of vdesRigMessages()) {
fragment.appendChild(renderVdesRow(message)); fragment.appendChild(renderVdesRow(message));
} }
vdesMessagesEl.replaceChildren(fragment); vdesMessagesEl.replaceChildren(fragment);
@@ -212,10 +217,10 @@ function normalizeServerVdesMessage(msg) {
} }
function onServerVdesBatch(messages) { function onServerVdesBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -254,8 +259,8 @@ function plotVdesMessage(msg) {
vdesWindow.vdesMapAddPoint(msg); vdesWindow.vdesMapAddPoint(msg);
} }
function onServerVdes(msg) { function onServerVdes(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
addVdesMessage(next); addVdesMessage(next);
plotVdesMessage(next); plotVdesMessage(next);
} }
@@ -272,6 +277,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerVdesBatch, restore: onServerVdesBatch,
reset: resetVdesHistoryView, reset: resetVdesHistoryView,
prune: pruneVdesHistoryView, prune: pruneVdesHistoryView,
rerender: () => {
updateVdesBar();
renderVdesHistory();
},
// Oldest first, so tracks are rebuilt in the order they happened. // Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => { syncMap: () => {
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -102,13 +106,17 @@ function paintLine(lineBytes) {
wefaxLiveCtx.putImageData(imgData, 0, y); wefaxLiveCtx.putImageData(imgData, 0, y);
wefaxLiveLineCount++; wefaxLiveLineCount++;
} }
function wefaxRigImages() {
return forActiveRig(wefaxImageHistory);
}
function renderWefaxLatestCard() { function renderWefaxLatestCard() {
if (!wefaxDom.liveLatest) return; if (!wefaxDom.liveLatest) return;
if (wefaxImageHistory.length === 0) { const rigImages = wefaxRigImages();
if (rigImages.length === 0) {
wefaxDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>'; wefaxDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
return; return;
} }
const img = wefaxImageHistory[0]; const img = rigImages[0];
if (!img) return; if (!img) return;
const ts = img._ts || "--"; const ts = img._ts || "--";
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : ""; const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
@@ -129,7 +137,7 @@ function renderWefaxLatestCard() {
wefaxDom.liveLatest.innerHTML = html; wefaxDom.liveLatest.innerHTML = html;
} }
function getWefaxFilteredHistory() { function getWefaxFilteredHistory() {
let items = wefaxImageHistory; let items = wefaxRigImages();
if (wefaxFilterText) { if (wefaxFilterText) {
items = items.filter(function(i) { items = items.filter(function(i) {
const haystack = [ const haystack = [
@@ -173,7 +181,7 @@ function renderWefaxHistoryTable() {
} }
wefaxDom.historyList.replaceChildren(fragment); wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) { if (wefaxDom.historyCount) {
const total = wefaxImageHistory.length; const total = wefaxRigImages().length;
const shown = items.length; const shown = items.length;
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`; wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
} }
@@ -208,6 +216,7 @@ function addWefaxImage(msg) {
} }
} }
function onServerWefaxProgress(msg) { function onServerWefaxProgress(msg) {
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state && !msg.line_data) { if (msg.state && !msg.line_data) {
if (wefaxDom.status) { if (wefaxDom.status) {
wefaxDom.status.textContent = msg.state; wefaxDom.status.textContent = msg.state;
@@ -234,6 +243,7 @@ function onServerWefaxProgress(msg) {
} }
function onServerWefax(msg) { function onServerWefax(msg) {
addWefaxImage(msg); addWefaxImage(msg);
if (!isActiveRigDecode(msg.rig_id)) return;
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none"; if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
if (wefaxDom.status) { if (wefaxDom.status) {
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`; wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
@@ -328,7 +338,11 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
onMessage: onServerWefax, onMessage: onServerWefax,
restore: restoreWefaxHistory, restore: restoreWefaxHistory,
prune: pruneWefaxHistoryView, prune: pruneWefaxHistoryView,
reset: resetWefaxHistoryView reset: resetWefaxHistoryView,
rerender: () => {
renderWefaxLatestCard();
renderWefaxHistoryTable();
}
}); });
wefaxWindow.trxPluginRuntime.registerDecoder({ wefaxWindow.trxPluginRuntime.registerDecoder({
id: "wefax_progress", id: "wefax_progress",
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -64,10 +68,10 @@ function renderWsprRow(msg) {
function renderWsprHistory() { function renderWsprHistory() {
pruneWsprMessageHistory(); pruneWsprMessageHistory();
if (!wsprMessagesEl) return; if (!wsprMessagesEl) return;
const rigMessages = forActiveRig(wsprMessageHistory);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (let i = 0; i < wsprMessageHistory.length; i += 1) { for (const message of rigMessages) {
const message = wsprMessageHistory[i]; fragment.appendChild(renderWsprRow(message));
if (message) fragment.appendChild(renderWsprRow(message));
} }
wsprMessagesEl.replaceChildren(fragment); wsprMessagesEl.replaceChildren(fragment);
} }
@@ -90,6 +94,7 @@ function normalizeServerWsprMessage(msg) {
station, station,
rfHz, rfHz,
history: { history: {
rig_id: msg.rig_id ?? null,
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null, receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms, ts_ms: msg.ts_ms,
snr_db: msg.snr_db, snr_db: msg.snr_db,
@@ -101,10 +106,10 @@ function normalizeServerWsprMessage(msg) {
} }
function onServerWsprBatch(messages) { function onServerWsprBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (wsprStatus) wsprStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
if (next.grids.length > 0 && wsprWindow.mapAddLocator) { if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg, ...msg,
@@ -251,7 +256,7 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
})(); })();
}); });
function onServerWspr(msg) { function onServerWspr(msg) {
if (wsprStatus) wsprStatus.textContent = "Receiving"; if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) { if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
@@ -267,5 +272,6 @@ wsprWindow.trxPluginRuntime.registerDecoder({
onBatch: onServerWsprBatch, onBatch: onServerWsprBatch,
restore: onServerWsprBatch, restore: onServerWsprBatch,
prune: pruneWsprHistoryView, prune: pruneWsprHistoryView,
reset: resetWsprHistoryView reset: resetWsprHistoryView,
rerender: renderWsprHistory
}); });
@@ -2240,9 +2240,10 @@ function resetDecoderStateOnRigSwitch() {
}); });
// The decode stream is not rig-scoped, so the histories behind the mini views // The decode stream is not rig-scoped, so the histories behind the mini views
// survive the switch: drop the outgoing rig's frames from them now instead of // and the decoder panels survive the switch: repaint both for the rig now
// leaving them on screen until the next state update. // selected instead of leaving the outgoing rig's traffic on screen.
refreshDecodeBars(); refreshDecodeBars();
window.trxPluginRuntime.rerenderAll();
} }
function resetWfmStereoIndicator() { function resetWfmStereoIndicator() {
@@ -81,6 +81,7 @@ const runtime: TrxPluginRuntime = {
plugin.prune(); plugin.prune();
return true; return true;
}, },
rerenderAll() { for (const plugin of decoders.values()) plugin.rerender?.(); },
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); }, syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
clearQueued() { queued.clear(); }, clearQueued() { queued.clear(); },
hasDecoder: (id) => decoders.has(id), hasDecoder: (id) => decoders.has(id),
@@ -4,13 +4,14 @@
import { hostState } from "./host.js"; import { hostState } from "./host.js";
// The decode panels are aggregate views on purpose: they list what every rig // The decode stream is not rig-scoped: every rig a client is connected to sends
// heard, and the history they restore from is not rig-scoped either. The mini // its decodes to every browser, each frame naming the rig that heard it (the
// views over the waterfall are not aggregates they read as a caption for the // client stamps `rig_id` as it leaves the audio connection). The map wants all
// spectrum underneath, so a frame a background rig copied on another band // of them — it has its own filter, and plotting only one rig would empty it —
// belongs to a different picture entirely. Each decode names the rig that // but the radio page and the decoder panels describe one rig at a time: the one
// heard it (the client stamps `rig_id` as the frame leaves the audio // the operator selected, whose spectrum is on screen and whose audio is
// connection), which is all the overlays need to keep to the rig on screen. // playing. A frame a background rig copied on another band belongs to neither
// picture, so both filter on this.
export function isActiveRigDecode(rigId: string | null | undefined): boolean { export function isActiveRigDecode(rigId: string | null | undefined): boolean {
const activeRigId = hostState.lastActiveRigId; const activeRigId = hostState.lastActiveRigId;
// Before the rig list has arrived, and for a decode that reached the browser // Before the rig list has arrived, and for a decode that reached the browser
@@ -18,3 +19,8 @@ export function isActiveRigDecode(rigId: string | null | undefined): boolean {
if (!activeRigId || !rigId) return true; if (!activeRigId || !rigId) return true;
return rigId === activeRigId; return rigId === activeRigId;
} }
/** The decodes of `items` that the selected rig heard, in their original order. */
export function forActiveRig<T extends { rig_id?: string | null | undefined }>(items: T[]): T[] {
return items.filter((item) => isActiveRigDecode(item.rig_id));
}
@@ -6,7 +6,7 @@ import { hostCore, hostState } from "./host.js";
export {}; export {};
import { isActiveRigDecode } from "./active-rig.js"; import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract"; import type { PluginRuntimeWindow } from "./runtime-contract";
interface AisMessage { interface AisMessage {
@@ -191,20 +191,27 @@ function aisLatestByVessel(messages: AisMessage[]): AisMessage[] {
return Array.from(byMmsi.values()); return Array.from(byMmsi.values());
} }
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function aisRigMessages(): AisMessage[] {
return forActiveRig(aisMessageHistory);
}
function updateAisSummary() { function updateAisSummary() {
const plan = currentAisChannelPlan(); const plan = currentAisChannelPlan();
if (aisChannelSummaryEl) { if (aisChannelSummaryEl) {
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`; aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
} }
const vessels = aisLatestByVessel(aisMessageHistory); const rigMessages = aisRigMessages();
const vessels = aisLatestByVessel(rigMessages);
if (aisVesselCountEl) { if (aisVesselCountEl) {
const count = vessels.length; const count = vessels.length;
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`; aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
} }
if (aisLatestSeenEl) { if (aisLatestSeenEl) {
const latest = aisMessageHistory[0]; const latest = rigMessages[0];
if (!latest) { if (!latest) {
aisLatestSeenEl.textContent = "No traffic yet"; aisLatestSeenEl.textContent = "No traffic yet";
} else { } else {
@@ -371,7 +378,7 @@ function renderAisHistory() {
return; return;
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const message of aisMessageHistory) { for (const message of aisRigMessages()) {
fragment.appendChild(renderAisRow(message)); fragment.appendChild(renderAisRow(message));
} }
aisMessagesEl.replaceChildren(fragment); aisMessagesEl.replaceChildren(fragment);
@@ -410,10 +417,11 @@ function normalizeServerAisMessage(msg: AisMessage): AisMessage {
function onServerAisBatch(messages: AisMessage[]): void { function onServerAisBatch(messages: AisMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized: AisMessage[] = []; const normalized: AisMessage[] = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerAisMessage(msg); const next = normalizeServerAisMessage(msg);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -455,8 +463,9 @@ if (aisFilterInput) {
} }
function onServerAis(msg: AisMessage): void { function onServerAis(msg: AisMessage): void {
if (aisStatus) aisStatus.textContent = "Receiving"; const message = normalizeServerAisMessage(msg);
addAisMessage(normalizeServerAisMessage(msg)); if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
addAisMessage(message);
} }
updateAisSummary(); updateAisSummary();
@@ -467,6 +476,7 @@ updateAisSummary();
restore: onServerAisBatch, restore: onServerAisBatch,
reset: resetAisHistoryView, reset: resetAisHistoryView,
prune: pruneAisHistoryView, prune: pruneAisHistoryView,
rerender: () => { updateAisBar(); renderAisHistory(); },
// Oldest first, so vessel tracks are rebuilt in the order they happened. // Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); }, syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
}); });
@@ -4,7 +4,7 @@
import { hostCore, hostState } from "./host.js"; import { hostCore, hostState } from "./host.js";
import { isActiveRigDecode } from "./active-rig.js"; import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import { import {
aprsAgeText, aprsAgeText,
aprsPacketCategory, aprsPacketCategory,
@@ -107,21 +107,29 @@ function aprsFilterMatch(pkt: AprsPacket): boolean {
return haystack.includes(aprsFilterText); return haystack.includes(aprsFilterText);
} }
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function aprsRigPackets(): AprsPacket[] {
return forActiveRig(aprsPacketHistory);
}
function aprsVisiblePackets(): AprsPacket[] { function aprsVisiblePackets(): AprsPacket[] {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory; const rigPackets = aprsRigPackets();
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(aprsFilterMatch); return packets.filter(aprsFilterMatch);
} }
function updateAprsSummary() { function updateAprsSummary() {
const rigPackets = aprsRigPackets();
const visible = aprsVisiblePackets(); const visible = aprsVisiblePackets();
if (aprsTotalCountEl) { if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`; aprsTotalCountEl.textContent = `${rigPackets.length} total`;
} }
if (aprsVisibleCountEl) { if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`; aprsVisibleCountEl.textContent = `${visible.length} shown`;
} }
if (aprsLatestSeenEl) { if (aprsLatestSeenEl) {
const latest = aprsPacketHistory[0]; const latest = rigPackets[0];
if (!latest) { if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet"; aprsLatestSeenEl.textContent = "No packets yet";
} else { } else {
@@ -259,11 +267,12 @@ function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
function onServerAprsBatch(packets: AprsPacket[]): void { function onServerAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return; if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = []; const normalized: AprsPacket[] = [];
let hasCrcOk = false; let hasCrcOk = false;
for (const pkt of packets) { for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt); const next = normalizeServerAprsPacket(pkt);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -327,8 +336,9 @@ if (aprsFilterInput) {
// --- Server-side APRS decode handler --- // --- Server-side APRS decode handler ---
function onServerAprs(pkt: AprsPacket): void { function onServerAprs(pkt: AprsPacket): void {
if (aprsStatus) aprsStatus.textContent = "Receiving"; const packet = normalizeServerAprsPacket(pkt);
addAprsPacket(normalizeServerAprsPacket(pkt)); if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
addAprsPacket(packet);
} }
renderAprsHistory(); renderAprsHistory();
@@ -339,6 +349,7 @@ renderAprsHistory();
restore: onServerAprsBatch, restore: onServerAprsBatch,
reset: resetAprsHistoryView, reset: resetAprsHistoryView,
prune: pruneAprsHistoryView, prune: pruneAprsHistoryView,
rerender: () => { updateAprsBar(); renderAprsHistory(); },
// Oldest first, so station tracks are rebuilt in the order they happened. // Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); }, syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
}); });
@@ -432,8 +432,13 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
// --- Server-side CW decode handler --- // --- Server-side CW decode handler ---
function onServerCw(evt: CwEvent): void { function onServerCw(evt: CwEvent): void {
if (cwStatusEl) cwStatusEl.textContent = "Receiving"; // The panel is a single stream of copied text: characters from a background
if (evt.text && cwOutputEl) { // rig do not belong in it at all, and interleaving them would make both
// rigs unreadable. The bar histories below keep every rig, each on its own
// line, and pick out the selected rig when they paint.
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl && forSelectedRig) {
// Append decoded text to output // Append decoded text to output
const now = Date.now(); const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") { if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
@@ -475,10 +480,10 @@ function onServerCw(evt: CwEvent): void {
} }
updateCwBar(); updateCwBar();
} }
if (cwSignalIndicator) { if (cwSignalIndicator && forSelectedRig) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off"; cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
} }
if (!cwAutoInput || cwAutoInput.checked) { if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) { if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = String(clampCwWpm(evt.wpm)); cwWpmInput.value = String(clampCwWpm(evt.wpm));
} }
@@ -506,6 +511,13 @@ cwWindow.trxPluginRuntime.registerDecoder({
onMessage: onServerCw, onMessage: onServerCw,
restore: restoreCwHistory, restore: restoreCwHistory,
reset: resetCwHistoryView, reset: resetCwHistoryView,
// The copied text of a rig that is no longer on screen cannot be unpicked
// from the pane, so the switch starts the new rig's stream from empty.
rerender: () => {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
updateCwBar();
},
}); });
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() { cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
@@ -4,7 +4,7 @@
import { hostCore } from "./host.js"; import { hostCore } from "./host.js";
import { isActiveRigDecode } from "./active-rig.js"; import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export type FtxDecoderId = "ft2" | "ft4" | "ft8"; export type FtxDecoderId = "ft2" | "ft4" | "ft8";
@@ -229,9 +229,12 @@ export function initializeFtxDecoder(config: FtxConfig): void {
const render = (): void => { const render = (): void => {
prune(); prune();
if (!messagesElement) return; if (!messagesElement) return;
// The history holds every rig's decodes, for the map; the panel is about
// the rig the operator is working.
const rigMessages = forActiveRig(history);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
let count = 0; let count = 0;
for (const message of history) { for (const message of rigMessages) {
if (count >= 200) break; if (count >= 200) break;
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue; if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
fragment.appendChild(renderRow(message)); fragment.appendChild(renderRow(message));
@@ -267,7 +270,10 @@ export function initializeFtxDecoder(config: FtxConfig): void {
}; };
const receiveBatch = (messages: FtxMessage[]): void => { const receiveBatch = (messages: FtxMessage[]): void => {
if (messages.length === 0) return; if (messages.length === 0) return;
if (status) status.textContent = "Receiving"; // "Receiving" is the panel's own status, and the panel is the rig's.
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
status.textContent = "Receiving";
}
history = messages.map(normalize).reverse().concat(history); history = messages.map(normalize).reverse().concat(history);
prune(); prune();
bridge.setFt8FamilyBarDecoder?.(id); bridge.setFt8FamilyBarDecoder?.(id);
@@ -305,6 +311,7 @@ export function initializeFtxDecoder(config: FtxConfig): void {
restore: receiveBatch, restore: receiveBatch,
prune: () => { prune(); render(); }, prune: () => { prune(); render(); },
reset, reset,
rerender: () => { bridge.updateFt8Bar?.(); render(); },
}); });
bridge.registerFt8FamilyBarRenderer?.(id, barFrames); bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
@@ -4,6 +4,7 @@
import { hostCore, hostState } from "./host.js"; import { hostCore, hostState } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import { import {
aprsAgeText, aprsAgeText,
aprsPacketCategory, aprsPacketCategory,
@@ -90,23 +91,31 @@ function hfAprsFilterMatch(pkt: AprsPacket): boolean {
return haystack.includes(hfAprsFilterText); return haystack.includes(hfAprsFilterText);
} }
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function hfAprsRigPackets(): AprsPacket[] {
return forActiveRig(hfAprsPacketHistory);
}
function hfAprsVisiblePackets(): AprsPacket[] { function hfAprsVisiblePackets(): AprsPacket[] {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory; const rigPackets = hfAprsRigPackets();
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(hfAprsFilterMatch); return packets.filter(hfAprsFilterMatch);
} }
const collapseHfAprsDuplicates = collapseAprsDuplicates; const collapseHfAprsDuplicates = collapseAprsDuplicates;
function updateHfAprsSummary() { function updateHfAprsSummary() {
const rigPackets = hfAprsRigPackets();
const visible = hfAprsVisiblePackets(); const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) { if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`; hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
} }
if (hfAprsVisibleCountEl) { if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`; hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
} }
if (hfAprsLatestSeenEl) { if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0]; const latest = rigPackets[0];
if (!latest) { if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet"; hfAprsLatestSeenEl.textContent = "No packets yet";
} else { } else {
@@ -192,10 +201,11 @@ function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket {
function onServerHfAprsBatch(packets: AprsPacket[]): void { function onServerHfAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return; if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = []; const normalized: AprsPacket[] = [];
for (const pkt of packets) { for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt); const next = normalizeServerHfAprsPacket(pkt);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -266,8 +276,9 @@ if (hfAprsFilterInput) {
// --- Server-side HF APRS decode handler --- // --- Server-side HF APRS decode handler ---
function onServerHfAprs(pkt: AprsPacket): void { function onServerHfAprs(pkt: AprsPacket): void {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; const packet = normalizeServerHfAprsPacket(pkt);
addHfAprsPacket(normalizeServerHfAprsPacket(pkt)); if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(packet);
} }
renderHfAprsHistory(); renderHfAprsHistory();
@@ -278,4 +289,5 @@ renderHfAprsHistory();
restore: onServerHfAprsBatch, restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView, reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView, prune: pruneHfAprsHistoryView,
rerender: renderHfAprsHistory,
}); });
@@ -9,6 +9,10 @@ export interface DecoderPlugin<TMessage = unknown> {
restore?(messages: TMessage[]): void; restore?(messages: TMessage[]): void;
reset?(): void; reset?(): void;
prune?(): void; prune?(): void;
/** Repaint the plugin's panel from what it already holds. Called when the
* selected rig changes: the panels show one rig's traffic, and the decodes
* behind them arrived long before the switch. */
rerender?(): void;
/** Replay everything the plugin is holding onto the map. Called when the map /** Replay everything the plugin is holding onto the map. Called when the map
* module attaches, which can happen long after the decodes arrived. */ * module attaches, which can happen long after the decodes arrived. */
syncMap?(): void; syncMap?(): void;
@@ -22,6 +26,7 @@ export interface TrxPluginRuntime {
reset(id: string): boolean; reset(id: string): boolean;
resetAll(): void; resetAll(): void;
prune(id: string): boolean; prune(id: string): boolean;
rerenderAll(): void;
syncMapAll(): void; syncMapAll(): void;
clearQueued(): void; clearQueued(): void;
hasDecoder(id: string): boolean; hasDecoder(id: string): boolean;
@@ -14,11 +14,13 @@
import { hostCore } from "./host.js"; import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {}; export {};
interface SstvImage { interface SstvImage {
rig_id?: string | null;
ts_ms?: number; ts_ms?: number;
vis?: number; vis?: number;
mode?: string; mode?: string;
@@ -32,6 +34,7 @@ interface SstvImage {
} }
interface SstvProgress { interface SstvProgress {
rig_id?: string | null;
state?: string; state?: string;
mode?: string; mode?: string;
width?: number; width?: number;
@@ -185,6 +188,9 @@ function paintRow(line: number, rgb: Uint8Array): void {
// ── Server messages ───────────────────────────────────────────────── // ── Server messages ─────────────────────────────────────────────────
function onProgress(msg: SstvProgress): void { function onProgress(msg: SstvProgress): void {
// The live canvas draws one picture at a time: a background rig's rows would
// paint straight over the one being watched.
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state) { if (msg.state) {
if (sstvDom.status) sstvDom.status.textContent = msg.state; if (sstvDom.status) sstvDom.status.textContent = msg.state;
// A state update carries the geometry; a row update carries only the row. // A state update carries the geometry; a row update carries only the row.
@@ -208,6 +214,9 @@ function onImage(msg: SstvImage): void {
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift(); if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
pruneHistory(); pruneHistory();
// Kept in the history either way, so switching to that rig finds it there.
if (!isActiveRigDecode(image.rig_id)) return;
if (sstvDom.status) { if (sstvDom.status) {
sstvDom.status.textContent = image.complete sstvDom.status.textContent = image.complete
? `Received ${image.mode ?? "picture"}` ? `Received ${image.mode ?? "picture"}`
@@ -219,9 +228,15 @@ function onImage(msg: SstvImage): void {
// ── Rendering ─────────────────────────────────────────────────────── // ── Rendering ───────────────────────────────────────────────────────
/** The pictures the selected rig decoded, oldest first, as the history is kept. */
function sstvRigImages(): SstvImage[] {
return forActiveRig(sstvHistory);
}
function renderLatestCard(): void { function renderLatestCard(): void {
if (!sstvDom.liveLatest) return; if (!sstvDom.liveLatest) return;
const latest = sstvHistory[sstvHistory.length - 1]; const rigImages = sstvRigImages();
const latest = rigImages[rigImages.length - 1];
if (!latest) { if (!latest) {
sstvDom.liveLatest.innerHTML = ""; sstvDom.liveLatest.innerHTML = "";
return; return;
@@ -245,10 +260,12 @@ function renderLatestCard(): void {
} }
function filteredHistory(): SstvImage[] { function filteredHistory(): SstvImage[] {
// Every rig's pictures are kept; the panel shows the selected rig's.
const rigImages = sstvRigImages();
const text = filterText.trim().toLowerCase(); const text = filterText.trim().toLowerCase();
const matching = text const matching = text
? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) ? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
: sstvHistory.slice(); : rigImages;
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest"; const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0))); matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
return matching; return matching;
@@ -348,6 +365,7 @@ sstvWindow.trxPluginRuntime.registerDecoder({
restore: restoreHistory, restore: restoreHistory,
prune: renderHistoryTable, prune: renderHistoryTable,
reset: resetHistoryView, reset: resetHistoryView,
rerender: () => { renderLatestCard(); renderHistoryTable(); },
}); });
sstvWindow.trxPluginRuntime.registerDecoder({ sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv_progress", id: "sstv_progress",
@@ -6,7 +6,7 @@ import { hostCore } from "./host.js";
export {}; export {};
import { isActiveRigDecode } from "./active-rig.js"; import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract"; import type { PluginRuntimeWindow } from "./runtime-contract";
interface VdesMessage { interface VdesMessage {
@@ -117,17 +117,24 @@ function vdesHexPreview(rawBytes: number[] | undefined): string {
.toUpperCase(); .toUpperCase();
} }
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function vdesRigMessages(): VdesMessage[] {
return forActiveRig(vdesMessageHistory);
}
function updateVdesSummary() { function updateVdesSummary() {
pruneVdesMessageHistory(); pruneVdesMessageHistory();
if (vdesChannelSummaryEl) { if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText(); vdesChannelSummaryEl.textContent = currentVdesCenterText();
} }
const rigMessages = vdesRigMessages();
if (vdesFrameCountEl) { if (vdesFrameCountEl) {
const count = vdesMessageHistory.length; const count = rigMessages.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`; vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
} }
if (vdesLatestSeenEl) { if (vdesLatestSeenEl) {
const latest = vdesMessageHistory[0]; const latest = rigMessages[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet"; vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
} }
} }
@@ -281,7 +288,7 @@ function renderVdesHistory() {
return; return;
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const message of vdesMessageHistory) { for (const message of vdesRigMessages()) {
fragment.appendChild(renderVdesRow(message)); fragment.appendChild(renderVdesRow(message));
} }
vdesMessagesEl.replaceChildren(fragment); vdesMessagesEl.replaceChildren(fragment);
@@ -312,10 +319,11 @@ function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage {
function onServerVdesBatch(messages: VdesMessage[]): void { function onServerVdesBatch(messages: VdesMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized: VdesMessage[] = []; const normalized: VdesMessage[] = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -357,8 +365,8 @@ function plotVdesMessage(msg: VdesMessage): void {
} }
function onServerVdes(msg: VdesMessage): void { function onServerVdes(msg: VdesMessage): void {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
addVdesMessage(next); addVdesMessage(next);
plotVdesMessage(next); plotVdesMessage(next);
} }
@@ -377,6 +385,7 @@ updateVdesSummary();
restore: onServerVdesBatch, restore: onServerVdesBatch,
reset: resetVdesHistoryView, reset: resetVdesHistoryView,
prune: pruneVdesHistoryView, prune: pruneVdesHistoryView,
rerender: () => { updateVdesBar(); renderVdesHistory(); },
// Oldest first, so tracks are rebuilt in the order they happened. // Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); }, syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
}); });
@@ -4,11 +4,13 @@
import { hostCore } from "./host.js"; import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {}; export {};
interface WefaxImage { interface WefaxImage {
rig_id?: string | null;
ts_ms?: number; ts_ms?: number;
ioc?: number; ioc?: number;
lpm?: number; lpm?: number;
@@ -20,6 +22,7 @@ interface WefaxImage {
} }
interface WefaxProgress { interface WefaxProgress {
rig_id?: string | null;
state?: string; state?: string;
line_data?: string; line_data?: string;
line_count?: number; line_count?: number;
@@ -156,15 +159,21 @@ function paintLine(lineBytes: Uint8Array): void {
} }
// ── Live view: latest image card ──────────────────────────────────── // ── Live view: latest image card ────────────────────────────────────
/** The pictures the selected rig decoded, newest first. */
function wefaxRigImages(): WefaxImage[] {
return forActiveRig(wefaxImageHistory);
}
function renderWefaxLatestCard() { function renderWefaxLatestCard() {
if (!wefaxDom.liveLatest) return; if (!wefaxDom.liveLatest) return;
if (wefaxImageHistory.length === 0) { const rigImages = wefaxRigImages();
if (rigImages.length === 0) {
wefaxDom.liveLatest.innerHTML = wefaxDom.liveLatest.innerHTML =
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>'; '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
return; return;
} }
const img = wefaxImageHistory[0]; const img = rigImages[0];
if (!img) return; if (!img) return;
const ts = img._ts || '--'; const ts = img._ts || '--';
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : ''; const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
@@ -193,7 +202,8 @@ function renderWefaxLatestCard() {
// ── History view: table ───────────────────────────────────────────── // ── History view: table ─────────────────────────────────────────────
function getWefaxFilteredHistory() { function getWefaxFilteredHistory() {
let items = wefaxImageHistory; // Every rig's pictures are kept; the panel shows the selected rig's.
let items = wefaxRigImages();
if (wefaxFilterText) { if (wefaxFilterText) {
items = items.filter(function (i) { items = items.filter(function (i) {
@@ -253,7 +263,7 @@ function renderWefaxHistoryTable() {
wefaxDom.historyList.replaceChildren(fragment); wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) { if (wefaxDom.historyCount) {
const total = wefaxImageHistory.length; const total = wefaxRigImages().length;
const shown = items.length; const shown = items.length;
wefaxDom.historyCount.textContent = wefaxDom.historyCount.textContent =
total === 0 total === 0
@@ -298,6 +308,9 @@ function addWefaxImage(msg: WefaxImage): void {
// ── SSE event handlers (public API) ───────────────────────────────── // ── SSE event handlers (public API) ─────────────────────────────────
function onServerWefaxProgress(msg: WefaxProgress): void { function onServerWefaxProgress(msg: WefaxProgress): void {
// The live canvas draws one picture at a time: a background rig's lines
// would paint straight over the one being watched.
if (!isActiveRigDecode(msg.rig_id)) return;
// State-only update (no image data): show decoder state in status. // State-only update (no image data): show decoder state in status.
if (msg.state && !msg.line_data) { if (msg.state && !msg.line_data) {
if (wefaxDom.status) { if (wefaxDom.status) {
@@ -331,6 +344,8 @@ function onServerWefaxProgress(msg: WefaxProgress): void {
function onServerWefax(msg: WefaxImage): void { function onServerWefax(msg: WefaxImage): void {
addWefaxImage(msg); addWefaxImage(msg);
// Kept in the history either way, so switching to that rig finds it there.
if (!isActiveRigDecode(msg.rig_id)) return;
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none'; if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
if (wefaxDom.status) { if (wefaxDom.status) {
@@ -438,6 +453,7 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
restore: restoreWefaxHistory, restore: restoreWefaxHistory,
prune: pruneWefaxHistoryView, prune: pruneWefaxHistoryView,
reset: resetWefaxHistoryView, reset: resetWefaxHistoryView,
rerender: () => { renderWefaxLatestCard(); renderWefaxHistoryTable(); },
}); });
wefaxWindow.trxPluginRuntime.registerDecoder({ wefaxWindow.trxPluginRuntime.registerDecoder({
id: "wefax_progress", id: "wefax_progress",
@@ -4,6 +4,7 @@
import { hostCore } from "./host.js"; import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {}; export {};
@@ -15,6 +16,7 @@ interface WsprMessage {
snr_db?: number | undefined; snr_db?: number | undefined;
dt_s?: number | undefined; dt_s?: number | undefined;
freq_hz?: number | undefined; freq_hz?: number | undefined;
rig_id?: string | null | undefined;
receiver?: unknown; receiver?: unknown;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -102,10 +104,12 @@ function renderWsprRow(msg: WsprMessage): HTMLDivElement {
function renderWsprHistory(): void { function renderWsprHistory(): void {
pruneWsprMessageHistory(); pruneWsprMessageHistory();
if (!wsprMessagesEl) return; if (!wsprMessagesEl) return;
// The history holds every rig's decodes, for the map; the panel is about the
// rig the operator is working.
const rigMessages = forActiveRig(wsprMessageHistory);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (let i = 0; i < wsprMessageHistory.length; i += 1) { for (const message of rigMessages) {
const message = wsprMessageHistory[i]; fragment.appendChild(renderWsprRow(message));
if (message) fragment.appendChild(renderWsprRow(message));
} }
wsprMessagesEl.replaceChildren(fragment); wsprMessagesEl.replaceChildren(fragment);
} }
@@ -130,6 +134,7 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
station, station,
rfHz, rfHz,
history: { history: {
rig_id: msg.rig_id ?? null,
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null, receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms, ts_ms: msg.ts_ms,
snr_db: msg.snr_db, snr_db: msg.snr_db,
@@ -142,10 +147,11 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
function onServerWsprBatch(messages: WsprMessage[]): void { function onServerWsprBatch(messages: WsprMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (wsprStatus) wsprStatus.textContent = "Receiving";
const normalized: WsprMessage[] = []; const normalized: WsprMessage[] = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
if (next.grids.length > 0 && wsprWindow.mapAddLocator) { if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg, ...msg,
@@ -312,7 +318,7 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
}); });
function onServerWspr(msg: WsprMessage): void { function onServerWspr(msg: WsprMessage): void {
if (wsprStatus) wsprStatus.textContent = "Receiving"; if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) { if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
@@ -330,4 +336,5 @@ wsprWindow.trxPluginRuntime.registerDecoder({
restore: onServerWsprBatch, restore: onServerWsprBatch,
prune: pruneWsprHistoryView, prune: pruneWsprHistoryView,
reset: resetWsprHistoryView, reset: resetWsprHistoryView,
rerender: renderWsprHistory,
}); });
@@ -2,11 +2,12 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
// The mini views over the waterfall caption the spectrum below them, so they // The radio page and the digital modes page both describe one rig: the one the
// show only what the rig on screen heard. The panels on the decoder tabs stay // operator selected. Every rig a client is connected to feeds the same decode
// aggregate — every rig's traffic lands there — which is the distinction these // stream, so both the mini views over the waterfall and the panels behind them
// tests pin down: a decode from a background rig belongs in the list and not in // filter on the rig that heard each decode. The map is the exception and keeps
// the overlay. // all of them — it has its own rig filter — which is why the histories these
// views read from are never pruned by rig.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
@@ -65,12 +66,14 @@ async function runPlugin(entry, { window, document: doc, extras = {} }) {
return context; return context;
} }
test("the APRS mini view keeps to the active rig while the panel lists both", async () => { test("the APRS mini view and panel both keep to the active rig", async () => {
const overlay = new ElementFixture(); const overlay = new ElementFixture();
const packets = new ElementFixture(); const packets = new ElementFixture();
const totals = new ElementFixture();
const elements = new Map([ const elements = new Map([
["aprs-bar-overlay", overlay], ["aprs-bar-overlay", overlay],
["aprs-packets", packets], ["aprs-packets", packets],
["aprs-total-count", totals],
["mode", new ElementFixture("PKT")], ["mode", new ElementFixture("PKT")],
]); ]);
const window = { const window = {
@@ -86,7 +89,8 @@ test("the APRS mini view keeps to the active rig while the panel lists both", as
assert.match(overlay.innerHTML, /SP1AAA/, "the active rig's frame is missing from the mini view"); assert.match(overlay.innerHTML, /SP1AAA/, "the active rig's frame is missing from the mini view");
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's frame reached the mini view"); assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's frame reached the mini view");
assert.equal(packets.children.length, 2, "the APRS panel dropped a frame it should still list"); assert.equal(packets.children.length, 1, "the APRS panel listed a background rig's frame");
assert.equal(totals.textContent, "1 total", `the APRS panel counted "${totals.textContent}"`);
}); });
test("the APRS mini view shows every frame until a rig is known", async () => { test("the APRS mini view shows every frame until a rig is known", async () => {
@@ -196,3 +200,149 @@ test("the FT8 mini view keeps to the active rig", async () => {
assert.match(overlay.innerHTML, /SP1AAA/); assert.match(overlay.innerHTML, /SP1AAA/);
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's decode reached the mini view"); assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's decode reached the mini view");
}); });
// ── The digital modes panels ───────────────────────────────────────────────
// The same rule one page over: a panel lists the selected rig's decodes only.
test("the AIS panel lists the active rig's vessels only", async () => {
const messages = new ElementFixture();
const count = new ElementFixture();
const elements = new Map([
["ais-messages", messages],
["ais-vessel-count", count],
["mode", new ElementFixture("AIS")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
aisMapAddVessel: () => {},
};
await runPlugin("ais", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the AIS panel listed a background rig's vessel");
assert.equal(count.textContent, "1 vessel", `the panel counted "${count.textContent}"`);
});
test("the VDES panel lists the active rig's bursts only", async () => {
const messages = new ElementFixture();
const count = new ElementFixture();
const elements = new Map([
["vdes-messages", messages],
["vdes-frame-count", count],
["mode", new ElementFixture("VDES")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
vdesMapAddPoint: () => {},
};
await runPlugin("vdes", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the VDES panel listed a background rig's burst");
assert.equal(count.textContent, "1 burst", `the panel counted "${count.textContent}"`);
});
test("the FT8 panel lists the active rig's decodes only", async () => {
const messages = new ElementFixture();
const elements = new Map([
["ft8-messages", messages],
["mode", new ElementFixture("DIG")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
ft8BaseHz: 7_074_000,
trxUi: { confirm: async () => true },
mapAddLocator: () => {},
setInterval() { return 1; },
};
await runPlugin("ft8", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the FT8 panel listed a background rig's decode");
});
test("the WSPR panel lists the active rig's spots only", async () => {
const messages = new ElementFixture();
const elements = new Map([
["wspr-messages", messages],
["mode", new ElementFixture("DIG")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
ft8BaseHz: 14_095_600,
trxUi: { confirm: async () => true },
mapAddLocator: () => {},
};
await runPlugin("wspr", {
window,
document: createDocument(elements),
extras: { setInterval: () => 1 },
});
const now = Date.now();
window.trxPluginRuntime.dispatch("wspr", { message: "SP1AAA JO91 30", freq_hz: 1_500, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("wspr", { message: "SP2BBB JO94 27", freq_hz: 1_520, ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the WSPR panel listed a background rig's spot");
});
test("the CW pane copies the active rig only", async () => {
const output = new ElementFixture();
const elements = new Map([
["cw-output", output],
["cw-bar-overlay", new ElementFixture()],
["mode", new ElementFixture("CW")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
addEventListener() {},
};
await runPlugin("cw", { window, document: createDocument(elements) });
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("cw", { text: "XX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
const copied = output.children.map((line) => line.textContent).join("");
assert.equal(copied, "CQ SP1AAA", `the pane copied "${copied}"`);
});
test("switching rigs repaints the panels through the runtime", async () => {
const packets = new ElementFixture();
const elements = new Map([
["aprs-packets", packets],
["aprs-bar-overlay", new ElementFixture()],
["mode", new ElementFixture("PKT")],
]);
// A mutable host: the rig picker writes lastActiveRigId, and the panels read
// it as they paint.
const host = createHost({ state: { lastActiveRigId: "rig-a" } });
let activeRigId = "rig-a";
Object.defineProperty(host.trx.state, "lastActiveRigId", { get: () => activeRigId });
const window = { ...host, trxUi: { confirm: async () => true }, aprsMapAddStation: () => {} };
await runPlugin("aprs", { window, document: createDocument(elements) });
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
assert.equal(packets.children.length, 1);
activeRigId = "rig-b";
window.trxPluginRuntime.rerenderAll();
assert.equal(packets.children.length, 1, "the panel did not follow the switch");
const shown = packets.children.map((row) => row.innerHTML).join("");
assert.match(shown, /SP2BBB/, "the panel still shows the rig that was switched away from");
assert.doesNotMatch(shown, /SP1AAA/);
});
@@ -22,9 +22,9 @@ const BEACON = {
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65, packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a", symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
}; };
// A second rig listening in the background, on its own band. Its traffic // A second rig listening in the background, on its own band. Both pages
// belongs in the panels, which aggregate every rig, but not in the mini view, // describe the rig on screen, so its traffic belongs in neither the panel nor
// which captions the spectrum of the rig on screen. // the mini view — only on the map, which shows the whole station.
const OTHER_RIG_VESSEL = { const OTHER_RIG_VESSEL = {
...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY", ...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY",
lat: 51.92, lon: 4.48, rig_id: "rig-b", lat: 51.92, lon: 4.48, rig_id: "rig-b",
@@ -51,12 +51,13 @@ try {
aisStatus: document.getElementById("ais-status")?.textContent ?? "", aisStatus: document.getElementById("ais-status")?.textContent ?? "",
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "", aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false, otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false,
aisRows: document.getElementById("ais-messages")?.children.length ?? 0,
mapLoaded: !!window.trx.modules.map, mapLoaded: !!window.trx.modules.map,
})); }));
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing"); assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`); assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`); assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
assert.equal(panels.otherRig, true, "the AIS panel dropped the background rig's vessel"); assert.equal(panels.otherRig, false, "the AIS panel listed a background rig's vessel");
// The mini view rides over the waterfall on the radio page. // The mini view rides over the waterfall on the radio page.
await page.locator('.tab[data-tab="main"]').click(); await page.locator('.tab[data-tab="main"]').click();
@@ -102,8 +103,11 @@ try {
: Object.keys(collection ?? {}).length); : Object.keys(collection ?? {}).length);
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) }; return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
}); });
assert.ok(markers.ais > 0, "the vessel never reached the map");
assert.ok(markers.stations > 0, "the APRS station never reached the map"); assert.ok(markers.stations > 0, "the APRS station never reached the map");
// The map is the whole station's view — the one place a background rig's
// traffic belongs — so both vessels are on it even though the panel and the
// mini view show only the selected rig's.
assert.equal(markers.ais, 2, `${markers.ais} of 2 vessels reached the map`);
assert.deepEqual(runtimeErrors, []); assert.deepEqual(runtimeErrors, []);
} finally { } finally {