From b48cc23d6eb5e4e59b97b80725766da07f8d944a Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Tue, 4 Aug 2026 22:38:21 +0200 Subject: [PATCH] [fix](trx-frontend-http): keep the rig names through the state stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker and the header showed each rig's lowercase id instead of its configured name. applyRigList takes the names as a parameter defaulted to an empty map, and the state-update path passes only the rig ids — names come from /rigs, not from a state frame — so that call landed on the default and the body, which treats "an object" as "here are the names", cleared them. One frame after load the names were gone for the rest of the session. Omitted now means no news rather than no names. The fixture is why this was invisible: it pushed an identical status payload every tick and the client skips a frame equal to the last, so render never ran and neither did the call that did the damage. Its event stream varies between frames now, as a real one does. Which immediately caught a second fault: state frames arrive continuously, and one sent before the server applied a new squelch threshold snapped the line back to where it had just been dragged from. A local change outranks the echo for two seconds, the same idea as the optimistic frequency guard beside it. The fixture also records what /set_sdr_squelch sets and reports it back afterwards — the drag test had been passing against a server that ignored the write. Signed-off-by: Stan Grams --- .../assets/web/generated/app.js | 6 +++- .../trx-frontend-http/frontend/src/app.ts | 14 +++++++- .../frontend/tests/browser-smoke.mjs | 14 ++++++++ .../frontend/tests/web-fixture.mjs | 34 +++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) 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 7272b89f..1d22e6ca 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 @@ -2825,7 +2825,7 @@ function updateRigSubtitle(activeRigId) { rigSubtitle.textContent = `Rig: ${name}`; updateDocumentTitle(activeChannelRds()); } -function applyRigList(activeRigId, rigIds, displayNames = {}) { +function applyRigList(activeRigId, rigIds, displayNames) { if (!Array.isArray(rigIds)) return; const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0); const prevKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); @@ -6433,6 +6433,7 @@ function positionSquelchLine() { } function submitSdrSquelch() { if (!sdrSquelchSupported) return; + sdrSquelchLocalAt = Date.now(); saveSetting("sdrSquelchEnabled", sdrSquelchEnabled); saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb); postPath( @@ -6446,6 +6447,8 @@ function setSdrSquelch(thresholdDb, enabled, options = {}) { renderSdrSquelch(); if (options.submit !== false) submitSdrSquelch(); } +var SDR_SQUELCH_HOLD_MS = 2e3; +var sdrSquelchLocalAt = 0; var SQUELCH_NOISE_WINDOW_MS = 1e4; var SQUELCH_NOISE_MARGIN_DB = 5; var SQUELCH_MEASURE_MS = 1500; @@ -6478,6 +6481,7 @@ function updateSdrSquelchControlVisibility() { function syncSdrSquelchFromServer(enabled, thresholdDb) { if (squelchDragPointerId !== null) return; if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return; + if (Date.now() - sdrSquelchLocalAt < SDR_SQUELCH_HOLD_MS) return; sdrSquelchEnabled = enabled; if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb); saveSetting("sdrSquelchEnabled", sdrSquelchEnabled); 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 45843d2e..3ded13e7 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 @@ -1424,7 +1424,11 @@ function updateRigSubtitle(activeRigId: string | null) { updateDocumentTitle(activeChannelRds()); } -function applyRigList(activeRigId: string | null, rigIds: string[], displayNames: Record = {}) { +// `displayNames` omitted means "no news", not "no names". The state updates +// carry only the rig ids — /rigs is what knows the names — and this defaulted +// to an empty map, so the first state frame after load wiped the names and the +// picker and header fell back to the lowercase ids for the rest of the session. +function applyRigList(activeRigId: string | null, rigIds: string[], displayNames?: Record) { if (!Array.isArray(rigIds)) return; const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0); // Detect whether the rig list or active rig actually changed so we can @@ -5404,6 +5408,7 @@ function positionSquelchLine() { function submitSdrSquelch() { if (!sdrSquelchSupported) return; + sdrSquelchLocalAt = Date.now(); saveSetting("sdrSquelchEnabled", sdrSquelchEnabled); saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb); postPath( @@ -5427,6 +5432,9 @@ function setSdrSquelch(thresholdDb: number, enabled: boolean, options: { submit? // the old "noise floor + 6 dB" left the gate 16-22 dB below the noise on a // narrow span and it simply never closed. Reading the same number the DSP // compares needs no conversion at all. +// How long a local squelch change outranks the server's echo of the old one. +const SDR_SQUELCH_HOLD_MS = 2_000; +let sdrSquelchLocalAt = 0; const SQUELCH_NOISE_WINDOW_MS = 10_000; const SQUELCH_NOISE_MARGIN_DB = 5; const SQUELCH_MEASURE_MS = 1_500; @@ -5472,6 +5480,10 @@ function syncSdrSquelchFromServer(enabled: boolean, thresholdDb: number | null) // level would fight the echo of the value the server last confirmed. if (squelchDragPointerId !== null) return; if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return; + // Nor just after letting go: state frames are in flight continuously, and + // one sent before the new threshold was applied would snap the line back to + // where it was dragged from. + if (Date.now() - sdrSquelchLocalAt < SDR_SQUELCH_HOLD_MS) return; sdrSquelchEnabled = enabled; if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb); saveSetting("sdrSquelchEnabled", sdrSquelchEnabled); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs index 7f0e2d21..cabaa179 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs @@ -147,6 +147,20 @@ try { assert.ok(["ok", "busy", "error"].includes(hint.state), `status pill state is ${hint.state}`); assert.equal(hint.state, "ok", `fixture reports "${hint.text}" but the pill is ${hint.state}`); + // Rig names, and they have to survive the state stream. The updates carry + // only rig ids — /rigs is what knows the names — and applying one used to + // clear the names, so the picker and the header fell back to the lowercase + // ids a second after load and stayed there. + await page.waitForTimeout(1500); + const rigLabels = await page.evaluate(() => ({ + options: [...document.getElementById("header-rig-switch-select").options].map((o) => o.textContent), + subtitle: document.getElementById("rig-subtitle").textContent, + })); + assert.deepEqual(rigLabels.options, ["Primary fixture", "Secondary fixture"], + `the picker reads ${JSON.stringify(rigLabels.options)}`); + assert.equal(rigLabels.subtitle, "Rig: Primary fixture", + `the header reads "${rigLabels.subtitle}"`); + const rigPicker = page.locator("#header-rig-switch-select"); await rigPicker.locator("option").nth(1).waitFor({ state: "attached" }); await rigPicker.selectOption("rig-b"); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs index 66d9f4cc..ee29f064 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs @@ -237,6 +237,18 @@ export async function startWebFixture({ const server = http.createServer(async (request, response) => { const url = new URL(request.url ?? "/", "http://127.0.0.1"); + // Setting a control means the next status carries the new value, the way a + // real server echoes what it applied. + if (url.pathname === "/set_sdr_squelch") { + const enabled = url.searchParams.get("enabled") === "true"; + const threshold = Number(url.searchParams.get("threshold_db")); + if (status.filter) { + status.filter.sdr_squelch_enabled = enabled; + if (Number.isFinite(threshold)) status.filter.sdr_squelch_threshold_db = threshold; + } + response.writeHead(200).end(); + return; + } if (url.pathname === "/select_rig" && request.method === "POST") { const remote = url.searchParams.get("remote"); if (remote) { @@ -324,6 +336,28 @@ export async function startWebFixture({ request.on("close", () => clearInterval(timer)); return; } + // The real server pushes rig state here every second or so; serving an + // open-but-silent stream meant nothing in the client's state-update path + // was ever exercised. + if (url.pathname === "/events") { + response.writeHead(200, { + "cache-control": "no-cache", + connection: "keep-alive", + "content-type": "text/event-stream", + }); + // Varying, as a real one is: the client skips a frame identical to the + // last, so a repeated payload exercises none of the state-update path. + const frame = () => JSON.stringify({ + ...status, + status: { ...status.status, freq: { hz: 100_000_000 + (Date.now() % 1000) } }, + }); + response.write(`data: ${frame()}\n\n`); + const timer = setInterval(() => { + response.write(`data: ${frame()}\n\n`); + }, 700); + request.on("close", () => clearInterval(timer)); + return; + } if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) { response.writeHead(200, { "cache-control": "no-cache",