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 {