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 9fda5775..093be46c 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,3 +1,6 @@ +import { + isActiveRigDecode +} from "./chunk-BQQXSNLC.js"; import { hostCore, hostState @@ -222,7 +225,9 @@ function updateAisBar() { updateAisSummary(); const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS"; const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS; - const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs); + const recent = aisMessageHistory.filter( + (msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id) + ); const messages = aisLatestByVessel(recent).slice(0, 8); if (!isAis || messages.length === 0) { aisBarOverlay.style.display = "none"; 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 6f891962..a598cc0a 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 @@ -3555,6 +3555,13 @@ function positionRdsPsOverlay() { function resetRdsDisplay() { updateRdsPsOverlay(primaryRds); } +function refreshDecodeBars() { + window.updateAisBar?.(); + window.updateVdesBar?.(); + window.updateAprsBar?.(); + window.updateFt8Bar?.(); + window.updateCwBar?.(); +} function resetDecoderStateOnRigSwitch() { primaryRds = null; vchanRdsById = /* @__PURE__ */ new Map(); @@ -3569,6 +3576,7 @@ function resetDecoderStateOnRigSwitch() { const el = document.getElementById(id); if (el) el.textContent = "--"; }); + refreshDecodeBars(); } function resetWfmStereoIndicator() { if (!wfmStFlagEl) return; @@ -4788,10 +4796,7 @@ function render(update) { const connText = _decodeConnectedText[d.id] || "Connected, listening for packets"; setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText); } - if (window.updateAisBar) window.updateAisBar(); - if (window.updateVdesBar) window.updateVdesBar(); - if (window.updateAprsBar) window.updateAprsBar(); - if (window.updateFt8Bar) window.updateFt8Bar(); + refreshDecodeBars(); for (const d of decoderRegistry) { if (d.activation !== "toggle") continue; const key = d.id.replace(/-/g, "_") + "_decode_enabled"; 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 0436b5df..2eba9eba 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 @@ -5,6 +5,9 @@ import { normalizeAprsPacket, renderAprsPacketRow } from "./chunk-OPEIVJGD.js"; +import { + isActiveRigDecode +} from "./chunk-BQQXSNLC.js"; import { hostCore, hostState @@ -153,7 +156,9 @@ function updateAprsBar() { if (!aprsBarOverlay) return; const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT"; const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS; - const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs); + const okFrames = aprsPacketHistory.filter( + (p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id) + ); const frames = collapseAprsDuplicates(okFrames).slice(0, 8); const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0); if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) { 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-BQQXSNLC.js new file mode 100644 index 00000000..14d8f60f --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-BQQXSNLC.js @@ -0,0 +1,14 @@ +import { + hostState +} from "./chunk-KL66PICH.js"; + +// src/plugins/active-rig.ts +function isActiveRigDecode(rigId) { + const activeRigId = hostState.lastActiveRigId; + if (!activeRigId || !rigId) return true; + return rigId === activeRigId; +} + +export { + isActiveRigDecode +}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-O2Y7YEVQ.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-K3D6FOP5.js similarity index 97% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-O2Y7YEVQ.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-K3D6FOP5.js index bbb4b0db..19c67d0f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-O2Y7YEVQ.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-K3D6FOP5.js @@ -1,3 +1,6 @@ +import { + isActiveRigDecode +} from "./chunk-BQQXSNLC.js"; import { hostCore } from "./chunk-KL66PICH.js"; @@ -177,6 +180,9 @@ function initializeFtxDecoder(config) { }); } return { + // The rig that heard it, kept so the mini view can tell a decode of the + // rig on screen from one a background rig made on another band. + rig_id: message.rig_id ?? null, receiver: bridge.getDecodeRigMeta?.() ?? null, ts_ms: message.ts_ms, snr_db: message.snr_db, @@ -202,7 +208,7 @@ function initializeFtxDecoder(config) { bridge.clearMapMarkersByType?.(id); }; const barFrames = () => { - const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8); + const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5 && isActiveRigDecode(message.rig_id)).slice(0, 8); let html = ""; for (const message of recent) { const timestamp = finiteNumber(message._tsMs ?? message.ts_ms); 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 0a7e7a3e..1564ab69 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,3 +1,6 @@ +import { + isActiveRigDecode +} from "./chunk-BQQXSNLC.js"; import { hostCore } from "./chunk-KL66PICH.js"; @@ -25,7 +28,7 @@ var CW_BAR_LINE_GAP_MS = 5e3; var cwLastAppendTime = 0; var cwTonePickerRaf = null; var cwBarHistory = []; -var cwBarCurrentLine = null; +var cwBarCurrentLines = /* @__PURE__ */ new Map(); var cwBarDismissedAtMs = 0; var cwAutoLocalOverride = null; function escapeCwHtml(input) { @@ -50,20 +53,22 @@ cwWindow.applyCwAutoUiFromServer = function(enabled) { if (cwAutoLocalOverride !== null) return; applyCwAutoUi(enabled); }; -function cwBarFlushCurrentLine() { - if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) { - cwBarHistory.unshift(cwBarCurrentLine); +function cwBarFlushCurrentLine(key) { + const line = cwBarCurrentLines.get(key); + cwBarCurrentLines.delete(key); + if (line?.text.trim()) { + cwBarHistory.unshift(line); if (cwBarHistory.length > 50) cwBarHistory.length = 50; } - cwBarCurrentLine = null; } function updateCwBar() { if (!cwBarOverlay) return; const mode = (document.getElementById("mode")?.value || "").toUpperCase(); const isCw = mode === "CW" || mode === "CWR"; const cutoffMs = Date.now() - CW_BAR_WINDOW_MS; - const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs); - const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent; + const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId)); + const inProgress = [...cwBarCurrentLines.values()].filter((l) => l.text && isActiveRigDecode(l.rigId)).sort((a, b) => b.tsMs - a.tsMs); + const liveLines = [...inProgress, ...recent]; const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0); if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) { cwBarOverlay.style.display = "none"; @@ -308,7 +313,7 @@ function resetCwHistoryView() { if (cwOutputEl) cwOutputEl.innerHTML = ""; cwLastAppendTime = 0; cwBarHistory = []; - cwBarCurrentLine = null; + cwBarCurrentLines.clear(); updateCwBar(); drawCwTonePicker(); } @@ -346,18 +351,22 @@ function onServerCw(evt) { } if (evt.text) { const now = Date.now(); + const rigId = evt.rig_id ?? null; + const key = rigId ?? ""; if (evt.text === "\n") { - cwBarFlushCurrentLine(); + cwBarFlushCurrentLine(key); } else { - if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) { - cwBarFlushCurrentLine(); + let line = cwBarCurrentLines.get(key); + if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) { + cwBarFlushCurrentLine(key); const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); - cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now }; + line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now }; + cwBarCurrentLines.set(key, line); } - cwBarCurrentLine.text += evt.text; - cwBarCurrentLine.lastMs = now; - if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm); - if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz)); + line.text += evt.text; + line.lastMs = now; + if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm); + if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz)); } updateCwBar(); } 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 9b1997d8..db3311bb 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,6 +1,7 @@ import { initializeFtxDecoder -} from "./chunk-O2Y7YEVQ.js"; +} from "./chunk-K3D6FOP5.js"; +import "./chunk-BQQXSNLC.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 9c7839bf..d4fdf8a8 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,6 +1,7 @@ import { initializeFtxDecoder -} from "./chunk-O2Y7YEVQ.js"; +} from "./chunk-K3D6FOP5.js"; +import "./chunk-BQQXSNLC.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 d18bcde6..85570b26 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,7 +2,8 @@ import { initializeFt8FamilyBar, initializeFtxDecoder, installFtxCompatibilityHelpers -} from "./chunk-O2Y7YEVQ.js"; +} from "./chunk-K3D6FOP5.js"; +import "./chunk-BQQXSNLC.js"; import "./chunk-KL66PICH.js"; // src/plugins/ft8.ts 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 ff9cf002..6bc119d2 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,3 +1,6 @@ +import { + isActiveRigDecode +} from "./chunk-BQQXSNLC.js"; import { hostCore } from "./chunk-KL66PICH.js"; @@ -137,7 +140,7 @@ function updateVdesBar() { updateVdesSummary(); const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES"; const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS; - const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6); + const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)).slice(0, 6); if (!isVdes || messages.length === 0) { vdesBarOverlay.style.display = "none"; vdesBarOverlay.innerHTML = ""; 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 4a0c707e..aaf7ce58 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 @@ -392,6 +392,7 @@ declare global { updateVdesBar?(value?: number): void; updateAprsBar?(value?: number): void; updateFt8Bar?(value?: number): void; + updateCwBar?(value?: number): void; updateSatLiveState?(value: unknown): void; applyCwAutoUi?(enabled: boolean): void; applyCwAutoUiFromServer?(enabled: boolean): void; @@ -2207,6 +2208,17 @@ function resetRdsDisplay() { updateRdsPsOverlay(primaryRds); } +// The mini views over the waterfall show only what the rig on screen heard, so +// they have to be repainted whenever that rig — or its mode — changes, not just +// when the next decode happens to arrive. +function refreshDecodeBars() { + window.updateAisBar?.(); + window.updateVdesBar?.(); + window.updateAprsBar?.(); + window.updateFt8Bar?.(); + window.updateCwBar?.(); +} + function resetDecoderStateOnRigSwitch() { // RDS primaryRds = null; @@ -2226,6 +2238,11 @@ function resetDecoderStateOnRigSwitch() { const el = document.getElementById(id); if (el) el.textContent = "--"; }); + + // 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. + refreshDecodeBars(); } function resetWfmStereoIndicator() { @@ -3676,10 +3693,7 @@ function render(update: AppUpdate) { const connText = _decodeConnectedText[d.id] || "Connected, listening for packets"; setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText); } - if (window.updateAisBar) window.updateAisBar(); - if (window.updateVdesBar) window.updateVdesBar(); - if (window.updateAprsBar) window.updateAprsBar(); - if (window.updateFt8Bar) window.updateFt8Bar(); + refreshDecodeBars(); // Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong. for (const d of decoderRegistry) { if (d.activation !== "toggle") continue; 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 new file mode 100644 index 00000000..499ae127 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/active-rig.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +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. +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 + // without a rig of its own, there is nothing to disagree with. + if (!activeRigId || !rigId) return true; + return rigId === activeRigId; +} 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 b08c0939..182fbbe7 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,6 +6,7 @@ import { hostCore, hostState } from "./host.js"; export {}; +import { isActiveRigDecode } from "./active-rig.js"; import type { PluginRuntimeWindow } from "./runtime-contract"; interface AisMessage { @@ -314,7 +315,9 @@ function updateAisBar() { const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS"; const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS; - const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs); + const recent = aisMessageHistory.filter( + (msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id), + ); const messages = aisLatestByVessel(recent).slice(0, 8); if (!isAis || messages.length === 0) { aisBarOverlay.style.display = "none"; 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 307e394e..5e14d0a6 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,6 +4,7 @@ import { hostCore, hostState } from "./host.js"; +import { isActiveRigDecode } from "./active-rig.js"; import { aprsAgeText, aprsPacketCategory, @@ -179,7 +180,9 @@ function updateAprsBar() { if (!aprsBarOverlay) return; const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT"; const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS; - const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs); + const okFrames = aprsPacketHistory.filter( + (p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id), + ); const frames = collapseAprsDuplicates(okFrames).slice(0, 8); const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0); if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) { 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 8ca86646..3028dcc6 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 @@ -4,6 +4,7 @@ import { hostCore } from "./host.js"; +import { isActiveRigDecode } from "./active-rig.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js"; export {}; @@ -20,8 +21,11 @@ interface CwRenderer { drawPoints(points: number[], size: number, color: Rgba): void; } interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number } -interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean } -interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number } +interface CwEvent { rig_id?: string | null; text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean } +interface CwLine { + rigId: string | null; tsMs: number; ts: string; text: string; + wpm: number | null; tone_hz: number | null; lastMs: number; +} interface CwToneRange { tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number; toneSpanHz: number; lowerSideband: boolean; mode: string; @@ -66,7 +70,10 @@ const CW_BAR_LINE_GAP_MS = 5000; let cwLastAppendTime = 0; let cwTonePickerRaf: number | null = null; let cwBarHistory: CwLine[] = []; -let cwBarCurrentLine: CwLine | null = null; +// One line in progress per rig, keyed by rig id ("" for a decode that names no +// rig). Two rigs copying at the same time each fill their own line instead of +// braiding their characters into one. +const cwBarCurrentLines = new Map(); let cwBarDismissedAtMs = 0; // Tracks a user-initiated auto toggle that is in-flight (POST not yet // acknowledged). While set, server-state updates must not override the @@ -101,12 +108,13 @@ cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) { applyCwAutoUi(enabled); }; -function cwBarFlushCurrentLine(): void { - if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) { - cwBarHistory.unshift(cwBarCurrentLine); +function cwBarFlushCurrentLine(key: string): void { + const line = cwBarCurrentLines.get(key); + cwBarCurrentLines.delete(key); + if (line?.text.trim()) { + cwBarHistory.unshift(line); if (cwBarHistory.length > 50) cwBarHistory.length = 50; } - cwBarCurrentLine = null; } function updateCwBar(): void { @@ -114,9 +122,12 @@ function updateCwBar(): void { const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase(); const isCw = mode === "CW" || mode === "CWR"; const cutoffMs = Date.now() - CW_BAR_WINDOW_MS; - const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs); - // Prepend the in-progress line so characters appear immediately - const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent; + const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId)); + // Prepend the in-progress lines so characters appear immediately + const inProgress = [...cwBarCurrentLines.values()] + .filter((l) => l.text && isActiveRigDecode(l.rigId)) + .sort((a, b) => b.tsMs - a.tsMs); + const liveLines = [...inProgress, ...recent]; const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0); if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) { cwBarOverlay.style.display = "none"; @@ -402,7 +413,7 @@ function resetCwHistoryView(): void { if (cwOutputEl) cwOutputEl.innerHTML = ""; cwLastAppendTime = 0; cwBarHistory = []; - cwBarCurrentLine = null; + cwBarCurrentLines.clear(); updateCwBar(); drawCwTonePicker(); } @@ -445,18 +456,22 @@ function onServerCw(evt: CwEvent): void { // Bar history accumulation (regardless of pause state) if (evt.text) { const now = Date.now(); + const rigId = evt.rig_id ?? null; + const key = rigId ?? ""; if (evt.text === "\n") { - cwBarFlushCurrentLine(); + cwBarFlushCurrentLine(key); } else { - if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) { - cwBarFlushCurrentLine(); + let line = cwBarCurrentLines.get(key); + if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) { + cwBarFlushCurrentLine(key); const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); - cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now }; + line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now }; + cwBarCurrentLines.set(key, line); } - cwBarCurrentLine.text += evt.text; - cwBarCurrentLine.lastMs = now; - if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm); - if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz)); + line.text += evt.text; + line.lastMs = now; + if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm); + if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz)); } updateCwBar(); } 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 5a533914..8f4a77fe 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,6 +4,7 @@ import { hostCore } from "./host.js"; +import { isActiveRigDecode } from "./active-rig.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js"; export type FtxDecoderId = "ft2" | "ft4" | "ft8"; @@ -15,6 +16,7 @@ export interface FtxMessage { snr_db?: number | undefined; dt_s?: number | undefined; freq_hz?: number | undefined; + rig_id?: string | null | undefined; receiver?: unknown; [key: string]: unknown; } @@ -251,6 +253,9 @@ export function initializeFtxDecoder(config: FtxConfig): void { }); } return { + // The rig that heard it, kept so the mini view can tell a decode of the + // rig on screen from one a background rig made on another band. + rig_id: message.rig_id ?? null, receiver: bridge.getDecodeRigMeta?.() ?? null, ts_ms: message.ts_ms, snr_db: message.snr_db, @@ -277,7 +282,8 @@ export function initializeFtxDecoder(config: FtxConfig): void { }; const barFrames = (): BarFrames => { const recent = history - .filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000) + .filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000 + && isActiveRigDecode(message.rig_id)) .slice(0, 8); let html = ""; for (const message of recent) { 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 0e6b46ce..fd083801 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,6 +6,7 @@ import { hostCore } from "./host.js"; export {}; +import { isActiveRigDecode } from "./active-rig.js"; import type { PluginRuntimeWindow } from "./runtime-contract"; interface VdesMessage { @@ -228,7 +229,9 @@ function updateVdesBar() { updateVdesSummary(); const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES"; const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS; - const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6); + const messages = vdesMessageHistory + .filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)) + .slice(0, 6); if (!isVdes || messages.length === 0) { vdesBarOverlay.style.display = "none"; vdesBarOverlay.innerHTML = ""; 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 e0d0cf00..6159bc13 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,18 @@ 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. +const OTHER_RIG_VESSEL = { + ...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY", + lat: 51.92, lon: 4.48, rig_id: "rig-b", +}; // AIS is what the mini view for vessels is gated on; the rig has to be on it. -const fixture = await startWebFixture({ spectrum: true, decodes: [VESSEL, BEACON], mode: "AIS" }); +const fixture = await startWebFixture({ + spectrum: true, decodes: [VESSEL, BEACON, OTHER_RIG_VESSEL], mode: "AIS", +}); const { browser, page, runtimeErrors } = await startBrowser(chromium); try { @@ -41,11 +50,13 @@ try { aprs: document.getElementById("aprs-packets")?.children.length ?? 0, aisStatus: document.getElementById("ais-status")?.textContent ?? "", aprsStatus: document.getElementById("aprs-status")?.textContent ?? "", + otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false, 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"); // The mini view rides over the waterfall on the radio page. await page.locator('.tab[data-tab="main"]').click(); @@ -56,11 +67,13 @@ try { shown: getComputedStyle(bar).display !== "none", pins: bar.querySelectorAll(".aprs-bar-pin").length, names: bar.textContent.includes("NEDERLAND"), + otherRig: bar.textContent.includes("ELDERBERRY"), }; }); assert.equal(miniView.shown, true, "the AIS mini view did not appear"); assert.ok(miniView.pins > 0, "the mini view has no pin to follow"); assert.equal(miniView.names, true, "the mini view does not name the vessel"); + assert.equal(miniView.otherRig, false, "the mini view shows a background rig's vessel"); // Following the pin: the map opens, on the vessel. This is the path that was // broken for every decoder — the module that owned the navigation had not 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/mini-view-rig.test.mjs new file mode 100644 index 00000000..6bdf0e13 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/mini-view-rig.test.mjs @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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. + +import assert from "node:assert/strict"; +import test from "node:test"; +import vm from "node:vm"; +import { bundleEntry } from "./bundle-entry.mjs"; +import { createHost } from "./host-fixture.mjs"; + +class ElementFixture { + constructor(value = "") { + this.children = []; + this.innerHTML = ""; + this.textContent = ""; + this.value = value; + this.style = {}; + this.dataset = {}; + this.classList = { add() {}, remove() {}, toggle() {} }; + } + appendChild(child) { this.children.push(child); return child; } + removeChild(child) { this.children.splice(this.children.indexOf(child), 1); } + replaceChildren(...nodes) { this.children = nodes.flatMap((node) => node.children ?? [node]); } + addEventListener() {} + setAttribute() {} + querySelector() { return null; } + querySelectorAll() { return []; } + get firstChild() { return this.children[0] ?? null; } + get lastElementChild() { return this.children.at(-1) ?? null; } + get scrollHeight() { return this.children.length; } +} + +/** A document whose named elements exist and whose unknown ones do not. */ +function createDocument(elements) { + return { + documentElement: {}, + getElementById: (id) => elements.get(id) ?? null, + querySelector: () => null, + querySelectorAll: () => [], + createElement: () => new ElementFixture(), + createDocumentFragment: () => new ElementFixture(), + }; +} + +async function runPlugin(entry, { window, document: doc, extras = {} }) { + const context = vm.createContext({ + window, + document: doc, + navigator: {}, + requestAnimationFrame(callback) { callback(); return 1; }, + getComputedStyle: () => ({ getPropertyValue: () => "" }), + Date, Number, String, Math, Array, Map, Set, Reflect, console, + ...extras, + }); + const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); + const source = await bundleEntry(new URL(`../src/plugins/${entry}.ts`, import.meta.url)); + new vm.Script(runtime).runInContext(context); + new vm.Script(source).runInContext(context); + return context; +} + +test("the APRS mini view keeps to the active rig while the panel lists both", async () => { + const overlay = new ElementFixture(); + const packets = new ElementFixture(); + const elements = new Map([ + ["aprs-bar-overlay", overlay], + ["aprs-packets", packets], + ["mode", new ElementFixture("PKT")], + ]); + const window = { + ...createHost({ state: { lastActiveRigId: "rig-a" } }), + 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.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"); +}); + +test("the APRS mini view shows every frame until a rig is known", async () => { + const overlay = new ElementFixture(); + const elements = new Map([ + ["aprs-bar-overlay", overlay], + ["mode", new ElementFixture("PKT")], + ]); + const window = { + ...createHost(), + 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" }); + + assert.match(overlay.innerHTML, /SP1AAA/); + assert.match(overlay.innerHTML, /SP2BBB/); +}); + +test("the AIS mini view keeps to the active rig", async () => { + const overlay = new ElementFixture(); + const elements = new Map([ + ["ais-bar-overlay", overlay], + ["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.match(overlay.innerHTML, /NEARBY/); + assert.doesNotMatch(overlay.innerHTML, /ELSEWHERE/, "a background rig's vessel reached the mini view"); +}); + +test("the VDES mini view keeps to the active rig", async () => { + const overlay = new ElementFixture(); + const elements = new Map([ + ["vdes-bar-overlay", overlay], + ["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.match(overlay.innerHTML, /SP1AAA/); + assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's burst reached the mini view"); +}); + +test("the CW mini view keeps to the active rig and does not braid two rigs into a line", async () => { + const overlay = new ElementFixture(); + const elements = new Map([ + ["cw-bar-overlay", overlay], + ["cw-output", 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: "DX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" }); + window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" }); + + assert.match(overlay.innerHTML, /CQ SP1AAA/, "the active rig's line was broken up or lost"); + assert.doesNotMatch(overlay.innerHTML, /DX/, "a background rig's characters reached the mini view"); +}); + +test("the FT8 mini view keeps to the active rig", async () => { + const overlay = new ElementFixture(); + const elements = new Map([ + ["ft8-bar-overlay", overlay], + ["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.match(overlay.innerHTML, /SP1AAA/); + assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's decode reached the mini view"); +});