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",