From bf3bcc8a846c4767888d81d8340d5c2a480aedb5 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Fri, 7 Aug 2026 08:09:42 +0200 Subject: [PATCH] [fix](trx-frontend-http): show one rig at a time on the digital modes page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- docs/User-Manual.md | 23 +++ .../assets/web/generated/ais.js | 24 ++- .../assets/web/generated/app.js | 4 + .../assets/web/generated/aprs.js | 25 ++- .../{chunk-K3D6FOP5.js => chunk-PAKFJPA2.js} | 16 +- .../{chunk-BQQXSNLC.js => chunk-S57W63QN.js} | 6 +- .../assets/web/generated/cw.js | 20 ++- .../assets/web/generated/ft2.js | 4 +- .../assets/web/generated/ft4.js | 4 +- .../assets/web/generated/ft8.js | 4 +- .../assets/web/generated/hf-aprs.js | 25 ++- .../assets/web/generated/sstv.js | 21 ++- .../assets/web/generated/vdes.js | 21 ++- .../assets/web/generated/wefax.js | 24 ++- .../assets/web/generated/wspr.js | 18 +- .../trx-frontend-http/frontend/src/app.ts | 5 +- .../frontend/src/plugin-runtime.ts | 1 + .../frontend/src/plugins/active-rig.ts | 20 ++- .../frontend/src/plugins/ais.ts | 24 ++- .../frontend/src/plugins/aprs.ts | 25 ++- .../frontend/src/plugins/cw.ts | 20 ++- .../frontend/src/plugins/ftx-family.ts | 13 +- .../frontend/src/plugins/hf-aprs.ts | 24 ++- .../frontend/src/plugins/runtime-contract.ts | 5 + .../frontend/src/plugins/sstv.ts | 24 ++- .../frontend/src/plugins/vdes.ts | 21 ++- .../frontend/src/plugins/wefax.ts | 24 ++- .../frontend/src/plugins/wspr.ts | 17 +- ...-view-rig.test.mjs => active-rig.test.mjs} | 164 +++++++++++++++++- .../frontend/tests/decode-flow.mjs | 14 +- 30 files changed, 516 insertions(+), 124 deletions(-) rename src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/{chunk-K3D6FOP5.js => chunk-PAKFJPA2.js} (97%) rename src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/{chunk-BQQXSNLC.js => chunk-S57W63QN.js} (65%) rename src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/{mini-view-rig.test.mjs => active-rig.test.mjs} (51%) diff --git a/docs/User-Manual.md b/docs/User-Manual.md index 65de98ff..8230e3bb 100644 --- a/docs/User-Manual.md +++ b/docs/User-Manual.md @@ -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 Every page of the web UI carries what the radio is doing in its address, so the diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js index 093be46c..896a2191 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js @@ -1,6 +1,7 @@ import { + forActiveRig, isActiveRigDecode -} from "./chunk-BQQXSNLC.js"; +} from "./chunk-S57W63QN.js"; import { hostCore, hostState @@ -143,18 +144,22 @@ function aisLatestByVessel(messages) { } return Array.from(byMmsi.values()); } +function aisRigMessages() { + return forActiveRig(aisMessageHistory); +} function updateAisSummary() { const plan = currentAisChannelPlan(); if (aisChannelSummaryEl) { aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`; } - const vessels = aisLatestByVessel(aisMessageHistory); + const rigMessages = aisRigMessages(); + const vessels = aisLatestByVessel(rigMessages); if (aisVesselCountEl) { const count = vessels.length; aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`; } if (aisLatestSeenEl) { - const latest = aisMessageHistory[0]; + const latest = rigMessages[0]; if (!latest) { aisLatestSeenEl.textContent = "No traffic yet"; } else { @@ -272,7 +277,7 @@ function renderAisHistory() { return; } const fragment = document.createDocumentFragment(); - for (const message of aisMessageHistory) { + for (const message of aisRigMessages()) { fragment.appendChild(renderAisRow(message)); } aisMessagesEl.replaceChildren(fragment); @@ -304,10 +309,10 @@ function normalizeServerAisMessage(msg) { } function onServerAisBatch(messages) { if (!Array.isArray(messages) || messages.length === 0) return; - if (aisStatus) aisStatus.textContent = "Receiving"; const normalized = []; for (const msg of messages) { 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(); next._tsMs = tsMs; next._ts = new Date(tsMs).toLocaleTimeString([], { @@ -347,8 +352,9 @@ if (aisFilterInput) { }); } function onServerAis(msg) { - if (aisStatus) aisStatus.textContent = "Receiving"; - addAisMessage(normalizeServerAisMessage(msg)); + const message = normalizeServerAisMessage(msg); + if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving"; + addAisMessage(message); } updateAisSummary(); window.trxPluginRuntime.registerDecoder({ @@ -358,6 +364,10 @@ window.trxPluginRuntime.registerDecoder({ restore: onServerAisBatch, reset: resetAisHistoryView, prune: pruneAisHistoryView, + rerender: () => { + updateAisBar(); + renderAisHistory(); + }, // Oldest first, so vessel tracks are rebuilt in the order they happened. syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index a598cc0a..88b35e37 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -1065,6 +1065,9 @@ var runtime = { plugin.prune(); return true; }, + rerenderAll() { + for (const plugin of decoders.values()) plugin.rerender?.(); + }, syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); }, @@ -3577,6 +3580,7 @@ function resetDecoderStateOnRigSwitch() { if (el) el.textContent = "--"; }); refreshDecodeBars(); + window.trxPluginRuntime.rerenderAll(); } function resetWfmStereoIndicator() { if (!wfmStFlagEl) return; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js index 2eba9eba..a64edc72 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js @@ -6,8 +6,9 @@ import { renderAprsPacketRow } from "./chunk-OPEIVJGD.js"; import { + forActiveRig, isActiveRigDecode -} from "./chunk-BQQXSNLC.js"; +} from "./chunk-S57W63QN.js"; import { hostCore, hostState @@ -85,20 +86,25 @@ function aprsFilterMatch(pkt) { ].filter(Boolean).join(" ").toUpperCase(); return haystack.includes(aprsFilterText); } +function aprsRigPackets() { + return forActiveRig(aprsPacketHistory); +} function aprsVisiblePackets() { - const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory; + const rigPackets = aprsRigPackets(); + const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets; return packets.filter(aprsFilterMatch); } function updateAprsSummary() { + const rigPackets = aprsRigPackets(); const visible = aprsVisiblePackets(); if (aprsTotalCountEl) { - aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`; + aprsTotalCountEl.textContent = `${rigPackets.length} total`; } if (aprsVisibleCountEl) { aprsVisibleCountEl.textContent = `${visible.length} shown`; } if (aprsLatestSeenEl) { - const latest = aprsPacketHistory[0]; + const latest = rigPackets[0]; if (!latest) { aprsLatestSeenEl.textContent = "No packets yet"; } else { @@ -220,11 +226,11 @@ function normalizeServerAprsPacket(pkt) { } function onServerAprsBatch(packets) { if (!Array.isArray(packets) || packets.length === 0) return; - if (aprsStatus) aprsStatus.textContent = "Receiving"; const normalized = []; let hasCrcOk = false; for (const pkt of packets) { 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(); next._tsMs = tsMs; next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); @@ -282,8 +288,9 @@ if (aprsFilterInput) { }); } function onServerAprs(pkt) { - if (aprsStatus) aprsStatus.textContent = "Receiving"; - addAprsPacket(normalizeServerAprsPacket(pkt)); + const packet = normalizeServerAprsPacket(pkt); + if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving"; + addAprsPacket(packet); } renderAprsHistory(); window.trxPluginRuntime.registerDecoder({ @@ -293,6 +300,10 @@ window.trxPluginRuntime.registerDecoder({ restore: onServerAprsBatch, reset: resetAprsHistoryView, prune: pruneAprsHistoryView, + rerender: () => { + updateAprsBar(); + renderAprsHistory(); + }, // Oldest first, so station tracks are rebuilt in the order they happened. syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-K3D6FOP5.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PAKFJPA2.js similarity index 97% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-K3D6FOP5.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PAKFJPA2.js index 19c67d0f..bf8b4031 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-K3D6FOP5.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PAKFJPA2.js @@ -1,6 +1,7 @@ import { + forActiveRig, isActiveRigDecode -} from "./chunk-BQQXSNLC.js"; +} from "./chunk-S57W63QN.js"; import { hostCore } from "./chunk-KL66PICH.js"; @@ -156,9 +157,10 @@ function initializeFtxDecoder(config) { const render = () => { prune(); if (!messagesElement) return; + const rigMessages = forActiveRig(history); const fragment = document.createDocumentFragment(); let count = 0; - for (const message of history) { + for (const message of rigMessages) { if (count >= 200) break; if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue; fragment.appendChild(renderRow(message)); @@ -194,7 +196,9 @@ function initializeFtxDecoder(config) { }; const receiveBatch = (messages) => { 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); prune(); bridge.setFt8FamilyBarDecoder?.(id); @@ -232,7 +236,11 @@ function initializeFtxDecoder(config) { prune(); render(); }, - reset + reset, + rerender: () => { + bridge.updateFt8Bar?.(); + render(); + } }); bridge.registerFt8FamilyBarRenderer?.(id, barFrames); const updatePeriod = () => { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-BQQXSNLC.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-S57W63QN.js similarity index 65% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-BQQXSNLC.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-S57W63QN.js index 14d8f60f..8afb41a6 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-BQQXSNLC.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-S57W63QN.js @@ -8,7 +8,11 @@ function isActiveRigDecode(rigId) { if (!activeRigId || !rigId) return true; return rigId === activeRigId; } +function forActiveRig(items) { + return items.filter((item) => isActiveRigDecode(item.rig_id)); +} export { - isActiveRigDecode + isActiveRigDecode, + forActiveRig }; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js index 1564ab69..1f91cc1c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js @@ -1,6 +1,6 @@ import { isActiveRigDecode -} from "./chunk-BQQXSNLC.js"; +} from "./chunk-S57W63QN.js"; import { hostCore } from "./chunk-KL66PICH.js"; @@ -329,8 +329,9 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click", })(); }); function onServerCw(evt) { - if (cwStatusEl) cwStatusEl.textContent = "Receiving"; - if (evt.text && cwOutputEl) { + const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null); + if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving"; + if (evt.text && cwOutputEl && forSelectedRig) { const now = Date.now(); if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") { const line = document.createElement("div"); @@ -370,10 +371,10 @@ function onServerCw(evt) { } updateCwBar(); } - if (cwSignalIndicator) { + if (cwSignalIndicator && forSelectedRig) { 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))) { cwWpmInput.value = String(clampCwWpm(evt.wpm)); } @@ -398,7 +399,14 @@ cwWindow.trxPluginRuntime.registerDecoder({ id: "cw", onMessage: onServerCw, 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() { ensureCwToneCanvasResolution(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js index db3311bb..7862c521 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js @@ -1,7 +1,7 @@ import { initializeFtxDecoder -} from "./chunk-K3D6FOP5.js"; -import "./chunk-BQQXSNLC.js"; +} from "./chunk-PAKFJPA2.js"; +import "./chunk-S57W63QN.js"; import "./chunk-KL66PICH.js"; // src/plugins/ft2.ts diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js index d4fdf8a8..386a5c2c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js @@ -1,7 +1,7 @@ import { initializeFtxDecoder -} from "./chunk-K3D6FOP5.js"; -import "./chunk-BQQXSNLC.js"; +} from "./chunk-PAKFJPA2.js"; +import "./chunk-S57W63QN.js"; import "./chunk-KL66PICH.js"; // src/plugins/ft4.ts diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js index 85570b26..d0b23a76 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js @@ -2,8 +2,8 @@ import { initializeFt8FamilyBar, initializeFtxDecoder, installFtxCompatibilityHelpers -} from "./chunk-K3D6FOP5.js"; -import "./chunk-BQQXSNLC.js"; +} from "./chunk-PAKFJPA2.js"; +import "./chunk-S57W63QN.js"; import "./chunk-KL66PICH.js"; // src/plugins/ft8.ts diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js index d267fa66..aaff423e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js @@ -5,6 +5,10 @@ import { normalizeAprsPacket, renderAprsPacketRow } from "./chunk-OPEIVJGD.js"; +import { + forActiveRig, + isActiveRigDecode +} from "./chunk-S57W63QN.js"; import { hostCore, hostState @@ -67,21 +71,26 @@ function hfAprsFilterMatch(pkt) { ].filter(Boolean).join(" ").toUpperCase(); return haystack.includes(hfAprsFilterText); } +function hfAprsRigPackets() { + return forActiveRig(hfAprsPacketHistory); +} function hfAprsVisiblePackets() { - const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory; + const rigPackets = hfAprsRigPackets(); + const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets; return packets.filter(hfAprsFilterMatch); } var collapseHfAprsDuplicates = collapseAprsDuplicates; function updateHfAprsSummary() { + const rigPackets = hfAprsRigPackets(); const visible = hfAprsVisiblePackets(); if (hfAprsTotalCountEl) { - hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`; + hfAprsTotalCountEl.textContent = `${rigPackets.length} total`; } if (hfAprsVisibleCountEl) { hfAprsVisibleCountEl.textContent = `${visible.length} shown`; } if (hfAprsLatestSeenEl) { - const latest = hfAprsPacketHistory[0]; + const latest = rigPackets[0]; if (!latest) { hfAprsLatestSeenEl.textContent = "No packets yet"; } else { @@ -158,10 +167,10 @@ function normalizeServerHfAprsPacket(pkt) { } function onServerHfAprsBatch(packets) { if (!Array.isArray(packets) || packets.length === 0) return; - if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; const normalized = []; for (const pkt of packets) { 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(); next._tsMs = tsMs; next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); @@ -227,8 +236,9 @@ if (hfAprsFilterInput) { }); } function onServerHfAprs(pkt) { - if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; - addHfAprsPacket(normalizeServerHfAprsPacket(pkt)); + const packet = normalizeServerHfAprsPacket(pkt); + if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving"; + addHfAprsPacket(packet); } renderHfAprsHistory(); window.trxPluginRuntime.registerDecoder({ @@ -237,5 +247,6 @@ window.trxPluginRuntime.registerDecoder({ onBatch: onServerHfAprsBatch, restore: onServerHfAprsBatch, reset: resetHfAprsHistoryView, - prune: pruneHfAprsHistoryView + prune: pruneHfAprsHistoryView, + rerender: renderHfAprsHistory }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sstv.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sstv.js index e92e9e21..6d1161a8 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sstv.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sstv.js @@ -1,3 +1,7 @@ +import { + forActiveRig, + isActiveRigDecode +} from "./chunk-S57W63QN.js"; import { hostCore } from "./chunk-KL66PICH.js"; @@ -111,6 +115,7 @@ function paintRow(line, rgb) { liveRows = Math.max(liveRows, line + 1); } function onProgress(msg) { + if (!isActiveRigDecode(msg.rig_id)) return; if (msg.state) { if (sstvDom.status) sstvDom.status.textContent = msg.state; beginPicture(msg.mode || "", msg.width || 0, msg.height || 0); @@ -131,15 +136,20 @@ function onImage(msg) { sstvHistory.push(image); if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift(); pruneHistory(); + if (!isActiveRigDecode(image.rig_id)) return; if (sstvDom.status) { sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"} — ${image.lines ?? 0} lines`; } scheduleUi("sstv-latest", renderLatestCard); if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable); } +function sstvRigImages() { + return forActiveRig(sstvHistory); +} function renderLatestCard() { if (!sstvDom.liveLatest) return; - const latest = sstvHistory[sstvHistory.length - 1]; + const rigImages = sstvRigImages(); + const latest = rigImages[rigImages.length - 1]; if (!latest) { sstvDom.liveLatest.innerHTML = ""; return; @@ -160,8 +170,9 @@ function renderLatestCard() { `; } function filteredHistory() { + const rigImages = sstvRigImages(); 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"; matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0))); return matching; @@ -241,7 +252,11 @@ sstvWindow.trxPluginRuntime.registerDecoder({ onMessage: onImage, restore: restoreHistory, prune: renderHistoryTable, - reset: resetHistoryView + reset: resetHistoryView, + rerender: () => { + renderLatestCard(); + renderHistoryTable(); + } }); sstvWindow.trxPluginRuntime.registerDecoder({ id: "sstv_progress", diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js index 6bc119d2..65e8c373 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js @@ -1,6 +1,7 @@ import { + forActiveRig, isActiveRigDecode -} from "./chunk-BQQXSNLC.js"; +} from "./chunk-S57W63QN.js"; import { hostCore } from "./chunk-KL66PICH.js"; @@ -63,17 +64,21 @@ function vdesHexPreview(rawBytes) { if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--"; return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase(); } +function vdesRigMessages() { + return forActiveRig(vdesMessageHistory); +} function updateVdesSummary() { pruneVdesMessageHistory(); if (vdesChannelSummaryEl) { vdesChannelSummaryEl.textContent = currentVdesCenterText(); } + const rigMessages = vdesRigMessages(); if (vdesFrameCountEl) { - const count = vdesMessageHistory.length; + const count = rigMessages.length; vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`; } if (vdesLatestSeenEl) { - const latest = vdesMessageHistory[0]; + const latest = rigMessages[0]; vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet"; } } @@ -185,7 +190,7 @@ function renderVdesHistory() { return; } const fragment = document.createDocumentFragment(); - for (const message of vdesMessageHistory) { + for (const message of vdesRigMessages()) { fragment.appendChild(renderVdesRow(message)); } vdesMessagesEl.replaceChildren(fragment); @@ -212,10 +217,10 @@ function normalizeServerVdesMessage(msg) { } function onServerVdesBatch(messages) { if (!Array.isArray(messages) || messages.length === 0) return; - if (vdesStatus) vdesStatus.textContent = "Receiving"; const normalized = []; for (const msg of messages) { 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(); next._tsMs = tsMs; next._ts = new Date(tsMs).toLocaleTimeString([], { @@ -254,8 +259,8 @@ function plotVdesMessage(msg) { vdesWindow.vdesMapAddPoint(msg); } function onServerVdes(msg) { - if (vdesStatus) vdesStatus.textContent = "Receiving"; const next = normalizeServerVdesMessage(msg); + if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving"; addVdesMessage(next); plotVdesMessage(next); } @@ -272,6 +277,10 @@ window.trxPluginRuntime.registerDecoder({ restore: onServerVdesBatch, reset: resetVdesHistoryView, prune: pruneVdesHistoryView, + rerender: () => { + updateVdesBar(); + renderVdesHistory(); + }, // Oldest first, so tracks are rebuilt in the order they happened. syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js index 0b09f26b..e11af9ba 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js @@ -1,3 +1,7 @@ +import { + forActiveRig, + isActiveRigDecode +} from "./chunk-S57W63QN.js"; import { hostCore } from "./chunk-KL66PICH.js"; @@ -102,13 +106,17 @@ function paintLine(lineBytes) { wefaxLiveCtx.putImageData(imgData, 0, y); wefaxLiveLineCount++; } +function wefaxRigImages() { + return forActiveRig(wefaxImageHistory); +} function renderWefaxLatestCard() { if (!wefaxDom.liveLatest) return; - if (wefaxImageHistory.length === 0) { + const rigImages = wefaxRigImages(); + if (rigImages.length === 0) { wefaxDom.liveLatest.innerHTML = '
No images decoded yet. Enable the decoder and tune to a WEFAX station.
'; return; } - const img = wefaxImageHistory[0]; + const img = rigImages[0]; if (!img) return; const ts = img._ts || "--"; const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : ""; @@ -129,7 +137,7 @@ function renderWefaxLatestCard() { wefaxDom.liveLatest.innerHTML = html; } function getWefaxFilteredHistory() { - let items = wefaxImageHistory; + let items = wefaxRigImages(); if (wefaxFilterText) { items = items.filter(function(i) { const haystack = [ @@ -173,7 +181,7 @@ function renderWefaxHistoryTable() { } wefaxDom.historyList.replaceChildren(fragment); if (wefaxDom.historyCount) { - const total = wefaxImageHistory.length; + const total = wefaxRigImages().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`; } @@ -208,6 +216,7 @@ function addWefaxImage(msg) { } } function onServerWefaxProgress(msg) { + if (!isActiveRigDecode(msg.rig_id)) return; if (msg.state && !msg.line_data) { if (wefaxDom.status) { wefaxDom.status.textContent = msg.state; @@ -234,6 +243,7 @@ function onServerWefaxProgress(msg) { } function onServerWefax(msg) { addWefaxImage(msg); + if (!isActiveRigDecode(msg.rig_id)) return; if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none"; if (wefaxDom.status) { wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`; @@ -328,7 +338,11 @@ wefaxWindow.trxPluginRuntime.registerDecoder({ onMessage: onServerWefax, restore: restoreWefaxHistory, prune: pruneWefaxHistoryView, - reset: resetWefaxHistoryView + reset: resetWefaxHistoryView, + rerender: () => { + renderWefaxLatestCard(); + renderWefaxHistoryTable(); + } }); wefaxWindow.trxPluginRuntime.registerDecoder({ id: "wefax_progress", diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js index df393e5d..0d0a1f5b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js @@ -1,3 +1,7 @@ +import { + forActiveRig, + isActiveRigDecode +} from "./chunk-S57W63QN.js"; import { hostCore } from "./chunk-KL66PICH.js"; @@ -64,10 +68,10 @@ function renderWsprRow(msg) { function renderWsprHistory() { pruneWsprMessageHistory(); if (!wsprMessagesEl) return; + const rigMessages = forActiveRig(wsprMessageHistory); const fragment = document.createDocumentFragment(); - for (let i = 0; i < wsprMessageHistory.length; i += 1) { - const message = wsprMessageHistory[i]; - if (message) fragment.appendChild(renderWsprRow(message)); + for (const message of rigMessages) { + fragment.appendChild(renderWsprRow(message)); } wsprMessagesEl.replaceChildren(fragment); } @@ -90,6 +94,7 @@ function normalizeServerWsprMessage(msg) { station, rfHz, history: { + rig_id: msg.rig_id ?? null, receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null, ts_ms: msg.ts_ms, snr_db: msg.snr_db, @@ -101,10 +106,10 @@ function normalizeServerWsprMessage(msg) { } function onServerWsprBatch(messages) { if (!Array.isArray(messages) || messages.length === 0) return; - if (wsprStatus) wsprStatus.textContent = "Receiving"; const normalized = []; for (const msg of messages) { const next = normalizeServerWsprMessage(msg); + if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving"; if (next.grids.length > 0 && wsprWindow.mapAddLocator) { wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { ...msg, @@ -251,7 +256,7 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click" })(); }); function onServerWspr(msg) { - if (wsprStatus) wsprStatus.textContent = "Receiving"; + if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving"; const next = normalizeServerWsprMessage(msg); if (next.grids.length > 0 && wsprWindow.mapAddLocator) { wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { @@ -267,5 +272,6 @@ wsprWindow.trxPluginRuntime.registerDecoder({ onBatch: onServerWsprBatch, restore: onServerWsprBatch, prune: pruneWsprHistoryView, - reset: resetWsprHistoryView + reset: resetWsprHistoryView, + rerender: renderWsprHistory }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts index aaf7ce58..82dee01e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts @@ -2240,9 +2240,10 @@ function resetDecoderStateOnRigSwitch() { }); // 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 - // leaving them on screen until the next state update. + // and the decoder panels survive the switch: repaint both for the rig now + // selected instead of leaving the outgoing rig's traffic on screen. refreshDecodeBars(); + window.trxPluginRuntime.rerenderAll(); } function resetWfmStereoIndicator() { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-runtime.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-runtime.ts index 91a4804b..29045ae3 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-runtime.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-runtime.ts @@ -81,6 +81,7 @@ const runtime: TrxPluginRuntime = { plugin.prune(); return true; }, + rerenderAll() { for (const plugin of decoders.values()) plugin.rerender?.(); }, syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); }, clearQueued() { queued.clear(); }, hasDecoder: (id) => decoders.has(id), diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/active-rig.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/active-rig.ts index 499ae127..5fb35919 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/active-rig.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/active-rig.ts @@ -4,13 +4,14 @@ import { hostState } from "./host.js"; -// The decode panels are aggregate views on purpose: they list what every rig -// heard, and the history they restore from is not rig-scoped either. The mini -// views over the waterfall are not aggregates — they read as a caption for the -// spectrum underneath, so a frame a background rig copied on another band -// belongs to a different picture entirely. Each decode names the rig that -// heard it (the client stamps `rig_id` as the frame leaves the audio -// connection), which is all the overlays need to keep to the rig on screen. +// The decode stream is not rig-scoped: every rig a client is connected to sends +// its decodes to every browser, each frame naming the rig that heard it (the +// client stamps `rig_id` as it leaves the audio connection). The map wants all +// of them — it has its own filter, and plotting only one rig would empty it — +// but the radio page and the decoder panels describe one rig at a time: the one +// the operator selected, whose spectrum is on screen and whose audio is +// 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 { const activeRigId = hostState.lastActiveRigId; // 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; return rigId === activeRigId; } + +/** The decodes of `items` that the selected rig heard, in their original order. */ +export function forActiveRig(items: T[]): T[] { + return items.filter((item) => isActiveRigDecode(item.rig_id)); +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts index 182fbbe7..7edfc253 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts @@ -6,7 +6,7 @@ import { hostCore, hostState } from "./host.js"; export {}; -import { isActiveRigDecode } from "./active-rig.js"; +import { forActiveRig, isActiveRigDecode } from "./active-rig.js"; import type { PluginRuntimeWindow } from "./runtime-contract"; interface AisMessage { @@ -191,20 +191,27 @@ function aisLatestByVessel(messages: AisMessage[]): AisMessage[] { 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() { const plan = currentAisChannelPlan(); if (aisChannelSummaryEl) { aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`; } - const vessels = aisLatestByVessel(aisMessageHistory); + const rigMessages = aisRigMessages(); + const vessels = aisLatestByVessel(rigMessages); if (aisVesselCountEl) { const count = vessels.length; aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`; } if (aisLatestSeenEl) { - const latest = aisMessageHistory[0]; + const latest = rigMessages[0]; if (!latest) { aisLatestSeenEl.textContent = "No traffic yet"; } else { @@ -371,7 +378,7 @@ function renderAisHistory() { return; } const fragment = document.createDocumentFragment(); - for (const message of aisMessageHistory) { + for (const message of aisRigMessages()) { fragment.appendChild(renderAisRow(message)); } aisMessagesEl.replaceChildren(fragment); @@ -410,10 +417,11 @@ function normalizeServerAisMessage(msg: AisMessage): AisMessage { function onServerAisBatch(messages: AisMessage[]): void { if (!Array.isArray(messages) || messages.length === 0) return; - if (aisStatus) aisStatus.textContent = "Receiving"; const normalized: AisMessage[] = []; for (const msg of messages) { 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(); next._tsMs = tsMs; next._ts = new Date(tsMs).toLocaleTimeString([], { @@ -455,8 +463,9 @@ if (aisFilterInput) { } function onServerAis(msg: AisMessage): void { - if (aisStatus) aisStatus.textContent = "Receiving"; - addAisMessage(normalizeServerAisMessage(msg)); + const message = normalizeServerAisMessage(msg); + if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving"; + addAisMessage(message); } updateAisSummary(); @@ -467,6 +476,7 @@ updateAisSummary(); restore: onServerAisBatch, reset: resetAisHistoryView, prune: pruneAisHistoryView, + rerender: () => { updateAisBar(); renderAisHistory(); }, // Oldest first, so vessel tracks are rebuilt in the order they happened. syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); }, }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts index 5e14d0a6..c476983a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts @@ -4,7 +4,7 @@ import { hostCore, hostState } from "./host.js"; -import { isActiveRigDecode } from "./active-rig.js"; +import { forActiveRig, isActiveRigDecode } from "./active-rig.js"; import { aprsAgeText, aprsPacketCategory, @@ -107,21 +107,29 @@ function aprsFilterMatch(pkt: AprsPacket): boolean { 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[] { - const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory; + const rigPackets = aprsRigPackets(); + const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets; return packets.filter(aprsFilterMatch); } function updateAprsSummary() { + const rigPackets = aprsRigPackets(); const visible = aprsVisiblePackets(); if (aprsTotalCountEl) { - aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`; + aprsTotalCountEl.textContent = `${rigPackets.length} total`; } if (aprsVisibleCountEl) { aprsVisibleCountEl.textContent = `${visible.length} shown`; } if (aprsLatestSeenEl) { - const latest = aprsPacketHistory[0]; + const latest = rigPackets[0]; if (!latest) { aprsLatestSeenEl.textContent = "No packets yet"; } else { @@ -259,11 +267,12 @@ function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket { function onServerAprsBatch(packets: AprsPacket[]): void { if (!Array.isArray(packets) || packets.length === 0) return; - if (aprsStatus) aprsStatus.textContent = "Receiving"; const normalized: AprsPacket[] = []; let hasCrcOk = false; for (const pkt of packets) { 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(); next._tsMs = tsMs; 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 --- function onServerAprs(pkt: AprsPacket): void { - if (aprsStatus) aprsStatus.textContent = "Receiving"; - addAprsPacket(normalizeServerAprsPacket(pkt)); + const packet = normalizeServerAprsPacket(pkt); + if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving"; + addAprsPacket(packet); } renderAprsHistory(); @@ -339,6 +349,7 @@ renderAprsHistory(); restore: onServerAprsBatch, reset: resetAprsHistoryView, prune: pruneAprsHistoryView, + rerender: () => { updateAprsBar(); renderAprsHistory(); }, // Oldest first, so station tracks are rebuilt in the order they happened. syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); }, }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.ts index 3028dcc6..0a9c2442 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.ts @@ -432,8 +432,13 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click", // --- Server-side CW decode handler --- function onServerCw(evt: CwEvent): void { - if (cwStatusEl) cwStatusEl.textContent = "Receiving"; - if (evt.text && cwOutputEl) { + // The panel is a single stream of copied text: characters from a background + // 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 const now = Date.now(); if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") { @@ -475,10 +480,10 @@ function onServerCw(evt: CwEvent): void { } updateCwBar(); } - if (cwSignalIndicator) { + if (cwSignalIndicator && forSelectedRig) { 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))) { cwWpmInput.value = String(clampCwWpm(evt.wpm)); } @@ -506,6 +511,13 @@ cwWindow.trxPluginRuntime.registerDecoder({ onMessage: onServerCw, restore: restoreCwHistory, 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() { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts index 8f4a77fe..64b7d475 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts @@ -4,7 +4,7 @@ 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"; export type FtxDecoderId = "ft2" | "ft4" | "ft8"; @@ -229,9 +229,12 @@ export function initializeFtxDecoder(config: FtxConfig): void { const render = (): void => { prune(); 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(); let count = 0; - for (const message of history) { + for (const message of rigMessages) { if (count >= 200) break; if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue; fragment.appendChild(renderRow(message)); @@ -267,7 +270,10 @@ export function initializeFtxDecoder(config: FtxConfig): void { }; const receiveBatch = (messages: FtxMessage[]): void => { 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); prune(); bridge.setFt8FamilyBarDecoder?.(id); @@ -305,6 +311,7 @@ export function initializeFtxDecoder(config: FtxConfig): void { restore: receiveBatch, prune: () => { prune(); render(); }, reset, + rerender: () => { bridge.updateFt8Bar?.(); render(); }, }); bridge.registerFt8FamilyBarRenderer?.(id, barFrames); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts index 3192fe2e..a7ef6118 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts @@ -4,6 +4,7 @@ import { hostCore, hostState } from "./host.js"; +import { forActiveRig, isActiveRigDecode } from "./active-rig.js"; import { aprsAgeText, aprsPacketCategory, @@ -90,23 +91,31 @@ function hfAprsFilterMatch(pkt: AprsPacket): boolean { 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[] { - const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory; + const rigPackets = hfAprsRigPackets(); + const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets; return packets.filter(hfAprsFilterMatch); } const collapseHfAprsDuplicates = collapseAprsDuplicates; function updateHfAprsSummary() { + const rigPackets = hfAprsRigPackets(); const visible = hfAprsVisiblePackets(); if (hfAprsTotalCountEl) { - hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`; + hfAprsTotalCountEl.textContent = `${rigPackets.length} total`; } if (hfAprsVisibleCountEl) { hfAprsVisibleCountEl.textContent = `${visible.length} shown`; } if (hfAprsLatestSeenEl) { - const latest = hfAprsPacketHistory[0]; + const latest = rigPackets[0]; if (!latest) { hfAprsLatestSeenEl.textContent = "No packets yet"; } else { @@ -192,10 +201,11 @@ function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket { function onServerHfAprsBatch(packets: AprsPacket[]): void { if (!Array.isArray(packets) || packets.length === 0) return; - if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; const normalized: AprsPacket[] = []; for (const pkt of packets) { 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(); next._tsMs = tsMs; 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 --- function onServerHfAprs(pkt: AprsPacket): void { - if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; - addHfAprsPacket(normalizeServerHfAprsPacket(pkt)); + const packet = normalizeServerHfAprsPacket(pkt); + if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving"; + addHfAprsPacket(packet); } renderHfAprsHistory(); @@ -278,4 +289,5 @@ renderHfAprsHistory(); restore: onServerHfAprsBatch, reset: resetHfAprsHistoryView, prune: pruneHfAprsHistoryView, + rerender: renderHfAprsHistory, }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/runtime-contract.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/runtime-contract.ts index 077c0c7d..124681a0 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/runtime-contract.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/runtime-contract.ts @@ -9,6 +9,10 @@ export interface DecoderPlugin { restore?(messages: TMessage[]): void; reset?(): 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 * module attaches, which can happen long after the decodes arrived. */ syncMap?(): void; @@ -22,6 +26,7 @@ export interface TrxPluginRuntime { reset(id: string): boolean; resetAll(): void; prune(id: string): boolean; + rerenderAll(): void; syncMapAll(): void; clearQueued(): void; hasDecoder(id: string): boolean; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sstv.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sstv.ts index c7f1bd60..d2e62c28 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sstv.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sstv.ts @@ -14,11 +14,13 @@ import { hostCore } from "./host.js"; +import { forActiveRig, isActiveRigDecode } from "./active-rig.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js"; export {}; interface SstvImage { + rig_id?: string | null; ts_ms?: number; vis?: number; mode?: string; @@ -32,6 +34,7 @@ interface SstvImage { } interface SstvProgress { + rig_id?: string | null; state?: string; mode?: string; width?: number; @@ -185,6 +188,9 @@ function paintRow(line: number, rgb: Uint8Array): void { // ── Server messages ───────────────────────────────────────────────── 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 (sstvDom.status) sstvDom.status.textContent = msg.state; // 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(); pruneHistory(); + // Kept in the history either way, so switching to that rig finds it there. + if (!isActiveRigDecode(image.rig_id)) return; + if (sstvDom.status) { sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` @@ -219,9 +228,15 @@ function onImage(msg: SstvImage): void { // ── Rendering ─────────────────────────────────────────────────────── +/** The pictures the selected rig decoded, oldest first, as the history is kept. */ +function sstvRigImages(): SstvImage[] { + return forActiveRig(sstvHistory); +} + function renderLatestCard(): void { if (!sstvDom.liveLatest) return; - const latest = sstvHistory[sstvHistory.length - 1]; + const rigImages = sstvRigImages(); + const latest = rigImages[rigImages.length - 1]; if (!latest) { sstvDom.liveLatest.innerHTML = ""; return; @@ -245,10 +260,12 @@ function renderLatestCard(): void { } 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 matching = text - ? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) - : sstvHistory.slice(); + ? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) + : rigImages; const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest"; matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0))); return matching; @@ -348,6 +365,7 @@ sstvWindow.trxPluginRuntime.registerDecoder({ restore: restoreHistory, prune: renderHistoryTable, reset: resetHistoryView, + rerender: () => { renderLatestCard(); renderHistoryTable(); }, }); sstvWindow.trxPluginRuntime.registerDecoder({ id: "sstv_progress", diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts index fd083801..5b458526 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts @@ -6,7 +6,7 @@ import { hostCore } from "./host.js"; export {}; -import { isActiveRigDecode } from "./active-rig.js"; +import { forActiveRig, isActiveRigDecode } from "./active-rig.js"; import type { PluginRuntimeWindow } from "./runtime-contract"; interface VdesMessage { @@ -117,17 +117,24 @@ function vdesHexPreview(rawBytes: number[] | undefined): string { .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() { pruneVdesMessageHistory(); if (vdesChannelSummaryEl) { vdesChannelSummaryEl.textContent = currentVdesCenterText(); } + const rigMessages = vdesRigMessages(); if (vdesFrameCountEl) { - const count = vdesMessageHistory.length; + const count = rigMessages.length; vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`; } if (vdesLatestSeenEl) { - const latest = vdesMessageHistory[0]; + const latest = rigMessages[0]; vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet"; } } @@ -281,7 +288,7 @@ function renderVdesHistory() { return; } const fragment = document.createDocumentFragment(); - for (const message of vdesMessageHistory) { + for (const message of vdesRigMessages()) { fragment.appendChild(renderVdesRow(message)); } vdesMessagesEl.replaceChildren(fragment); @@ -312,10 +319,11 @@ function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage { function onServerVdesBatch(messages: VdesMessage[]): void { if (!Array.isArray(messages) || messages.length === 0) return; - if (vdesStatus) vdesStatus.textContent = "Receiving"; const normalized: VdesMessage[] = []; for (const msg of messages) { 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(); next._tsMs = tsMs; next._ts = new Date(tsMs).toLocaleTimeString([], { @@ -357,8 +365,8 @@ function plotVdesMessage(msg: VdesMessage): void { } function onServerVdes(msg: VdesMessage): void { - if (vdesStatus) vdesStatus.textContent = "Receiving"; const next = normalizeServerVdesMessage(msg); + if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving"; addVdesMessage(next); plotVdesMessage(next); } @@ -377,6 +385,7 @@ updateVdesSummary(); restore: onServerVdesBatch, reset: resetVdesHistoryView, prune: pruneVdesHistoryView, + rerender: () => { updateVdesBar(); renderVdesHistory(); }, // Oldest first, so tracks are rebuilt in the order they happened. syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); }, }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wefax.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wefax.ts index bc0e4e45..687212f0 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wefax.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wefax.ts @@ -4,11 +4,13 @@ import { hostCore } from "./host.js"; +import { forActiveRig, isActiveRigDecode } from "./active-rig.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js"; export {}; interface WefaxImage { + rig_id?: string | null; ts_ms?: number; ioc?: number; lpm?: number; @@ -20,6 +22,7 @@ interface WefaxImage { } interface WefaxProgress { + rig_id?: string | null; state?: string; line_data?: string; line_count?: number; @@ -156,15 +159,21 @@ function paintLine(lineBytes: Uint8Array): void { } // ── Live view: latest image card ──────────────────────────────────── +/** The pictures the selected rig decoded, newest first. */ +function wefaxRigImages(): WefaxImage[] { + return forActiveRig(wefaxImageHistory); +} + function renderWefaxLatestCard() { if (!wefaxDom.liveLatest) return; - if (wefaxImageHistory.length === 0) { + const rigImages = wefaxRigImages(); + if (rigImages.length === 0) { wefaxDom.liveLatest.innerHTML = '
No images decoded yet. Enable the decoder and tune to a WEFAX station.
'; return; } - const img = wefaxImageHistory[0]; + const img = rigImages[0]; if (!img) return; const ts = img._ts || '--'; const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : ''; @@ -193,7 +202,8 @@ function renderWefaxLatestCard() { // ── History view: table ───────────────────────────────────────────── function getWefaxFilteredHistory() { - let items = wefaxImageHistory; + // Every rig's pictures are kept; the panel shows the selected rig's. + let items = wefaxRigImages(); if (wefaxFilterText) { items = items.filter(function (i) { @@ -253,7 +263,7 @@ function renderWefaxHistoryTable() { wefaxDom.historyList.replaceChildren(fragment); if (wefaxDom.historyCount) { - const total = wefaxImageHistory.length; + const total = wefaxRigImages().length; const shown = items.length; wefaxDom.historyCount.textContent = total === 0 @@ -298,6 +308,9 @@ function addWefaxImage(msg: WefaxImage): void { // ── SSE event handlers (public API) ───────────────────────────────── 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. if (msg.state && !msg.line_data) { if (wefaxDom.status) { @@ -331,6 +344,8 @@ function onServerWefaxProgress(msg: WefaxProgress): void { function onServerWefax(msg: WefaxImage): void { 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.status) { @@ -438,6 +453,7 @@ wefaxWindow.trxPluginRuntime.registerDecoder({ restore: restoreWefaxHistory, prune: pruneWefaxHistoryView, reset: resetWefaxHistoryView, + rerender: () => { renderWefaxLatestCard(); renderWefaxHistoryTable(); }, }); wefaxWindow.trxPluginRuntime.registerDecoder({ id: "wefax_progress", diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.ts index 9ba9654d..192eef52 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.ts @@ -4,6 +4,7 @@ import { hostCore } from "./host.js"; +import { forActiveRig, isActiveRigDecode } from "./active-rig.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js"; export {}; @@ -15,6 +16,7 @@ interface WsprMessage { snr_db?: number | undefined; dt_s?: number | undefined; freq_hz?: number | undefined; + rig_id?: string | null | undefined; receiver?: unknown; [key: string]: unknown; } @@ -102,10 +104,12 @@ function renderWsprRow(msg: WsprMessage): HTMLDivElement { function renderWsprHistory(): void { pruneWsprMessageHistory(); 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(); - for (let i = 0; i < wsprMessageHistory.length; i += 1) { - const message = wsprMessageHistory[i]; - if (message) fragment.appendChild(renderWsprRow(message)); + for (const message of rigMessages) { + fragment.appendChild(renderWsprRow(message)); } wsprMessagesEl.replaceChildren(fragment); } @@ -130,6 +134,7 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str station, rfHz, history: { + rig_id: msg.rig_id ?? null, receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null, ts_ms: msg.ts_ms, snr_db: msg.snr_db, @@ -142,10 +147,11 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str function onServerWsprBatch(messages: WsprMessage[]): void { if (!Array.isArray(messages) || messages.length === 0) return; - if (wsprStatus) wsprStatus.textContent = "Receiving"; const normalized: WsprMessage[] = []; for (const msg of messages) { 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) { wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { ...msg, @@ -312,7 +318,7 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click" }); function onServerWspr(msg: WsprMessage): void { - if (wsprStatus) wsprStatus.textContent = "Receiving"; + if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving"; const next = normalizeServerWsprMessage(msg); if (next.grids.length > 0 && wsprWindow.mapAddLocator) { wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { @@ -330,4 +336,5 @@ wsprWindow.trxPluginRuntime.registerDecoder({ restore: onServerWsprBatch, prune: pruneWsprHistoryView, reset: resetWsprHistoryView, + rerender: renderWsprHistory, }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/mini-view-rig.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/active-rig.test.mjs similarity index 51% rename from src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/mini-view-rig.test.mjs rename to src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/active-rig.test.mjs index 6bdf0e13..71308b72 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/mini-view-rig.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/active-rig.test.mjs @@ -2,11 +2,12 @@ // // SPDX-License-Identifier: GPL-2.0-or-later -// The mini views over the waterfall caption the spectrum below them, so they -// show only what the rig on screen heard. The panels on the decoder tabs stay -// aggregate — every rig's traffic lands there — which is the distinction these -// tests pin down: a decode from a background rig belongs in the list and not in -// the overlay. +// The radio page and the digital modes page both describe one rig: the one the +// operator selected. Every rig a client is connected to feeds the same decode +// stream, so both the mini views over the waterfall and the panels behind them +// filter on the rig that heard each decode. The map is the exception and keeps +// 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 test from "node:test"; @@ -65,12 +66,14 @@ async function runPlugin(entry, { window, document: doc, extras = {} }) { 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 packets = new ElementFixture(); + const totals = new ElementFixture(); const elements = new Map([ ["aprs-bar-overlay", overlay], ["aprs-packets", packets], + ["aprs-total-count", totals], ["mode", new ElementFixture("PKT")], ]); 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.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 () => { @@ -196,3 +200,149 @@ test("the FT8 mini view keeps to the active rig", async () => { assert.match(overlay.innerHTML, /SP1AAA/); 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/); +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs index 6159bc13..7689c121 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs @@ -22,9 +22,9 @@ const BEACON = { packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65, symbol_table: "/", symbol_code: ">", rig_id: "rig-a", }; -// A second rig listening in the background, on its own band. Its traffic -// belongs in the panels, which aggregate every rig, but not in the mini view, -// which captions the spectrum of the rig on screen. +// A second rig listening in the background, on its own band. Both pages +// describe the rig on screen, so its traffic belongs in neither the panel nor +// the mini view — only on the map, which shows the whole station. const OTHER_RIG_VESSEL = { ...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY", lat: 51.92, lon: 4.48, rig_id: "rig-b", @@ -51,12 +51,13 @@ try { aisStatus: document.getElementById("ais-status")?.textContent ?? "", aprsStatus: document.getElementById("aprs-status")?.textContent ?? "", otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false, + aisRows: document.getElementById("ais-messages")?.children.length ?? 0, mapLoaded: !!window.trx.modules.map, })); 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.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. await page.locator('.tab[data-tab="main"]').click(); @@ -102,8 +103,11 @@ try { : Object.keys(collection ?? {}).length); 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"); + // 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, []); } finally {