[fix](trx-frontend-http): keep the mini views to the rig on screen #53
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
+7
-1
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, CwLine>();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// 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");
|
||||
});
|
||||
@@ -27,6 +27,21 @@ const dial = () => page.evaluate(() => ({
|
||||
path: window.location.pathname,
|
||||
}));
|
||||
|
||||
// Typed, not filled. The app holds back its own refreshes of the frequency
|
||||
// field from the first keystroke until Enter, so that a state update arriving
|
||||
// mid-edit does not rewrite what is being typed. `fill()` sets the value
|
||||
// without a keystroke, leaving the field unguarded: on a slow machine a state
|
||||
// update could land between the fill and the Enter and put the old frequency
|
||||
// back, and the Enter would then re-apply the frequency the radio was already
|
||||
// on. Selecting first arms the guard before a single character changes.
|
||||
async function tuneByHand(text) {
|
||||
const field = page.locator("#freq");
|
||||
await field.click();
|
||||
await field.press("ControlOrMeta+a");
|
||||
await field.pressSequentially(text);
|
||||
await field.press("Enter");
|
||||
}
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1500, height: 950 });
|
||||
|
||||
@@ -49,8 +64,7 @@ try {
|
||||
|
||||
// Tuning by hand rewrites the link. This is the part that makes the address
|
||||
// bar shareable at any moment rather than only at load.
|
||||
await page.locator("#freq").fill("7.040M");
|
||||
await page.locator("#freq").press("Enter");
|
||||
await tuneByHand("7.040M");
|
||||
await page.waitForTimeout(1500);
|
||||
const tuned = await dial();
|
||||
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
|
||||
@@ -59,8 +73,7 @@ try {
|
||||
|
||||
// Tuning is not navigation: a swept dial must not bury the back button.
|
||||
const historyLength = await page.evaluate(() => window.history.length);
|
||||
await page.locator("#freq").fill("7.100M");
|
||||
await page.locator("#freq").press("Enter");
|
||||
await tuneByHand("7.100M");
|
||||
await page.waitForTimeout(1200);
|
||||
assert.equal(await page.evaluate(() => window.history.length), historyLength,
|
||||
"tuning pushed a history entry instead of replacing one");
|
||||
|
||||
Reference in New Issue
Block a user