diff --git a/docs/User-Manual.md b/docs/User-Manual.md
index ba498840..2c4a10b0 100644
--- a/docs/User-Manual.md
+++ b/docs/User-Manual.md
@@ -300,6 +300,39 @@ loopback on Linux, BlackHole on macOS).
---
+## Tune Links
+
+Every page of the web UI carries what the radio is doing in its address, so the
+URL in the address bar is always a link someone else can open:
+
+```
+http://receiver.example:8080/?rig=sdr&f=14074000&mode=USB&bw=3000
+```
+
+| Parameter | Meaning |
+|-----------|---------|
+| `f` | Frequency. Hz by default; `7074k` and `14.074M` also work. |
+| `mode` | Demodulation mode, e.g. `USB`, `CW`, `WFM`. |
+| `bw` | Filter bandwidth in Hz. Ignored by rigs without filter control. |
+| `rig` | Rig to select first, by id, on a multi-rig client. |
+
+Opening such a link selects the rig, sets the mode, tunes, and applies the
+bandwidth, in that order — a mode change carries its own default bandwidth, so
+an explicit `bw` is applied last. Anything the rig cannot do (an unknown mode,
+a frequency outside its range) is reported and the rest of the link still
+applies. All four parameters are optional.
+
+The link button in the top bar copies the current link to the clipboard. The
+address bar itself is updated as you tune, using `replaceState`, so sweeping
+the dial does not fill the browser's history.
+
+Applying a link changes the radio, so it needs the `control` role; an `rx`
+session opens the page and says the link was not applied. Links describe the
+rig's own dial — while a tab is listening to a virtual channel the address is
+left as it was, rather than publishing a frequency the rig is not on.
+
+---
+
## Authentication
The HTTP frontend supports optional passphrase-based authentication with two
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 ab7aabe2..592d42ab 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
@@ -747,7 +747,12 @@ function elementById(id) {
api.applyLayout(savedLayoutName(), { persist: false });
}
}
- const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
+ const overflowOrder = [
+ ".operator-layout-picker",
+ ".header-style-pick",
+ "#header-share-btn",
+ "#theme-toggle"
+ ];
function anchorMenu(button, menu) {
const rect = button.getBoundingClientRect();
if (menu.parentElement !== document.body) document.body.appendChild(menu);
@@ -1555,6 +1560,44 @@ function updateTabHistory(name, replace = false) {
if (replace) window.history.replaceState({}, "", nextUrl);
else window.history.pushState({}, "", nextUrl);
}
+var FREQ_MULTIPLIER = { k: 1e3, m: 1e6, g: 1e9 };
+function parseFrequencyParam(raw) {
+ if (typeof raw !== "string") return null;
+ const text = raw.trim().toLowerCase().replace(/hz$/, "").trim();
+ const match = /^(\d+(?:\.\d+)?)\s*([kmg]?)$/.exec(text);
+ if (!match) return null;
+ const value = Number(match[1]) * (FREQ_MULTIPLIER[match[2] ?? ""] ?? 1);
+ if (!Number.isFinite(value) || value <= 0) return null;
+ return Math.round(value);
+}
+function parseModeParam(raw) {
+ const mode = typeof raw === "string" ? raw.trim().toUpperCase() : "";
+ return /^[A-Z]{2,4}$/.test(mode) ? mode : null;
+}
+function parseTuneLink(search) {
+ const params = new URLSearchParams(search);
+ const rig = (params.get("rig") || "").trim();
+ return {
+ rig: rig || null,
+ freqHz: parseFrequencyParam(params.get("f")),
+ mode: parseModeParam(params.get("mode")),
+ bandwidthHz: parseFrequencyParam(params.get("bw"))
+ };
+}
+function tuneLinkSearch(search, link) {
+ const params = new URLSearchParams(search);
+ const set = (key, value) => {
+ if (value == null || value === "") params.delete(key);
+ else params.set(key, value);
+ };
+ const hz = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? String(Math.round(value)) : null;
+ set("rig", link.rig);
+ set("f", hz(link.freqHz));
+ set("mode", parseModeParam(link.mode));
+ set("bw", hz(link.bandwidthHz));
+ const text = params.toString();
+ return text ? `?${text}` : "";
+}
// src/features/radio/auto-bandwidth.ts
function clampPercent(value) {
@@ -1978,6 +2021,7 @@ function applyAuthRestrictions() {
function applyCapabilities(caps) {
if (!caps) return;
lastHasTx = !!caps.tx;
+ bandwidthControlSupported = !!caps.filter_controls;
if (signalVisualBlockEl) signalVisualBlockEl.style.display = "";
const pttBtn2 = document.getElementById("ptt-btn");
const txPowerCol = document.getElementById("tx-power-col");
@@ -2325,6 +2369,7 @@ var jogAngle = 0;
var lastClientCount = null;
var lastLocked = false;
var sdrSquelchSupported = false;
+var bandwidthControlSupported = false;
var previousTuneState = null;
function savePreviousTuneState() {
previousTuneState = {
@@ -4410,6 +4455,85 @@ window.buildAisVesselUrl = function(mmsi) {
if (!aisVesselUrlBase || !isFiniteNumber(Number(mmsi))) return null;
return `${aisVesselUrlBase}${String(mmsi)}`;
};
+var incomingTuneLink = parseTuneLink(window.location.search);
+var tuneLinkPhase = "pending";
+var tuneLinkSyncTimer = null;
+function currentTuneLink() {
+ const bandwidthHz = bandwidthControlSupported && isFiniteNumber(currentBandwidthHz) && currentBandwidthHz > 0 ? currentBandwidthHz : null;
+ return {
+ rig: lastActiveRigId || null,
+ freqHz: isFiniteNumber(lastFreqHz) ? lastFreqHz : null,
+ mode: lastModeName || null,
+ bandwidthHz
+ };
+}
+function tuneLinkUrl() {
+ const search = tuneLinkSearch(window.location.search, currentTuneLink());
+ return `${window.location.origin}${window.location.pathname}${search}${window.location.hash}`;
+}
+function scheduleTuneLinkSync() {
+ if (tuneLinkPhase !== "live") return;
+ if (window.trx?.modules.vchan?.isOnVirtual() === true) return;
+ if (tuneLinkSyncTimer != null) return;
+ tuneLinkSyncTimer = window.setTimeout(() => {
+ tuneLinkSyncTimer = null;
+ const search = tuneLinkSearch(window.location.search, currentTuneLink());
+ if (search === window.location.search) return;
+ window.history.replaceState({}, "", `${window.location.pathname}${search}${window.location.hash}`);
+ }, 500);
+}
+async function applyTuneLink(link) {
+ const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
+ if (!wanted) return;
+ if (authRole === "rx") {
+ showHint("Read-only session — link not applied", 2500);
+ return;
+ }
+ if (link.rig && headerRigSwitchSelect && link.rig !== lastActiveRigId && lastRigIds.includes(link.rig)) {
+ headerRigSwitchSelect.value = link.rig;
+ await switchRigFromSelect(headerRigSwitchSelect);
+ }
+ if (link.mode && link.mode !== lastModeName && modeEl) {
+ const known = Array.from(modeEl.options).some((option) => option.value === link.mode);
+ if (known) {
+ modeEl.value = link.mode;
+ await applyModeFromPicker();
+ } else {
+ showHint(`Rig has no ${link.mode} mode`, 2500);
+ }
+ }
+ if (link.freqHz != null) {
+ try {
+ setRigFrequency(link.freqHz);
+ } catch {
+ }
+ }
+ if (link.bandwidthHz != null && bandwidthControlSupported && spectrumBwInput) {
+ spectrumBwInput.value = String(link.bandwidthHz / 1e3);
+ await applyBandwidthFromInput();
+ }
+}
+function followTuneLinkOnce() {
+ if (tuneLinkPhase !== "pending") return;
+ tuneLinkPhase = "applying";
+ void applyTuneLink(incomingTuneLink).finally(() => {
+ tuneLinkPhase = "live";
+ scheduleTuneLinkSync();
+ });
+}
+async function copyTuneLink() {
+ const url = tuneLinkUrl();
+ try {
+ await navigator.clipboard.writeText(url);
+ showHint("Link copied", 1500);
+ } catch {
+ window.trxUi?.notify("Clipboard unavailable — the address bar holds the link", { kind: "error" });
+ }
+}
+var shareLinkBtn = document.getElementById("header-share-btn");
+if (shareLinkBtn) shareLinkBtn.addEventListener("click", () => {
+ void copyTuneLink();
+});
function render(update) {
if (!update) return;
if (update.server_version) serverVersion = update.server_version;
@@ -4922,6 +5046,8 @@ function render(update) {
swrBar.style.width = "0%";
swrValue.textContent = "SWR --";
}
+ if (update.status) followTuneLinkOnce();
+ scheduleTuneLinkSync();
}
function scheduleReconnect(delayMs = 1e3) {
if (reconnectTimer) return;
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
index de45e33f..7f8def65 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
@@ -99,6 +99,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
index 46d67167..0edbabd9 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
@@ -1345,7 +1345,10 @@ small { color: var(--text-muted); }
.top-bar-actions > * {
flex: 0 0 auto;
}
-.header-bar-btn.header-audio-btn {
+/* Square icon buttons in the top bar: an icon with no box of its own has no
+ size to draw at, so the button gives it one. */
+.header-bar-btn.header-audio-btn,
+.header-bar-btn.header-share-btn {
width: 2rem;
height: 2rem;
min-height: 0;
@@ -1360,10 +1363,15 @@ small { color: var(--text-muted); }
cursor: pointer;
flex-shrink: 0;
}
-.header-audio-btn svg {
+.header-audio-btn svg,
+.header-share-btn svg {
width: 100%;
height: 100%;
}
+/* The link icon is drawn in strokes, not fills, and wants room around it. */
+.header-share-btn svg {
+ padding: 1px;
+}
.header-audio-btn.audio-active {
color: #00d17f;
border-color: #00d17f;
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json
index ec41ae2f..60334cc3 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs",
- "test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs",
+ "test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
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 3ded13e7..653422e1 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
@@ -34,7 +34,10 @@ import {
import { decodeCbor as decodeCborPayload } from "./core/cbor.js";
import {
TAB_ORDER,
+ type TuneLink,
+ parseTuneLink,
tabFromPath as tabFromPathname,
+ tuneLinkSearch,
updateTabHistory,
} from "./features/navigation/routes.js";
import { estimateOccupiedBandwidth } from "./features/radio/auto-bandwidth.js";
@@ -609,6 +612,7 @@ function applyAuthRestrictions() {
function applyCapabilities(caps: RigCapabilities | null) {
if (!caps) return;
lastHasTx = !!caps.tx;
+ bandwidthControlSupported = !!caps.filter_controls;
if (signalVisualBlockEl) signalVisualBlockEl.style.display = "";
// PTT / TX controls
@@ -1012,6 +1016,9 @@ let jogAngle = 0;
let lastClientCount: number | null = null;
let lastLocked = false;
let sdrSquelchSupported = false;
+// Whether this rig takes a bandwidth at all — a CAT rig without filter control
+// has no use for the `bw` of a tune link, in either direction.
+let bandwidthControlSupported = false;
// ── Previous-state tracking for "B" hotkey ────────────────────────────────────
let previousTuneState: PreviousTuneState | null = null;
@@ -3267,6 +3274,110 @@ window.buildAisVesselUrl = function(mmsi: unknown) {
return `${aisVesselUrlBase}${String(mmsi)}`;
};
+// ---------------------------------------------------------------------------
+// Tune links
+//
+// The address bar is the share link: whatever the dial is on is in the query
+// string, and a link someone else opens puts the radio there. Read the
+// incoming link once, at load, before anything of ours rewrites the URL.
+// ---------------------------------------------------------------------------
+
+const incomingTuneLink: TuneLink = parseTuneLink(window.location.search);
+// "pending" until the first state arrives, "applying" while the link is being
+// followed, "live" once the URL is ours to keep up to date.
+let tuneLinkPhase: "pending" | "applying" | "live" = "pending";
+let tuneLinkSyncTimer: number | null = null;
+
+function currentTuneLink(): TuneLink {
+ const bandwidthHz = bandwidthControlSupported && isFiniteNumber(currentBandwidthHz) && currentBandwidthHz > 0
+ ? currentBandwidthHz
+ : null;
+ return {
+ rig: lastActiveRigId || null,
+ freqHz: isFiniteNumber(lastFreqHz) ? lastFreqHz : null,
+ mode: lastModeName || null,
+ bandwidthHz,
+ };
+}
+
+/** The link as someone else would open it. */
+function tuneLinkUrl(): string {
+ const search = tuneLinkSearch(window.location.search, currentTuneLink());
+ return `${window.location.origin}${window.location.pathname}${search}${window.location.hash}`;
+}
+
+function scheduleTuneLinkSync() {
+ if (tuneLinkPhase !== "live") return;
+ // A virtual channel is a tab's own slice, not where the rig is pointed —
+ // publishing it as the rig's link would send someone to another frequency.
+ if (window.trx?.modules.vchan?.isOnVirtual() === true) return;
+ if (tuneLinkSyncTimer != null) return;
+ tuneLinkSyncTimer = window.setTimeout(() => {
+ tuneLinkSyncTimer = null;
+ const search = tuneLinkSearch(window.location.search, currentTuneLink());
+ if (search === window.location.search) return;
+ // replaceState, not push: tuning is not navigation, and a dial sweep would
+ // otherwise bury the back button under a hundred entries.
+ window.history.replaceState({}, "", `${window.location.pathname}${search}${window.location.hash}`);
+ }, 500);
+}
+
+async function applyTuneLink(link: TuneLink) {
+ const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
+ if (!wanted) return;
+ if (authRole === "rx") {
+ showHint("Read-only session — link not applied", 2500);
+ return;
+ }
+ // Rig first: the rest of the link describes state on that rig.
+ if (link.rig && headerRigSwitchSelect && link.rig !== lastActiveRigId && lastRigIds.includes(link.rig)) {
+ headerRigSwitchSelect.value = link.rig;
+ await switchRigFromSelect(headerRigSwitchSelect);
+ }
+ // Mode before frequency: the mode change carries a default bandwidth with
+ // it, which an explicit bw in the link then overrides.
+ if (link.mode && link.mode !== lastModeName && modeEl) {
+ const known = Array.from(modeEl.options).some((option) => option.value === link.mode);
+ if (known) {
+ modeEl.value = link.mode;
+ await applyModeFromPicker();
+ } else {
+ showHint(`Rig has no ${link.mode} mode`, 2500);
+ }
+ }
+ if (link.freqHz != null) {
+ // setRigFrequency throws on a frequency this rig cannot reach, having
+ // already told the user which one it was.
+ try { setRigFrequency(link.freqHz); } catch { /* reported to the user */ }
+ }
+ if (link.bandwidthHz != null && bandwidthControlSupported && spectrumBwInput) {
+ spectrumBwInput.value = String(link.bandwidthHz / 1000);
+ await applyBandwidthFromInput();
+ }
+}
+
+function followTuneLinkOnce() {
+ if (tuneLinkPhase !== "pending") return;
+ tuneLinkPhase = "applying";
+ void applyTuneLink(incomingTuneLink).finally(() => {
+ tuneLinkPhase = "live";
+ scheduleTuneLinkSync();
+ });
+}
+
+async function copyTuneLink() {
+ const url = tuneLinkUrl();
+ try {
+ await navigator.clipboard.writeText(url);
+ showHint("Link copied", 1500);
+ } catch {
+ window.trxUi?.notify("Clipboard unavailable — the address bar holds the link", { kind: "error" });
+ }
+}
+
+const shareLinkBtn = document.getElementById("header-share-btn") as HTMLButtonElement | null;
+if (shareLinkBtn) shareLinkBtn.addEventListener("click", () => { void copyTuneLink(); });
+
function render(update: AppUpdate) {
if (!update) return;
if (update.server_version) serverVersion = update.server_version;
@@ -3842,6 +3953,10 @@ function render(update: AppUpdate) {
swrBar.style.width = "0%";
swrValue.textContent = "SWR --";
}
+ // Follow the link that opened the page once there is a rig to point, then
+ // keep the address bar on whatever the dial does next.
+ if (update.status) followTuneLinkOnce();
+ scheduleTuneLinkSync();
}
function scheduleReconnect(delayMs = 1000) {
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/navigation/routes.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/navigation/routes.ts
index 3d8ad7a5..497413c5 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/navigation/routes.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/navigation/routes.ts
@@ -37,3 +37,66 @@ export function updateTabHistory(name: TabName, replace = false): void {
if (replace) window.history.replaceState({}, "", nextUrl);
else window.history.pushState({}, "", nextUrl);
}
+
+/** What a shared link says to listen to: `?rig=sdr&f=14074000&mode=USB&bw=3000`. */
+export interface TuneLink {
+ rig: string | null;
+ freqHz: number | null;
+ mode: string | null;
+ bandwidthHz: number | null;
+}
+
+const FREQ_MULTIPLIER: Readonly> = { k: 1e3, m: 1e6, g: 1e9 };
+
+/**
+ * Read a frequency written for a person: bare Hz as the canonical form, but
+ * `7074k` and `14.074M` are what someone typing a link by hand reaches for.
+ * Returns Hz, or null for anything that is not a positive frequency.
+ */
+export function parseFrequencyParam(raw: string | null | undefined): number | null {
+ if (typeof raw !== "string") return null;
+ const text = raw.trim().toLowerCase().replace(/hz$/, "").trim();
+ const match = /^(\d+(?:\.\d+)?)\s*([kmg]?)$/.exec(text);
+ if (!match) return null;
+ const value = Number(match[1]) * (FREQ_MULTIPLIER[match[2] ?? ""] ?? 1);
+ if (!Number.isFinite(value) || value <= 0) return null;
+ return Math.round(value);
+}
+
+/** Mode names run from FM to VDES; anything else in the URL is not one. */
+function parseModeParam(raw: string | null | undefined): string | null {
+ const mode = typeof raw === "string" ? raw.trim().toUpperCase() : "";
+ return /^[A-Z]{2,4}$/.test(mode) ? mode : null;
+}
+
+export function parseTuneLink(search: string): TuneLink {
+ const params = new URLSearchParams(search);
+ const rig = (params.get("rig") || "").trim();
+ return {
+ rig: rig || null,
+ freqHz: parseFrequencyParam(params.get("f")),
+ mode: parseModeParam(params.get("mode")),
+ bandwidthHz: parseFrequencyParam(params.get("bw")),
+ };
+}
+
+/**
+ * Rewrite the tune parameters of a query string, leaving anything else in it
+ * alone — the address bar is the share link, so it has to keep up with the
+ * dial without discarding whatever else a page put there.
+ */
+export function tuneLinkSearch(search: string, link: TuneLink): string {
+ const params = new URLSearchParams(search);
+ const set = (key: string, value: string | null) => {
+ if (value == null || value === "") params.delete(key);
+ else params.set(key, value);
+ };
+ const hz = (value: number | null) =>
+ (typeof value === "number" && Number.isFinite(value) && value > 0 ? String(Math.round(value)) : null);
+ set("rig", link.rig);
+ set("f", hz(link.freqHz));
+ set("mode", parseModeParam(link.mode));
+ set("bw", hz(link.bandwidthHz));
+ const text = params.toString();
+ return text ? `?${text}` : "";
+}
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts
index d59be76a..70022d6e 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts
@@ -327,7 +327,9 @@ function elementById(id: string): T {
// Secondary controls, in the order they leave the bar when it gets tight.
// Audio, record and the rig picker are the operating controls and stay.
- const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
+ const overflowOrder = [
+ ".operator-layout-picker", ".header-style-pick", "#header-share-btn", "#theme-toggle",
+ ];
// Anchored at paint time in fixed coordinates. An absolutely positioned
// dropdown is clipped by any scrolling ancestor and trapped inside the
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/navigation-routes.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/navigation-routes.test.mjs
index 2703b784..6f966280 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/navigation-routes.test.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/navigation-routes.test.mjs
@@ -17,7 +17,8 @@ async function loadRoutes(window) {
write: false,
});
const module = { exports: {} };
- vm.runInNewContext(result.outputFiles[0].text, { module, exports: module.exports, window });
+ // The sandbox is bare; the browser globals the module actually uses go in.
+ vm.runInNewContext(result.outputFiles[0].text, { module, exports: module.exports, window, URLSearchParams });
return module.exports;
}
@@ -38,3 +39,55 @@ test("all top-level tabs have stable round-trip routes", async () => {
assert.equal(calls[0][0], "push");
assert.equal(calls[0][3], "/statistics?rig=one#panel");
});
+
+// A tune link is what one operator sends another: it has to survive being
+// typed by hand, and it has to come back out as the frequency that went in.
+test("tune links parse the frequencies people write", async () => {
+ const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
+ for (const [text, hz] of [
+ ["14074000", 14_074_000], ["14074000hz", 14_074_000],
+ ["7074k", 7_074_000], ["14.074M", 14_074_000], ["1.2G", 1_200_000_000],
+ [" 145500k ", 145_500_000],
+ ]) {
+ assert.equal(routes.parseFrequencyParam(text), hz, `${text} did not read as ${hz} Hz`);
+ }
+ for (const bad of ["", " ", "abc", "-7074", "0", "14,074", "1e6", null, undefined]) {
+ assert.equal(routes.parseFrequencyParam(bad), null, `${bad} was read as a frequency`);
+ }
+});
+
+test("tune links carry rig, frequency, mode and bandwidth", async () => {
+ const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
+ assert.deepEqual({ ...routes.parseTuneLink("?rig=sdr&f=14.074M&mode=usb&bw=3k") }, {
+ rig: "sdr", freqHz: 14_074_000, mode: "USB", bandwidthHz: 3000,
+ });
+ // A link with nothing to say must not tune anything.
+ assert.deepEqual({ ...routes.parseTuneLink("?tab=map") }, {
+ rig: null, freqHz: null, mode: null, bandwidthHz: null,
+ });
+ // Junk in a parameter is not a frequency, and must not become one.
+ assert.deepEqual({ ...routes.parseTuneLink("?f=&mode=NOTAMODE&bw=wide") }, {
+ rig: null, freqHz: null, mode: null, bandwidthHz: null,
+ });
+});
+
+test("writing a tune link canonicalises it and leaves other parameters alone", async () => {
+ const routes = await loadRoutes({ location: { pathname: "/", search: "", hash: "" }, history: {} });
+ const search = routes.tuneLinkSearch("?theme=dark&f=7074k", {
+ rig: "sdr", freqHz: 14_074_000.4, mode: "usb", bandwidthHz: 3000,
+ });
+ const params = new URLSearchParams(search);
+ assert.equal(params.get("theme"), "dark", "an unrelated parameter was dropped");
+ assert.equal(params.get("f"), "14074000", "the frequency was not written as whole Hz");
+ assert.equal(params.get("mode"), "USB");
+ assert.equal(params.get("bw"), "3000");
+ assert.equal(params.get("rig"), "sdr");
+ // Round trip: what was written reads back as what went in.
+ assert.deepEqual({ ...routes.parseTuneLink(search) }, {
+ rig: "sdr", freqHz: 14_074_000, mode: "USB", bandwidthHz: 3000,
+ });
+ // Nothing known yet means no stale tune parameters left behind.
+ const cleared = routes.tuneLinkSearch(search, { rig: null, freqHz: null, mode: null, bandwidthHz: null });
+ assert.equal(cleared, "?theme=dark");
+ assert.equal(routes.tuneLinkSearch("", { rig: null, freqHz: null, mode: null, bandwidthHz: null }), "");
+});
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/tune-links.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/tune-links.mjs
new file mode 100644
index 00000000..85ab0535
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/tune-links.mjs
@@ -0,0 +1,102 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// A link is how one operator tells another where to listen. Opening one has to
+// put the radio there, and the address bar has to keep up with the dial after
+// that — a link that goes stale the moment someone tunes is worse than none,
+// because it looks like it works.
+
+import assert from "node:assert/strict";
+import { chromium } from "playwright-core";
+import { startBrowser, startWebFixture } from "./web-fixture.mjs";
+
+/* global document, window */
+
+const LINK_HZ = 14_074_000;
+const LINK_BW = 2_800;
+const TUNED_HZ = 7_040_000;
+
+const fixture = await startWebFixture({ spectrum: true });
+const { browser, page, runtimeErrors } = await startBrowser(chromium);
+
+const dial = () => page.evaluate(() => ({
+ freqHz: window.lastFreqHz,
+ mode: document.getElementById("mode").value,
+ search: window.location.search,
+ path: window.location.pathname,
+}));
+
+try {
+ await page.setViewportSize({ width: 1500, height: 950 });
+
+ // Opening the link tunes the radio: frequency, mode and bandwidth.
+ await page.goto(`${fixture.origin}/?f=14.074M&mode=USB&bw=${LINK_BW}`, { waitUntil: "domcontentloaded" });
+ await page.locator("#content").waitFor({ state: "visible" });
+ await page.waitForTimeout(2500);
+
+ const opened = await dial();
+ assert.equal(opened.freqHz, LINK_HZ, `the link left the radio on ${opened.freqHz} Hz`);
+ assert.equal(opened.mode, "USB", `the link left the radio in ${opened.mode}`);
+ const bandwidth = await page.evaluate(() => window.currentBandwidthHz);
+ assert.equal(bandwidth, LINK_BW, `the link left the bandwidth at ${bandwidth} Hz`);
+
+ // Hand-written frequencies are canonicalised in place, so what the operator
+ // copies back out is the same link in the form the app writes.
+ const params = new URLSearchParams(opened.search);
+ assert.equal(params.get("f"), String(LINK_HZ), `the URL kept "${params.get("f")}"`);
+ assert.equal(params.get("mode"), "USB");
+
+ // 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 page.waitForTimeout(1500);
+ const tuned = await dial();
+ assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
+ assert.equal(new URLSearchParams(tuned.search).get("f"), String(TUNED_HZ),
+ `the URL still reads "${tuned.search}" after tuning`);
+
+ // 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 page.waitForTimeout(1200);
+ assert.equal(await page.evaluate(() => window.history.length), historyLength,
+ "tuning pushed a history entry instead of replacing one");
+
+ // Moving between tabs keeps the tune parameters: the path changes, the link
+ // does not stop being a link.
+ await page.locator('.tab[data-tab="map"]').click();
+ await page.waitForTimeout(800);
+ const onMap = await dial();
+ assert.equal(onMap.path, "/map", `the map tab landed on ${onMap.path}`);
+ assert.equal(new URLSearchParams(onMap.search).get("f"), "7100000",
+ `the map tab dropped the tune parameters: "${onMap.search}"`);
+
+ // A link naming a rig selects it before applying the rest.
+ await page.goto(`${fixture.origin}/?rig=rig-b&f=${TUNED_HZ}&mode=CW`, { waitUntil: "domcontentloaded" });
+ await page.locator("#content").waitFor({ state: "visible" });
+ await page.waitForTimeout(2500);
+ const switched = await page.evaluate(() => ({
+ rig: document.getElementById("header-rig-switch-select")?.value ?? null,
+ selected: window.location.search,
+ mode: document.getElementById("mode").value,
+ }));
+ assert.equal(switched.rig, "rig-b", `the link left the client on rig "${switched.rig}"`);
+ assert.equal(switched.mode, "CW", `the link left the radio in ${switched.mode}`);
+
+ // A link with nothing to say tunes nothing, and the address bar fills in
+ // with where the radio already is.
+ await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
+ await page.locator("#content").waitFor({ state: "visible" });
+ await page.waitForTimeout(2000);
+ const bare = await dial();
+ assert.equal(new URLSearchParams(bare.search).get("f"), String(bare.freqHz),
+ `a bare load wrote "${bare.search}" for a radio on ${bare.freqHz} Hz`);
+
+ assert.deepEqual(runtimeErrors, []);
+} finally {
+ await browser.close();
+ await fixture.close();
+}
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 ee29f064..d81b4a65 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
@@ -249,6 +249,24 @@ export async function startWebFixture({
response.writeHead(200).end();
return;
}
+ if (url.pathname === "/set_freq") {
+ const hz = Number(url.searchParams.get("hz"));
+ if (Number.isFinite(hz) && hz > 0) status.status.freq = { hz: Math.round(hz) };
+ response.writeHead(200).end();
+ return;
+ }
+ if (url.pathname === "/set_mode") {
+ const next = (url.searchParams.get("mode") || "").toUpperCase();
+ if (next) status.status.mode = next;
+ response.writeHead(200).end();
+ return;
+ }
+ if (url.pathname === "/set_bandwidth") {
+ const hz = Number(url.searchParams.get("hz"));
+ if (Number.isFinite(hz) && hz > 0 && status.filter) status.filter.bandwidth_hz = Math.round(hz);
+ response.writeHead(200).end();
+ return;
+ }
if (url.pathname === "/select_rig" && request.method === "POST") {
const remote = url.searchParams.get("remote");
if (remote) {
@@ -347,9 +365,11 @@ export async function startWebFixture({
});
// 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.
+ // The variation rides on the S-meter rather than the dial — a rig that
+ // wandered a kilohertz every frame could never be told to tune.
const frame = () => JSON.stringify({
...status,
- status: { ...status.status, freq: { hz: 100_000_000 + (Date.now() % 1000) } },
+ status: { ...status.status, rx: { ...status.status.rx, sig: meterDb + ((Date.now() % 3) - 1) } },
});
response.write(`data: ${frame()}\n\n`);
const timer = setInterval(() => {