[fix](trx-frontend-http): keep the rig names through the state stream
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m14s
CI / frontend (push) Successful in 3m47s
CI / reuse (push) Successful in 3s

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 <sjg@haxx.space>
This commit is contained in:
sjg
2026-08-04 22:38:21 +02:00
parent c1899229a0
commit b48cc23d6e
4 changed files with 66 additions and 2 deletions
@@ -2825,7 +2825,7 @@ function updateRigSubtitle(activeRigId) {
rigSubtitle.textContent = `Rig: ${name}`; rigSubtitle.textContent = `Rig: ${name}`;
updateDocumentTitle(activeChannelRds()); updateDocumentTitle(activeChannelRds());
} }
function applyRigList(activeRigId, rigIds, displayNames = {}) { function applyRigList(activeRigId, rigIds, displayNames) {
if (!Array.isArray(rigIds)) return; if (!Array.isArray(rigIds)) return;
const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0); const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0);
const prevKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); const prevKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
@@ -6433,6 +6433,7 @@ function positionSquelchLine() {
} }
function submitSdrSquelch() { function submitSdrSquelch() {
if (!sdrSquelchSupported) return; if (!sdrSquelchSupported) return;
sdrSquelchLocalAt = Date.now();
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled); saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb); saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
postPath( postPath(
@@ -6446,6 +6447,8 @@ function setSdrSquelch(thresholdDb, enabled, options = {}) {
renderSdrSquelch(); renderSdrSquelch();
if (options.submit !== false) submitSdrSquelch(); if (options.submit !== false) submitSdrSquelch();
} }
var SDR_SQUELCH_HOLD_MS = 2e3;
var sdrSquelchLocalAt = 0;
var SQUELCH_NOISE_WINDOW_MS = 1e4; var SQUELCH_NOISE_WINDOW_MS = 1e4;
var SQUELCH_NOISE_MARGIN_DB = 5; var SQUELCH_NOISE_MARGIN_DB = 5;
var SQUELCH_MEASURE_MS = 1500; var SQUELCH_MEASURE_MS = 1500;
@@ -6478,6 +6481,7 @@ function updateSdrSquelchControlVisibility() {
function syncSdrSquelchFromServer(enabled, thresholdDb) { function syncSdrSquelchFromServer(enabled, thresholdDb) {
if (squelchDragPointerId !== null) return; if (squelchDragPointerId !== null) return;
if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return; if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return;
if (Date.now() - sdrSquelchLocalAt < SDR_SQUELCH_HOLD_MS) return;
sdrSquelchEnabled = enabled; sdrSquelchEnabled = enabled;
if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb); if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled); saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
@@ -1424,7 +1424,11 @@ function updateRigSubtitle(activeRigId: string | null) {
updateDocumentTitle(activeChannelRds()); updateDocumentTitle(activeChannelRds());
} }
function applyRigList(activeRigId: string | null, rigIds: string[], displayNames: Record<string, string> = {}) { // `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<string, string>) {
if (!Array.isArray(rigIds)) return; if (!Array.isArray(rigIds)) return;
const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0); const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0);
// Detect whether the rig list or active rig actually changed so we can // Detect whether the rig list or active rig actually changed so we can
@@ -5404,6 +5408,7 @@ function positionSquelchLine() {
function submitSdrSquelch() { function submitSdrSquelch() {
if (!sdrSquelchSupported) return; if (!sdrSquelchSupported) return;
sdrSquelchLocalAt = Date.now();
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled); saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb); saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
postPath( 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 // 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 // narrow span and it simply never closed. Reading the same number the DSP
// compares needs no conversion at all. // 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_WINDOW_MS = 10_000;
const SQUELCH_NOISE_MARGIN_DB = 5; const SQUELCH_NOISE_MARGIN_DB = 5;
const SQUELCH_MEASURE_MS = 1_500; 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. // level would fight the echo of the value the server last confirmed.
if (squelchDragPointerId !== null) return; if (squelchDragPointerId !== null) return;
if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) 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; sdrSquelchEnabled = enabled;
if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb); if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled); saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
@@ -147,6 +147,20 @@ try {
assert.ok(["ok", "busy", "error"].includes(hint.state), `status pill state is ${hint.state}`); 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}`); 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"); const rigPicker = page.locator("#header-rig-switch-select");
await rigPicker.locator("option").nth(1).waitFor({ state: "attached" }); await rigPicker.locator("option").nth(1).waitFor({ state: "attached" });
await rigPicker.selectOption("rig-b"); await rigPicker.selectOption("rig-b");
@@ -237,6 +237,18 @@ export async function startWebFixture({
const server = http.createServer(async (request, response) => { const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1"); 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") { if (url.pathname === "/select_rig" && request.method === "POST") {
const remote = url.searchParams.get("remote"); const remote = url.searchParams.get("remote");
if (remote) { if (remote) {
@@ -324,6 +336,28 @@ export async function startWebFixture({
request.on("close", () => clearInterval(timer)); request.on("close", () => clearInterval(timer));
return; 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)) { if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
response.writeHead(200, { response.writeHead(200, {
"cache-control": "no-cache", "cache-control": "no-cache",