Compare commits

..
Author SHA1 Message Date
sjg 53736ef750 fix(ui): hide SDR power and align header controls
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 2s
CI / reuse (pull_request) Failing after 1s
2026-08-01 11:23:57 +02:00
sjg 6566eae2c7 fix(ui): keep workspace and rig context visible
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 1s
CI / lint (push) Failing after 0s
CI / test (push) Failing after 0s
CI / reuse (push) Failing after 1s
2026-08-01 11:19:31 +02:00
sjg f3eeddff7c fix(ui): close mobile overlays predictably 2026-08-01 11:18:37 +02:00
sjg 1540ca5b4e test(ui): cover shared component behavior 2026-08-01 11:17:17 +02:00
sjg e449704fd2 feat(ui): explain unavailable workspaces 2026-08-01 11:16:03 +02:00
sjg af74430551 fix(ui): make rig switching atomic and visible 2026-08-01 11:15:39 +02:00
sjg d07dbdc645 feat(ui): focus broadcast workspace on reception 2026-08-01 11:14:10 +02:00
sjg d07a627508 feat(ui): remember workspaces per rig 2026-08-01 11:13:21 +02:00
sjg 9fbf90ee91 feat(ui): explain operator workspace choices 2026-08-01 11:12:33 +02:00
sjg d393b3cefc feat(ui): drive workspaces from rig capabilities 2026-08-01 11:11:47 +02:00
sjg 35a492ec95 fix(ui): gate broadcast layout on rig capability
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 2s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 1s
CI / test (push) Failing after 3s
CI / reuse (push) Failing after 1s
2026-08-01 11:05:45 +02:00
sjg b20da5c541 style(ui): polish shared interaction components
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 1s
CI / reuse (push) Failing after 2s
2026-08-01 10:43:29 +02:00
sjg a0bdaa2c4b feat(ui): improve radio operator experience
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 1s
CI / lint (push) Failing after 1s
CI / test (push) Failing after 1s
CI / reuse (push) Failing after 0s
2026-08-01 02:24:47 +02:00
sjg 6676b66993 [fix](trx-frontend): account for WFM interference in auto bandwidth
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 0s
CI / lint (push) Failing after 0s
CI / test (push) Failing after 1s
CI / reuse (push) Failing after 1s
2026-08-01 02:07:32 +02:00
sjg 412651d612 [fix](trx-frontend): narrow weak WFM to 60 kHz
CI / lint (push) Failing after 1s
CI / test (push) Failing after 2s
CI / reuse (push) Failing after 1s
2026-08-01 02:04:14 +02:00
sjg d347b493d9 [fix](trx-frontend): make auto bandwidth modulation-aware 2026-08-01 02:04:14 +02:00
sjg 39e59dca96 [fix](trx-server): gate test-only history imports
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 1s
CI / lint (push) Failing after 3s
CI / test (push) Failing after 2s
CI / reuse (push) Failing after 1s
2026-08-01 01:55:33 +02:00
sjg 5654520901 [fix](workspace): clear build and clippy warnings
CI / lint (push) Failing after 1s
CI / test (push) Failing after 1s
CI / reuse (push) Failing after 1s
2026-08-01 01:52:08 +02:00
sjg ff75fcc692 [refactor](trx-server): extract decoder history store
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 2s
CI / reuse (pull_request) Failing after 1s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 2s
CI / reuse (push) Failing after 1s
2026-08-01 01:45:19 +02:00
sjg 4728b578ae [refactor](trx-server): extract history policy 2026-08-01 01:44:26 +02:00
sjg 7db7fad9b0 [refactor](trx-frontend): define module service boundaries 2026-08-01 01:44:26 +02:00
sjg 830f7299fe [fix](trx-frontend): vendor Opus decoder 2026-08-01 01:44:26 +02:00
33 changed files with 2085 additions and 720 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+3 -3
View File
@@ -142,6 +142,6 @@ a unified set of frontends.
## License
GPL-2.0-or-later. See [`LICENSES`](LICENSES) for the full license text and
bundled third-party license files. Bundled third-party components (Leaflet and
the Leaflet AIS tracksymbol plugin under `assets/web/vendor/`) retain their
original BSD-2-Clause license.
bundled third-party license files. Bundled third-party components retain their
original licenses: Leaflet is BSD-2-Clause, DSEG is OFL-1.1, and opus-decoder
is MIT.
+8
View File
@@ -42,3 +42,11 @@ SPDX-License-Identifier = "BSD-2-Clause"
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/dseg14-classic-latin-400-normal.woff2"]
SPDX-FileCopyrightText = "2020 The DSEG Authors (https://github.com/keshikan/DSEG)"
SPDX-License-Identifier = "OFL-1.1"
# Vendored opus-decoder 0.7.11 browser build
# (https://github.com/eshaz/wasm-audio-decoders), MIT.
# SHA-256: fd73ee0a9c8a5e233c0b88234df14f46003ad89bb4ba27435bc8714db2a6dc62
[[annotations]]
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/opus-decoder-0.7.11.min.js"]
SPDX-FileCopyrightText = "2021-2025 Ethan Halsall"
SPDX-License-Identifier = "MIT"
@@ -23,6 +23,7 @@ window.onDecoderRegistryReady = function (fn) {
for (const fn of _decoderRegistryReadyCallbacks) fn();
_decoderRegistryReadyCallbacks.length = 0;
hideUnsupportedDecoderTabs();
refreshOperatorLayoutCapabilities();
}
} catch (e) {
console.error("Failed to fetch decoder registry:", e);
@@ -333,8 +334,19 @@ function applyCapabilities(caps) {
const txAudioBtn = document.getElementById("tx-audio-btn");
const txVolSlider = document.getElementById("tx-vol");
const txVolControl = txVolSlider ? txVolSlider.closest(".vol-label") : null;
if (txPowerCol) txPowerCol.style.display = caps.tx ? "" : "none";
const hasPowerControl = !caps.filter_controls;
if (txPowerCol) {
txPowerCol.style.display = (caps.tx || hasPowerControl || caps.lockable) ? "" : "none";
const label = txPowerCol.querySelector(".label span");
if (label) {
label.textContent = caps.tx && hasPowerControl ? "Transmit / Power"
: caps.tx ? "Transmit / Tuning"
: hasPowerControl ? "Power / Tuning" : "Tuning";
}
}
if (pttBtn) pttBtn.style.display = caps.tx ? "" : "none";
if (powerBtn) powerBtn.style.display = hasPowerControl ? "" : "none";
if (lockBtn) lockBtn.style.display = caps.lockable ? "" : "none";
if (txMetersRow) txMetersRow.style.display = caps.tx ? "" : "none";
if (txAudioBtn) txAudioBtn.style.display = caps.tx ? "" : "none";
if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none";
@@ -344,7 +356,7 @@ function applyCapabilities(caps) {
// TX limit row
const txLimitRow = document.getElementById("tx-limit-row");
if (txLimitRow && !caps.tx_limit) txLimitRow.style.display = "none";
if (txLimitRow) txLimitRow.style.display = caps.tx_limit ? "" : "none";
// VFO row
const vfoRow = document.getElementById("vfo-row");
@@ -436,6 +448,7 @@ const signalSplitValueEl = document.getElementById("signal-split-value");
const overviewPeakHoldEl = document.getElementById("overview-peak-hold");
const themeToggleBtn = document.getElementById("theme-toggle");
const headerRigSwitchSelect = document.getElementById("header-rig-switch-select");
const headerRigSummary = document.getElementById("header-rig-summary");
const headerStylePickSelect = document.getElementById("header-style-pick-select");
const rdsPsOverlay = document.getElementById("rds-ps-overlay");
const tabMainEl = document.getElementById("tab-main");
@@ -645,7 +658,7 @@ function flushDeferredDecodeMapSync() {
if (!decodeMapSyncPending || decodeHistoryReplayActive || !window.trx?.map?.aprsMap) return;
decodeMapSyncPending = false;
scheduleUiFrameJob("decode-map-maintenance", () => {
window.trx.map?.pruneMapHistory();
window.trx.modules.map?.pruneMapHistory();
});
}
@@ -840,6 +853,8 @@ let jogMult = loadSetting("jogMult", 1); // divisor: 1, 10, 100
let jogStep = Math.max(Math.round(jogUnit / jogMult), 1);
let minFreqStepHz = 1;
let lastModeName = "";
let lastWfmCci = 0;
let lastWfmAci = 0;
const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"];
function vfoColor(idx) {
if (idx < VFO_COLORS.length) return VFO_COLORS[idx];
@@ -893,6 +908,7 @@ async function restorePreviousTuneState() {
let lastRigIds = [];
let lastRigDisplayNames = {};
let lastActiveRigId = null;
let rigSwitchInProgress = false;
let lastCityLabel = "";
let sseSessionId = null;
const originalTitle = document.title;
@@ -1237,6 +1253,20 @@ function populateRigPicker(selectEl, rigIds, activeRigId, disabled) {
selectEl.disabled = disabled;
}
function updateRigIdentitySummary(rigId, pending = false) {
if (!headerRigSummary) return;
const rig = serverRigs.find((entry) => entry?.remote === rigId);
if (!rig) {
headerRigSummary.textContent = pending ? "Switching rigs…" : "No rig details available";
return;
}
const hardware = [rig.manufacturer, rig.model].map(value => String(value || "").trim()).filter(Boolean).join(" ") || rig.remote;
const modes = Array.isArray(rig.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : [];
const features = [rig.tx ? "TX" : "RX", rig.filter_controls ? "SDR filters" : null, ...modes.slice(0, 5)];
if (modes.length > 5) features.push(`+${modes.length - 5} modes`);
headerRigSummary.textContent = `${pending ? "Switching to " : ""}${hardware} · ${features.filter(Boolean).join(" · ")}`;
}
function updateRigSubtitle(activeRigId) {
if (!rigSubtitle) return;
const name = (activeRigId && lastRigDisplayNames[activeRigId]) || activeRigId || "--";
@@ -1273,13 +1303,15 @@ function applyRigList(activeRigId, rigIds, displayNames) {
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
if (rigListChanged) {
if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId);
if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId);
if (typeof bmPopulateScopePicker === "function") bmPopulateScopePicker();
if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || "");
}
window.trx.map?.updateMapRigFilter();
window.trx.modules.map?.updateMapRigFilter();
}
@@ -1303,18 +1335,35 @@ async function refreshRigList() {
}
});
serverRigs = rigs;
refreshOperatorLayoutCapabilities();
serverActiveRigId = data.active_remote || null;
applyRigList(data.active_remote, rigIds, displayNames);
window.trx.map?.syncAprsReceiverMarker();
window.trx.modules.map?.syncAprsReceiverMarker();
} catch (e) {
// Non-fatal: SSE/status path still drives main UI.
}
}
function refreshOperatorLayoutCapabilities() {
const rigModes = serverRigs.map((rig) =>
Array.isArray(rig?.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : []
);
const decoderModes = new Set(decoderRegistry.flatMap((decoder) =>
Array.isArray(decoder?.active_modes) ? decoder.active_modes.map(normalizeMode).filter(Boolean) : []
));
window.trxUi?.setLayoutCapabilities({
broadcast: rigModes.some((modes) => modes.includes("WFM")),
digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))),
});
}
function showHint(msg, duration) {
powerHint.textContent = msg;
if (hintTimer) clearTimeout(hintTimer);
if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration);
if (/failed|missing|unavailable|unknown|lost|required/i.test(msg)) {
window.trxUi?.notify(msg, { kind: "error" });
}
}
let supportedModes = [];
let supportedBands = [];
@@ -2829,7 +2878,7 @@ function showUnsupportedFreqPopup(hz) {
const now = Date.now();
if (now - lastUnsupportedFreqPopupAt < 1200) return;
lastUnsupportedFreqPopupAt = now;
window.alert(message);
window.trxUi?.notify(message.replaceAll("\n", " "), { kind: "error", duration: 7000 });
}
// Convert dBm (wire format) to S-units (S1=-121dBm, S9=-73dBm, 6dB/S-unit).
@@ -3143,9 +3192,9 @@ function render(update) {
const grid = latLonToMaidenhead(serverLat, serverLon);
locationSubtitle.textContent = `Location: ${grid}`;
locationSubtitle.style.display = "";
window.trx.map?.reverseGeocodeLocation(serverLat, serverLon, grid);
window.trx.modules.map?.reverseGeocodeLocation(serverLat, serverLon, grid);
}
window.trx.map?.syncAprsReceiverMarker();
window.trx.modules.map?.syncAprsReceiverMarker();
if (typeof update.initial_map_zoom === "number" && Number.isFinite(update.initial_map_zoom)) {
initialMapZoom = Math.max(1, Math.round(update.initial_map_zoom));
}
@@ -3293,8 +3342,14 @@ function render(update) {
wfmStFlagEl.classList.toggle("wfm-st-flag-stereo", detected);
wfmStFlagEl.classList.toggle("wfm-st-flag-mono", !detected);
}
if (typeof update.filter.wfm_cci === "number") updateIntfBar(wfmCciFillEl, wfmCciValEl, update.filter.wfm_cci);
if (typeof update.filter.wfm_aci === "number") updateIntfBar(wfmAciFillEl, wfmAciValEl, update.filter.wfm_aci);
if (typeof update.filter.wfm_cci === "number") {
lastWfmCci = Math.max(0, Math.min(100, update.filter.wfm_cci));
updateIntfBar(wfmCciFillEl, wfmCciValEl, lastWfmCci);
}
if (typeof update.filter.wfm_aci === "number") {
lastWfmAci = Math.max(0, Math.min(100, update.filter.wfm_aci));
updateIntfBar(wfmAciFillEl, wfmAciValEl, lastWfmAci);
}
if (samStereoWidthEl && typeof update.filter.sam_stereo_width === "number") {
samStereoWidthEl.value = String(Math.round(update.filter.sam_stereo_width * 100));
}
@@ -3421,7 +3476,11 @@ function render(update) {
if (update.status && typeof update.status.tx_en === "boolean" && update.status.tx_en !== prevRenderData.txEn) {
prevRenderData.txEn = update.status.tx_en;
lastTxEn = update.status.tx_en;
pttBtn.textContent = update.status.tx_en ? "PTT On" : "PTT Off";
window.trxUi?.setButtonState(pttBtn, {
active: update.status.tx_en,
activeLabel: "Stop TX",
inactiveLabel: "Start TX",
});
if (update.status.tx_en) {
pttBtn.style.background = "var(--accent-red)";
pttBtn.style.borderColor = "var(--accent-red)";
@@ -3530,11 +3589,15 @@ function render(update) {
bandLabel.textContent = typeof update.band === "string" ? update.band : "--";
}
if (typeof update.enabled === "boolean") {
powerBtn.disabled = false;
powerBtn.textContent = update.enabled ? "Power Off" : "Power On";
window.trxUi?.setButtonState(powerBtn, {
active: update.enabled,
activeLabel: "Power Off",
inactiveLabel: "Power On",
});
} else {
powerBtn.disabled = true;
powerBtn.textContent = "Toggle Power";
powerBtn.textContent = "Power unavailable";
powerBtn.setAttribute("aria-pressed", "false");
powerHint.textContent = "State unknown";
}
lastControl = update.enabled;
@@ -3649,7 +3712,11 @@ function render(update) {
}
powerHint.textContent = readyText();
lastLocked = update.status && update.status.lock === true;
lockBtn.textContent = lastLocked ? "Unlock" : "Lock";
window.trxUi?.setButtonState(lockBtn, {
active: lastLocked,
activeLabel: "Unlock Tuning",
inactiveLabel: "Lock Tuning",
});
const tx = update.status && update.status.tx ? update.status.tx : null;
txMeters.style.display = lastHasTx ? "" : "none";
@@ -3836,12 +3903,16 @@ function scheduleUiFrameJob(key, job) {
window.trxScheduleUiFrameJob = scheduleUiFrameJob;
async function postPath(path) {
async function postPath(path, options = {}) {
if (rigSwitchInProgress && !options.allowDuringRigSwitch) {
throw new Error("Wait for the rig switch to finish");
}
const targetRigId = options.remote === undefined ? lastActiveRigId : options.remote;
// Auto-append remote so each tab targets its own rig.
// Skip when the caller already included remote (e.g. /select_rig).
if (lastActiveRigId && !path.includes("remote=")) {
if (targetRigId && !path.includes("remote=")) {
const sep = path.includes("?") ? "&" : "?";
path = `${path}${sep}remote=${encodeURIComponent(lastActiveRigId)}`;
path = `${path}${sep}remote=${encodeURIComponent(targetRigId)}`;
}
const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) {
@@ -3886,44 +3957,60 @@ async function switchRigFromSelect(selectEl) {
return;
}
const prevRig = lastActiveRigId;
lastActiveRigId = selectEl.value;
if (prevRig && prevRig !== lastActiveRigId) {
const nextRig = selectEl.value;
if (nextRig === prevRig || rigSwitchInProgress) return;
rigSwitchInProgress = true;
setControlPending(selectEl, true);
selectEl.closest(".header-rig-switch")?.classList.add("is-switching");
updateRigIdentitySummary(nextRig, true);
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`);
try {
const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : "";
await postPath(`/select_rig?remote=${encodeURIComponent(nextRig)}${sidParam}`, { allowDuringRigSwitch: true, remote: null });
lastActiveRigId = nextRig;
resetDecoderStateOnRigSwitch();
}
updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId);
if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId);
if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || "");
window.trx.map?.syncAprsReceiverMarker();
// Switch this session's rig and reconnect SSE to the new rig's
// state channel.
try {
const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : "";
await postPath(`/select_rig?remote=${encodeURIComponent(selectEl.value)}${sidParam}`);
window.trx.modules.map?.syncAprsReceiverMarker();
connect();
} catch (err) {
console.error("select_rig failed:", err);
}
// Reconnect spectrum SSE to the new rig's spectrum channel.
stopSpectrumStreaming();
startSpectrumStreaming();
// Reconnect meter SSE to the new rig's meter channel.
stopMeterStreaming();
startMeterStreaming();
// Reconnect audio to the new rig if audio is active.
if (rxActive) {
stopRxAudio();
startRxAudio();
}
showHint(`Rig: ${lastActiveRigId}`, 1500);
showHint(`Rig: ${lastRigDisplayNames[lastActiveRigId] || lastActiveRigId}`, 1500);
} catch (err) {
console.error("select_rig failed:", err);
selectEl.value = prevRig || "";
updateRigIdentitySummary(prevRig);
window.trxUi?.notify("Rig could not be switched", { kind: "error" });
} finally {
rigSwitchInProgress = false;
setControlPending(selectEl, false);
selectEl.closest(".header-rig-switch")?.classList.remove("is-switching");
}
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.addEventListener("change", () => { switchRigFromSelect(headerRigSwitchSelect); });
}
function setControlPending(control, pending) {
if (!control) return;
control.disabled = pending;
control.classList.toggle("is-busy", pending);
control.setAttribute("aria-busy", String(pending));
}
powerBtn.addEventListener("click", async () => {
powerBtn.disabled = true;
setControlPending(powerBtn, true);
showHint("Sending...");
try {
await postPath("/toggle_power");
@@ -3932,12 +4019,12 @@ powerBtn.addEventListener("click", async () => {
showHint("Toggle failed", 2000);
console.error(err);
} finally {
powerBtn.disabled = false;
setControlPending(powerBtn, false);
}
});
pttBtn.addEventListener("click", async () => {
pttBtn.disabled = true;
setControlPending(pttBtn, true);
showHint("Toggling PTT…");
try {
const desired = lastTxEn ? "false" : "true";
@@ -3947,7 +4034,7 @@ pttBtn.addEventListener("click", async () => {
showHint("PTT toggle failed", 2000);
console.error(err);
} finally {
pttBtn.disabled = false;
setControlPending(pttBtn, false);
}
});
@@ -3980,7 +4067,7 @@ async function applyCenterFreqFromInput() {
return;
}
centerFreqDirty = false;
centerFreqEl.disabled = true;
setControlPending(centerFreqEl, true);
showHint("Setting central frequency…");
try {
await postPath(`/set_center_freq?hz=${parsed}`);
@@ -3989,7 +4076,7 @@ async function applyCenterFreqFromInput() {
showHint("Set central freq failed", 2000);
console.error(err);
} finally {
centerFreqEl.disabled = false;
setControlPending(centerFreqEl, false);
}
}
@@ -4190,7 +4277,7 @@ async function applyModeFromPicker() {
return;
}
updateWfmControls();
modeEl.disabled = true;
setControlPending(modeEl, true);
showHint("Setting mode…");
try {
if (typeof vchanInterceptMode === "function" && await vchanInterceptMode(mode)) {
@@ -4208,7 +4295,7 @@ async function applyModeFromPicker() {
showHint("Set mode failed", 2000);
console.error(err);
} finally {
modeEl.disabled = false;
setControlPending(modeEl, false);
}
}
@@ -4227,7 +4314,7 @@ txLimitBtn.addEventListener("click", async () => {
showHint("Limit missing", 1500);
return;
}
txLimitBtn.disabled = true;
setControlPending(txLimitBtn, true);
showHint("Setting TX limit…");
try {
await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`);
@@ -4236,22 +4323,22 @@ txLimitBtn.addEventListener("click", async () => {
showHint("TX limit failed", 2000);
console.error(err);
} finally {
txLimitBtn.disabled = false;
setControlPending(txLimitBtn, false);
}
});
lockBtn.addEventListener("click", async () => {
lockBtn.disabled = true;
setControlPending(lockBtn, true);
showHint("Toggling lock…");
try {
const nextLock = lockBtn.textContent === "Lock";
const nextLock = !lastLocked;
await postPath(nextLock ? "/lock" : "/unlock");
showHint("Lock toggled", 1500);
} catch (err) {
showHint("Lock toggle failed", 2000);
console.error(err);
} finally {
lockBtn.disabled = false;
setControlPending(lockBtn, false);
}
});
@@ -4268,7 +4355,7 @@ const MODE_BW_DEFAULTS = {
FM: [12_500, 2_500, 25_000, 500],
AIS: [25_000, 12_500, 50_000, 500],
VDES: [100_000, 25_000, 200_000, 1_000],
WFM: [180_000, 50_000,300_000,5_000],
WFM: [180_000, 60_000,300_000,5_000],
DIG: [3_000, 300, 6_000, 100],
PKT: [25_000, 300, 50_000, 500],
};
@@ -4318,7 +4405,8 @@ async function applyBwDefaultForMode(mode, sendToServer) {
scheduleSpectrumDraw();
}
if (sendToServer) {
try { await postPath(`/set_bandwidth?hz=${def}`); } catch (_) {}
try { await postPath(`/set_bandwidth?hz=${def}`); }
catch (error) { window.trxUi?.notify("Default bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: () => applyBwDefaultForMode(mode, true) } }); }
}
}
@@ -4345,67 +4433,115 @@ async function applyBandwidthFromInput() {
if (Number.isFinite(lastFreqHz)) {
await ensureTunedBandwidthCoverage(lastFreqHz);
}
} catch (_) {}
} catch (error) {
window.trxUi?.notify("Bandwidth could not be changed", { kind: "error", action: { label: "Retry", run: applyBandwidthFromInput } });
}
}
function estimateBandwidthAroundPeak(data, centerHz) {
function estimateOccupiedBandwidth(data, centerHz, interference = {}) {
if (!data || !isBinsArray(data.bins) || data.bins.length < 3 || !Number.isFinite(centerHz)) {
return null;
}
const bins = data.bins;
const maxIdx = bins.length - 1;
const hzPerBin = data.sample_rate / maxIdx;
const fullLoHz = data.center_hz - data.sample_rate / 2;
const centerIdx = Math.max(
1,
Math.min(maxIdx - 1, Math.round(((centerHz - fullLoHz) / data.sample_rate) * maxIdx)),
);
const searchRadius = Math.max(6, Math.min(120, Math.round(maxIdx * 0.03)));
const searchLo = Math.max(1, centerIdx - searchRadius);
const searchHi = Math.min(maxIdx - 1, centerIdx + searchRadius);
const mode = (modeEl ? modeEl.value : "USB").toUpperCase();
const [defaultBw, minBw, maxBw, stepBw] = mwDefaultsForMode(mode);
const oneSided = mode === "USB" || mode === "DIG" || mode === "CW"
? 1
: mode === "LSB" || mode === "CWR" ? -1 : 0;
const isWfm = mode === "WFM";
let peakIdx = centerIdx;
for (let i = searchLo; i <= searchHi; i++) {
if (bins[i] > bins[peakIdx]) peakIdx = i;
// Reduce single-bin peaks and holes before finding occupied-channel edges.
// WFM needs a wider smoothing window because its energy is noise-like and
// spread across the entire channel rather than concentrated at a carrier.
const smoothRadius = isWfm ? 3 : 1;
const smoothed = bins.map((_, i) => {
let sum = 0;
let count = 0;
for (let j = Math.max(0, i - smoothRadius); j <= Math.min(maxIdx, i + smoothRadius); j++) {
sum += bins[j];
count += 1;
}
return sum / count;
});
const sorted = [...bins].sort((a, b) => a - b);
const noise = sorted[Math.floor(sorted.length * 0.2)];
const peak = bins[peakIdx];
const threshold = Math.max(noise + 4, peak - Math.max(8, (peak - noise) * 0.35));
const maxSpanBins = Math.max(2, Math.ceil(maxBw / hzPerBin));
const searchHalfBins = oneSided === 0 ? Math.ceil(maxSpanBins / 2) : maxSpanBins;
const searchLo = Math.max(1, centerIdx - (oneSided > 0 ? 2 : searchHalfBins));
const searchHi = Math.min(maxIdx - 1, centerIdx + (oneSided < 0 ? 2 : searchHalfBins));
let peak = -Infinity;
for (let i = searchLo; i <= searchHi; i++) peak = Math.max(peak, smoothed[i]);
const snr = peak - noise;
if (!Number.isFinite(snr) || snr < (isWfm ? 5 : 4)) return isWfm ? minBw : defaultBw;
let left = peakIdx;
let right = peakIdx;
let belowCount = 0;
for (let i = peakIdx; i > 1; i--) {
if (bins[i] < threshold) belowCount += 1;
else belowCount = 0;
if (belowCount >= 2) break;
left = i;
// A threshold relative to the noise floor finds occupied bandwidth much
// more reliably than one relative to the peak. The latter fails for WFM,
// whose multiplex spectrum has peaks, notches, and no narrow centre carrier.
const threshold = noise + Math.max(3, Math.min(isWfm ? 6 : 10, snr * (isWfm ? 0.18 : 0.28)));
const allowedGap = Math.max(isWfm ? 4 : 2, Math.ceil((isWfm ? 12_000 : stepBw) / hzPerBin));
function occupiedExtent(direction, limitBins) {
let lastOccupied = centerIdx;
let gap = 0;
for (let n = 0; n <= limitBins; n++) {
const i = centerIdx + direction * n;
if (i <= 0 || i >= maxIdx) break;
if (smoothed[i] >= threshold) {
lastOccupied = i;
gap = 0;
} else if (++gap > allowedGap) {
break;
}
}
return Math.abs(lastOccupied - centerIdx) * hzPerBin;
}
belowCount = 0;
for (let i = peakIdx; i < maxIdx - 1; i++) {
if (bins[i] < threshold) belowCount += 1;
else belowCount = 0;
if (belowCount >= 2) break;
right = i;
let rawBw;
if (oneSided !== 0) {
rawBw = occupiedExtent(oneSided, maxSpanBins);
} else {
const leftHz = occupiedExtent(-1, searchHalfBins);
const rightHz = occupiedExtent(1, searchHalfBins);
// A symmetric RF filter must contain the larger of the two sidebands.
rawBw = 2 * Math.max(leftHz, rightHz);
}
const shoulderPad = Math.max(1, Math.round((right - left) * 0.08));
left = Math.max(0, left - shoulderPad);
right = Math.min(maxIdx, right + shoulderPad);
const hzPerBin = data.sample_rate / maxIdx;
const rawBw = Math.max(hzPerBin, (right - left) * hzPerBin);
const [, minBw, maxBw, stepBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB");
// Add a transition-band margin. Weak WFM deliberately falls back to the
// 60 kHz mode floor above: a narrower filter trades stereo/RDS content for
// a useful improvement in intelligibility when the signal is very poor.
rawBw *= isWfm ? 1.08 : 1.12;
if (isWfm) {
const aci = Math.max(0, Math.min(100, Number(interference.aci) || 0)) / 100;
const cci = Math.max(0, Math.min(100, Number(interference.cci) || 0)) / 100;
// Adjacent-channel energy is outside the wanted modulation, so ACI can
// safely drive the cap all the way from the 300 kHz ceiling to 60 kHz.
const aciCap = maxBw - (maxBw - minBw) * aci;
// CCI overlaps the wanted station and cannot be removed by an RF filter.
// Only distrust the widest edge estimates, retaining at least 65% of the
// useful range between the weak-signal floor and nominal WFM bandwidth.
const cciFloor = minBw + (defaultBw - minBw) * 0.65;
const cciCap = maxBw - (maxBw - cciFloor) * cci;
rawBw = Math.min(rawBw, aciCap, cciCap);
}
const clamped = Math.max(minBw, Math.min(maxBw, rawBw));
return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw);
}
async function applyAutoBandwidth() {
if (!lastSpectrumData || lastFreqHz == null) return;
const estimated = estimateBandwidthAroundPeak(lastSpectrumData, lastFreqHz);
// WFM interference telemetry belongs to the primary DSP channel. Do not
// apply it to a virtual channel, where it would describe the wrong signal.
const onVirtual = typeof vchanIsOnVirtual === "function" && vchanIsOnVirtual();
const interference = onVirtual ? {} : { cci: lastWfmCci, aci: lastWfmAci };
const estimated = estimateOccupiedBandwidth(lastSpectrumData, lastFreqHz, interference);
if (!Number.isFinite(estimated) || estimated <= 0) {
syncBandwidthInput(currentBandwidthHz);
return;
@@ -4417,13 +4553,24 @@ async function applyAutoBandwidth() {
if (lastSpectrumData) {
scheduleSpectrumDraw();
}
const mode = (modeEl?.value || "").toUpperCase();
let reason = "measured occupied spectrum";
if (mode === "WFM") {
if (estimated === 60_000 && lastWfmAci >= 20) reason = `high adjacent-channel interference (${Math.round(lastWfmAci)}% ACI)`;
else if (estimated === 60_000) reason = "weak-signal noise rejection";
else if (lastWfmAci >= lastWfmCci && lastWfmAci >= 10) reason = `${Math.round(lastWfmAci)}% ACI cap`;
else if (lastWfmCci >= 10) reason = `${Math.round(lastWfmCci)}% CCI confidence cap`;
}
window.trxUi?.notify(`Auto BW: ${formatBwLabel(estimated)}${reason}`, { kind: "success", duration: 5000 });
try {
if (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(estimated)) return;
await postPath(`/set_bandwidth?hz=${estimated}`);
if (Number.isFinite(lastFreqHz)) {
await ensureTunedBandwidthCoverage(lastFreqHz);
}
} catch (_) {}
} catch (error) {
window.trxUi?.notify("Automatic bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: applyAutoBandwidth } });
}
}
if (spectrumBwInput) {
@@ -4480,23 +4627,23 @@ function updateTabHistory(name, replaceHistory = false) {
}
// Initialise the Leaflet map, waiting for both Leaflet (L) and map-core.js
// (window.trx.map) if they haven't loaded yet.
// (window.trx.modules.map) if they haven't loaded yet.
let _mapInitTimer = null;
function _initMapWhenReady() {
const loadingEl = document.getElementById("map-loading");
if (window.trx.map && typeof L !== "undefined") {
if (window.trx.modules.map && typeof L !== "undefined") {
if (_mapInitTimer) { clearInterval(_mapInitTimer); _mapInitTimer = null; }
if (loadingEl) loadingEl.classList.add("is-hidden");
window.trx.map.initAprsMap();
window.trx.map.sizeAprsMapToViewport();
window.trx.modules.map.initAprsMap();
window.trx.modules.map.sizeAprsMapToViewport();
// The map panel was just made visible (display:none → ""); the browser
// may not have laid it out yet, so getBoundingClientRect() can return
// stale/zero dimensions. Double-rAF ensures a full layout pass has
// completed before we re-measure and tell Leaflet about its real size.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.trx.map.sizeAprsMapToViewport();
if (window.trx.map.aprsMap) window.trx.map.aprsMap.invalidateSize();
window.trx.modules.map.sizeAprsMapToViewport();
if (window.trx.modules.map.aprsMap) window.trx.modules.map.aprsMap.invalidateSize();
});
});
return;
@@ -4512,6 +4659,7 @@ function _initMapWhenReady() {
}
function navigateToTab(name, options = {}) {
window.trxUi?.closeMobileOverlays?.();
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
showAuthGate(false);
@@ -4522,6 +4670,7 @@ function navigateToTab(name, options = {}) {
_activeTab = name;
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn);
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const panel = document.getElementById(`tab-${name}`);
panel.style.display = "";
@@ -4544,12 +4693,13 @@ function navigateToTab(name, options = {}) {
_initMapWhenReady();
}
if (name === "statistics") {
window.trx.map?.scheduleStatsRender();
window.trx.modules.map?.scheduleStatsRender();
}
if (name === "recorder") {
refreshRecorderStatus();
}
}
window.navigateToTab = navigateToTab;
document.querySelector(".tab-bar").addEventListener("click", (e) => {
const btn = e.target.closest(".tab[data-tab]");
@@ -4708,7 +4858,7 @@ if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => {
if (authRole) {
// Logged in - show logout confirmation
if (confirm("Are you sure you want to logout?")) {
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
await authLogout();
}
} else {
@@ -4721,10 +4871,11 @@ if (headerAuthBtn) {
// ── Shared namespace for lazy-loaded modules ────────────────────────────────
// Modules (map-core.js, screenshot.js) access core state and utilities via
// window.trx. Modules register their own APIs as sub-namespaces
// (e.g. window.trx.map, window.trx.screenshot).
window.trx = Object.create(null);
// (e.g. window.trx.modules.map, window.trx.modules.screenshot).
const trxState = Object.create(null);
const trxModules = Object.create(null);
// -- State getters (backed by core-scoped variables) --
Object.defineProperties(window.trx, {
Object.defineProperties(trxState, {
serverLat: { get() { return serverLat; }, set(v) { serverLat = v; } },
serverLon: { get() { return serverLon; }, set(v) { serverLon = v; } },
lastFreqHz: { get() { return lastFreqHz; } },
@@ -4762,7 +4913,7 @@ Object.defineProperties(window.trx, {
signalOverlayGl: { get() { return signalOverlayGl; } },
});
// -- Shared utility functions --
Object.assign(window.trx, {
const trxCore = Object.freeze({
saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans,
formatWavelength, formatBwLabel, formatUptime, formatSigStrength, formatSignal,
postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor,
@@ -4772,18 +4923,19 @@ Object.assign(window.trx, {
currentTheme, canvasPalette, currentStyle,
cssColorToRgba, rgbaWithAlpha, isBinsArray, estimateNoiseFloorDb,
spectrumVisibleRange, drawSpectrum,
bandForHz: function(hz) { return window.trx.map?.bandForHz?.(hz); },
bandForHz: function(hz) { return trxModules.map?.bandForHz?.(hz); },
markDecodeMapSyncPending,
decodeHistoryMapRenderingDeferred,
updateDocumentTitle,
activeChannelRds,
});
Object.defineProperties(window.trx, {
Object.defineProperties(trxState, {
decodeHistoryReplayActive: { get() { return decodeHistoryReplayActive; } },
decodeMapSyncPending: { get() { return decodeMapSyncPending; } },
_activeTab: { get() { return _activeTab; } },
locationSubtitle: { get() { return locationSubtitle; } },
});
window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
// Load plugin scripts now that window.trx is populated. Dynamic scripts are
// async so they must not be created before the namespace they depend on exists.
@@ -4797,7 +4949,7 @@ window.addEventListener("resize", resizeHeaderSignalCanvas);
// ── Map module (extracted to map-core.js, lazy-loaded) ──────────────────────
// The map, statistics, and geolocation code (~3,450 lines) has been moved to
// map-core.js and is loaded on demand when the Map tab is first activated.
// Core communicates with the map module via window.trx.map.* namespace.
// Core communicates with the map module via window.trx.modules.map.* namespace.
// ── Geo utilities (shared with map-core.js via window.trx) ─────────────────
function haversineKm(lat1, lon1, lat2, lon2) {
@@ -4907,11 +5059,15 @@ function latLonToMaidenhead(lat, lon) {
function _wireSubTabBar(bar) {
if (bar._subtabWired) return;
bar._subtabWired = true;
window.trxUi?.prepareTabList(bar, "secondary");
bar.addEventListener("click", (e) => {
const btn = e.target.closest(".sub-tab[data-subtab]");
if (!btn) return;
bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
window.trxUi?.syncSelectedTab(bar, btn);
const decoderPicker = document.getElementById("decoder-tab-select");
if (decoderPicker && btn.closest("#tab-digital-modes")) decoderPicker.value = btn.dataset.subtab;
const parent = bar.parentElement;
parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none");
const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`);
@@ -4932,7 +5088,7 @@ document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
window.addEventListener("resize", () => {
const mapTab = document.getElementById("tab-map");
if (!mapTab || mapTab.style.display === "none") return;
window.trx.map?.sizeAprsMapToViewport();
window.trx.modules.map?.sizeAprsMapToViewport();
});
// --- Signal measurement ---
@@ -5402,6 +5558,7 @@ function configureRxStream(nextInfo) {
ensureRxAudioContext(nextSampleRate);
rxGainNode.gain.value = rxVolSlider.value / 100;
rxActive = true;
window.trxUi?.setButtonState(rxAudioBtn, { active: true, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
setAudioLevel(0);
rxAudioBtn.style.borderColor = "#00d17f";
rxAudioBtn.style.color = "#00d17f";
@@ -5603,6 +5760,7 @@ function startRxAudio() {
// If TX was active when WS closed, release PTT
if (txActive) { stopTxAudio(); }
rxActive = false;
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
streamInfo = null;
updateWfmControls();
rxAudioBtn.style.borderColor = "";
@@ -5629,6 +5787,7 @@ function startRxAudio() {
function stopRxAudio() {
rxActive = false;
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
streamInfo = null;
if (audioWs) { audioWs.close(); audioWs = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; }
@@ -5667,6 +5826,7 @@ function startTxAudio() {
}).then(async (stream) => {
txStream = stream;
txActive = true;
window.trxUi?.setButtonState(txAudioBtn, { active: true, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" });
txAudioBtn.style.borderColor = "#e55353";
txAudioBtn.style.color = "#e55353";
audioStatus.textContent = "RX+TX";
@@ -5744,6 +5904,7 @@ function startTxAudio() {
async function stopTxAudio() {
if (!txActive) return;
txActive = false;
window.trxUi?.setButtonState(txAudioBtn, { active: false, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" });
clearTxTimeout();
// Release PTT automatically
@@ -5989,7 +6150,7 @@ function renderRecorderFiles() {
el.querySelectorAll(".rec-delete-btn").forEach(function (btn) {
btn.addEventListener("click", async function () {
const name = btn.dataset.name;
if (!confirm("Delete recording " + name + "?")) return;
if (!await window.trxUi.confirm({ title: "Delete recording?", message: `${name} will be permanently removed.`, confirmLabel: "Delete" })) return;
try {
const resp = await fetch("/api/recorder/files/" + encodeURIComponent(name), { method: "DELETE" });
if (!resp.ok) throw new Error("HTTP " + resp.status);
@@ -5997,6 +6158,7 @@ function renderRecorderFiles() {
renderRecorderFiles();
} catch (e) {
console.error("Delete failed", e);
window.trxUi?.notify("Recording could not be deleted", { kind: "error" });
}
});
});
@@ -6102,8 +6264,8 @@ function dispatchDecodeMessage(msg, skipStats) {
if (msg.type === "wefax" && window.onServerWefax) window.onServerWefax(msg);
if (msg.type === "wefax_progress" && window.onServerWefaxProgress) window.onServerWefaxProgress(msg);
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
window.trx.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
window.trx.map?.scheduleStatsRender();
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
window.trx.modules.map?.scheduleStatsRender();
}
}
@@ -6112,10 +6274,10 @@ function dispatchDecodeBatch(batch) {
// Record statistics for every message in the batch regardless of dispatch path.
for (const msg of batch) {
if (msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
window.trx.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
}
}
window.trx.map?.scheduleStatsRender();
window.trx.modules.map?.scheduleStatsRender();
const type = String(batch[0]?.type || "");
const uniformType = batch.every((msg) => String(msg?.type || "") === type);
if (uniformType) {
@@ -6200,9 +6362,9 @@ function restoreDecodeHistoryGroup(kind, messages) {
// Record statistics for restored history messages.
if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
for (const msg of messages) {
window.trx.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
}
window.trx.map?.scheduleStatsRender();
window.trx.modules.map?.scheduleStatsRender();
}
if (kind === "ais") {
if (window.restoreAisHistory) { window.restoreAisHistory(messages); }
@@ -6933,6 +7095,13 @@ function startSpectrumStreaming() {
const rds = lastSpectrumData?.rds;
lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds };
window.lastSpectrumData = lastSpectrumData;
const spectrumSummary = document.getElementById("spectrum-text-summary");
if (spectrumSummary && bins.length) {
let peakIndex = 0;
for (let i = 1; i < bins.length; i += 1) if (bins[i] > bins[peakIndex]) peakIndex = i;
const peakHz = centerHz - sampleRate / 2 + (peakIndex / Math.max(1, bins.length - 1)) * sampleRate;
spectrumSummary.textContent = `Spectrum centered at ${formatFreqForHumans(centerHz)}, spanning ${formatFreqForHumans(sampleRate)}. Strongest visible bin near ${formatFreqForHumans(peakHz)} at ${bins[peakIndex]} dB.`;
}
// Server confirmed a new center — clear optimistic pending value.
if (spectrumCenterPendingHz !== null && Math.abs(centerHz - spectrumCenterPendingHz) < 1000) {
spectrumCenterPendingHz = null;
@@ -8000,12 +8169,12 @@ window.addEventListener("keydown", (event) => {
// S — spectrum screenshot (lazy-loads screenshot.js on first use)
if (key === "s") {
event.preventDefault();
if (window.trx.screenshot) {
void window.trx.screenshot.captureSpectrumScreenshot();
if (window.trx.modules.screenshot) {
void window.trx.modules.screenshot.captureSpectrumScreenshot();
} else {
const s = document.createElement("script");
s.src = "/screenshot.js";
s.onload = () => { void window.trx.screenshot?.captureSpectrumScreenshot(); };
s.onload = () => { void window.trx.modules.screenshot?.captureSpectrumScreenshot(); };
document.body.appendChild(s);
}
return;
@@ -8395,7 +8564,12 @@ if (spectrumCanvas || overviewCanvas) {
await ensureTunedBandwidthCoverage(lastFreqHz, currentBandwidthHz);
}
}
} catch (_) {}
} catch (error) {
window.trxUi?.notify("Bandwidth could not be changed", {
kind: "error",
action: { label: "Retry", run: () => postPath(`/set_bandwidth?hz=${Math.round(currentBandwidthHz)}`) },
});
}
_bwDragEdge = null;
_bwDragCanvas = null;
return;
@@ -43,7 +43,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="subtitle" id="location-subtitle" style="display:none;"></div>
</div>
</div>
<div class="tab-bar-nav">
<div class="tab-bar-nav" aria-label="Primary navigation">
<button class="tab active" data-tab="main">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-home"/></svg>
<span class="tab-label">Main</span>
@@ -85,6 +85,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="header-rec-btn" class="header-bar-btn header-rec-btn" type="button" aria-label="Toggle recording" title="Toggle recording">REC</button>
<div class="header-rig-switch">
<select id="header-rig-switch-select" aria-label="Select active rig"></select>
<span id="header-rig-summary" class="header-rig-summary" aria-live="polite"></span>
</div>
<div class="header-style-pick">
<select id="header-style-pick-select" aria-label="Select UI style">
@@ -140,13 +141,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="spectrum-wrap">
<div id="spectrum-bookmark-axis"></div>
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div>
<canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display"></canvas>
<canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display" aria-describedby="spectrum-text-summary"></canvas>
<p id="spectrum-text-summary" class="visually-hidden">Spectrum data is waiting for the receiver.</p>
<div id="spectrum-zoom-indicator" aria-hidden="true"></div>
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div>
<div id="spectrum-db-axis" aria-hidden="true"></div>
<div id="spectrum-bookmark-side-right" class="spectrum-bookmark-side spectrum-bookmark-side-right" aria-hidden="true"></div>
<div id="spectrum-tooltip"></div>
<canvas id="spectrum-waterfall-canvas" tabindex="0" role="img" aria-label="Waterfall display" style="display:none;"></canvas>
<canvas id="spectrum-waterfall-canvas" tabindex="0" role="img" aria-label="Waterfall display" aria-describedby="spectrum-text-summary" style="display:none;"></canvas>
<div id="spectrum-freq-axis">
<button id="spectrum-center-left-btn" class="spectrum-edge-shift spectrum-edge-shift-left" type="button" aria-label="Shift spectrum center left">&lsaquo;</button>
<button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">&rsaquo;</button>
@@ -204,12 +206,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="label"><span>Signal strength</span></div>
</div>
<div class="freq-field frequency-col">
<input class="status-input" id="freq" type="text" value="--" />
<div class="label"><span>Frequency</span></div>
<input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" />
<div class="label" id="freq-label"><span>Frequency</span></div>
</div>
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;">
<input class="status-input" id="center-freq" type="text" value="--" />
<div class="label"><span>Center Frequency</span></div>
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" />
<div class="label" id="center-freq-label"><span>Center Frequency</span></div>
</div>
<div class="freq-field unit-col">
<div class="jog-step" id="jog-step">
@@ -235,7 +237,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="controls-col label-below-col">
<div class="label"><span>Mode</span></div>
<div class="inline">
<select class="status-input" id="mode"></select>
<select class="status-input" id="mode" aria-label="Demodulation mode"></select>
</div>
</div>
<div class="controls-col controls-col-center">
@@ -307,9 +309,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
<div class="label"><span>Transmit / Power</span></div>
<div class="btn-grid">
<button id="ptt-btn" type="button">Toggle PTT</button>
<button id="power-btn" type="button">Toggle Power</button>
<button id="lock-btn" type="button">Lock</button>
<button id="ptt-btn" type="button" aria-pressed="false">Start TX</button>
<button id="power-btn" type="button" aria-pressed="false">Power On</button>
<button id="lock-btn" type="button" aria-pressed="false">Lock Tuning</button>
</div>
</div>
</div>
@@ -495,6 +497,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</label>
</div>
<div class="bm-form-actions">
<div id="bm-form-error" class="form-error" role="alert" aria-live="polite"></div>
<button type="submit" class="bm-save-btn">Save</button>
<button type="button" id="bm-form-cancel">Cancel</button>
</div>
@@ -530,7 +533,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
<div id="tab-digital-modes" class="tab-panel" style="display:none;">
<div class="sub-tab-bar">
<div class="sub-tab-bar" aria-label="Decoder views">
<button class="sub-tab active" data-subtab="overview">Overview</button>
<button class="sub-tab" data-subtab="ais">AIS</button>
<button class="sub-tab" data-subtab="vdes">VDES</button>
@@ -1623,10 +1626,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
</div>
</div>
<script defer src="https://cdn.jsdelivr.net/npm/opus-decoder@0.7.11/dist/opus-decoder.min.js" charset="UTF-8"></script>
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
<script defer src="/vendor/leaflet.js"></script>
<script defer src="/leaflet-ais-tracksymbol.js"></script>
<script defer src="/webgl-renderer.js"></script>
<script defer src="/ui-core.js"></script>
<script defer src="/app.js"></script>
<script>
// Lazy plugin loader: loads plugin scripts when their tab/feature is first activated
@@ -3,10 +3,10 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Map, statistics, and geolocation module (lazy-loaded on map tab activation).
// Communicates with app.js core via window.trx namespace.
// Communicates with app.js through explicit state/core/module services.
(function () {
"use strict";
const T = window.trx;
const { state: T, core: C, modules } = window.trx;
// Destructure shared utility functions for convenience
const { saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans,
@@ -14,7 +14,7 @@
formatUptime, latLonToMaidenhead, locatorToLatLon, haversineKm,
formatDistanceKm, formatTimeAgo, currentDecodeHistoryRetentionMs,
formatWavelength, bookmarkDistanceText, buildBookmarkTooltipText,
nearestBookmarkForHz } = T;
nearestBookmarkForHz } = C;
function updateMapRigFilter() {
const el = document.getElementById("map-rig-filter");
@@ -261,7 +261,7 @@
if (canRenderMap) {
refreshAprsTrack(call, entry);
} else {
T.markDecodeMapSyncPending();
C.markDecodeMapSyncPending();
}
if (!visible) {
if (canRenderMap && selectedAprsTrackCall && String(selectedAprsTrackCall) === String(call)) {
@@ -294,7 +294,7 @@
if (canRenderMap) {
refreshAisTrack(key, entry);
} else {
T.markDecodeMapSyncPending();
C.markDecodeMapSyncPending();
}
if (!visible) {
if (canRenderMap && selectedAisTrackMmsi && String(selectedAisTrackMmsi) === String(key)) {
@@ -337,7 +337,7 @@
entry.stations = new Set();
entry.bandMeta = new Map();
if (canRenderMap) setRetainedMapMarkerVisible(entry.marker, false);
else T.markDecodeMapSyncPending();
else C.markDecodeMapSyncPending();
return false;
}
const nextStations = new Set();
@@ -352,7 +352,7 @@
);
const count = Math.max(nextDetails.size, nextStations.size || 0, 1);
if (!canRenderMap) {
T.markDecodeMapSyncPending();
C.markDecodeMapSyncPending();
return true;
}
ensureDecodeLocatorMarker(entry);
@@ -392,7 +392,7 @@
pruneLocatorEntry(key, entry, cutoffMs);
}
if (!aprsMap || T.decodeHistoryReplayActive) {
T.markDecodeMapSyncPending();
C.markDecodeMapSyncPending();
return;
}
rebuildDecodeContactPaths();
@@ -415,7 +415,7 @@
function locatorFilterColor(type) {
const hues = locatorThemeHues();
const lightTheme = T.currentTheme() === "light";
const lightTheme = C.currentTheme() === "light";
const sat = lightTheme ? 66 : 76;
const light = lightTheme ? 42 : 56;
const hue = type === "bookmark"
@@ -539,7 +539,7 @@
}
function locatorThemeHues() {
const pal = T.canvasPalette();
const pal = C.canvasPalette();
const baseHue = paletteHue(pal?.spectrumLine, 145);
const waveHue = paletteHue(pal?.waveformLine, baseHue + 34);
const peakHue = paletteHue(pal?.waveformPeak, baseHue - 42);
@@ -560,7 +560,7 @@
function locatorBandChipColor(label) {
const hues = locatorThemeHues();
const lightTheme = T.currentTheme() === "light";
const lightTheme = C.currentTheme() === "light";
const hue = wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508);
const sat = lightTheme ? 68 : 78;
const light = lightTheme ? 44 : 58;
@@ -606,7 +606,7 @@
const safeCount = Math.max(1, Number.isFinite(count) ? count : 1);
const intensity = Math.min(1, Math.log2(safeCount + 1) / 5);
const hue = locatorHueForEntry(entry);
const lightTheme = T.currentTheme() === "light";
const lightTheme = C.currentTheme() === "light";
const strokeSat = lightTheme ? 62 : 74;
const fillSat = lightTheme ? 68 : 78;
const strokeLight = lightTheme ? 40 : 56;
@@ -1573,7 +1573,7 @@
stageResizeObserver = new ResizeObserver(() => sizeAprsMapToViewport());
stageResizeObserver.observe(stage);
}
updateMapBaseLayerForTheme(T.currentTheme());
updateMapBaseLayerForTheme(C.currentTheme());
syncAprsReceiverMarker();
// Rebuild popup content on open (keeps age/distance/rig list fresh)
@@ -2307,18 +2307,18 @@
existing.rigIds.add(msgRigId);
}
if (!visible) {
if (!T.decodeHistoryMapRenderingDeferred()) {
if (!C.decodeHistoryMapRenderingDeferred()) {
setRetainedMapMarkerVisible(existing.marker, false);
} else {
T.markDecodeMapSyncPending();
C.markDecodeMapSyncPending();
}
return;
}
if (!T.decodeHistoryMapRenderingDeferred()) {
if (!C.decodeHistoryMapRenderingDeferred()) {
ensureVdesMarker(key, existing);
setRetainedMapMarkerVisible(existing.marker, true);
} else {
T.markDecodeMapSyncPending();
C.markDecodeMapSyncPending();
}
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
existing.marker.setLatLng([msg.lat, msg.lon]);
@@ -2334,11 +2334,11 @@
};
vdesMarkers.set(key, entry);
if (!visible) return;
if (!T.decodeHistoryMapRenderingDeferred()) {
if (!C.decodeHistoryMapRenderingDeferred()) {
ensureVdesMarker(key, entry);
setRetainedMapMarkerVisible(entry.marker, true);
} else {
T.markDecodeMapSyncPending();
C.markDecodeMapSyncPending();
}
if (aprsMap && entry.marker && !T.decodeHistoryReplayActive) {
entry.marker.setPopupContent(popupHtml);
@@ -2365,7 +2365,7 @@
if (T.locationSubtitle) {
T.locationSubtitle.textContent = `Location: ${grid} · ${label}`;
}
T.updateDocumentTitle(T.activeChannelRds());
C.updateDocumentTitle(C.activeChannelRds());
})
.catch(() => {});
}
@@ -2443,8 +2443,8 @@
}
function scheduleDecodeMapMaintenance() {
if (T.decodeHistoryMapRenderingDeferred()) {
T.markDecodeMapSyncPending();
if (C.decodeHistoryMapRenderingDeferred()) {
C.markDecodeMapSyncPending();
return;
}
scheduleUiFrameJob("decode-map-maintenance", () => {
@@ -3461,7 +3461,7 @@
}
// Register module API for core to call
window.trx.map = {
modules.map = {
initAprsMap,
sizeAprsMapToViewport,
syncAprsReceiverMarker,
@@ -382,7 +382,7 @@ window.pruneAisHistoryView = function() {
};
document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => {
if (!confirm("Clear all AIS decode history? This cannot be undone.")) return;
if (!await window.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_ais_decode");
window.resetAisHistoryView();
@@ -442,7 +442,7 @@ window.restoreAprsHistory = function(packets) {
};
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => {
if (!confirm("Clear all APRS decode history? This cannot be undone.")) return;
if (!await window.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_aprs_decode");
window.resetAprsHistoryView();
@@ -215,10 +215,10 @@
});
}
function resetBackgroundDecode() {
async function resetBackgroundDecode() {
const rigId = currentRigId;
if (!rigId) return;
if (!confirm("Reset background decode configuration? This cannot be undone.")) return;
if (!await window.trxUi.confirm({ title: "Reset background decoding?", message: "The current background decode configuration will be permanently reset.", confirmLabel: "Reset" })) return;
apiResetConfig(rigId)
.then(function (saved) {
currentConfig = saved;
@@ -331,8 +331,13 @@ async function bmSave(e) {
const comment = document.getElementById("bm-comment").value.trim();
const decoders = bmReadDecoders();
const formError = document.getElementById("bm-form-error");
if (formError) formError.textContent = "";
if (!name || !Number.isFinite(freq_hz) || !mode) {
alert("Name, Frequency, and Mode are required.");
if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode.";
const invalid = !name ? document.getElementById("bm-name")
: !Number.isFinite(freq_hz) ? document.getElementById("bm-freq") : document.getElementById("bm-mode");
invalid?.focus();
return;
}
@@ -373,12 +378,13 @@ async function bmSave(e) {
await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) {
console.error("Failed to save bookmark:", err);
alert("Failed to save bookmark: " + err.message);
if (formError) formError.textContent = "Failed to save bookmark: " + err.message;
window.trxUi?.notify("Bookmark could not be saved", { kind: "error" });
}
}
async function bmDelete(id) {
if (!confirm("Delete this bookmark?")) return;
if (!await window.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return;
const bm = bmList.find((b) => b.id === id);
const scope = bm ? bm.scope : undefined;
try {
@@ -389,7 +395,7 @@ async function bmDelete(id) {
await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) {
console.error("Failed to delete bookmark:", err);
alert("Failed to delete bookmark: " + err.message);
window.trxUi?.notify("Failed to delete bookmark: " + err.message, { kind: "error" });
}
}
@@ -555,7 +561,12 @@ async function bmMoveSelected() {
const target = document.getElementById("bm-move-target")?.value;
if (!target) return;
const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
if (!confirm(`Move ${ids.length} bookmark${ids.length > 1 ? "s" : ""} to "${targetLabel}"?`)) return;
if (!await window.trxUi.confirm({
title: "Move selected bookmarks?",
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
confirmLabel: "Move",
danger: false,
})) return;
try {
// Group selected IDs by their owning scope (skip if already in target).
const byScope = {};
@@ -577,7 +588,7 @@ async function bmMoveSelected() {
await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) {
console.error("Failed to move bookmarks:", err);
alert("Failed to move bookmarks: " + err.message);
window.trxUi?.notify("Failed to move bookmarks: " + err.message, { kind: "error" });
}
}
@@ -598,7 +609,11 @@ function bmSyncSelectAllCheckbox() {
async function bmDeleteSelected() {
const ids = Array.from(bmSelected);
if (ids.length === 0) return;
if (!confirm(`Delete ${ids.length} selected bookmark${ids.length > 1 ? "s" : ""}?`)) return;
if (!await window.trxUi.confirm({
title: "Delete selected bookmarks?",
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
confirmLabel: "Delete",
})) return;
try {
// Group selected IDs by their owning scope.
const byScope = {};
@@ -619,7 +634,7 @@ async function bmDeleteSelected() {
await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) {
console.error("Failed to delete bookmarks:", err);
alert("Failed to delete bookmarks: " + err.message);
window.trxUi?.notify("Failed to delete bookmarks: " + err.message, { kind: "error" });
}
}
@@ -364,7 +364,7 @@ window.resetCwHistoryView = function() {
};
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
if (!confirm("Clear all CW decode history? This cannot be undone.")) return;
if (!await window.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_cw_decode");
window.resetCwHistoryView();
@@ -190,7 +190,7 @@ ft2DecodeToggleBtn?.addEventListener("click", async () => {
});
document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => {
if (!confirm("Clear all FT2 decode history? This cannot be undone.")) return;
if (!await window.trxUi.confirm({ title: "Clear FT2 history?", message: "All stored FT2 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_ft2_decode");
window.resetFt2HistoryView();
@@ -190,7 +190,7 @@ ft4DecodeToggleBtn?.addEventListener("click", async () => {
});
document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => {
if (!confirm("Clear all FT4 decode history? This cannot be undone.")) return;
if (!await window.trxUi.confirm({ title: "Clear FT4 history?", message: "All stored FT4 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_ft4_decode");
window.resetFt4HistoryView();
@@ -462,7 +462,7 @@ ft8DecodeToggleBtn?.addEventListener("click", async () => {
});
document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => {
if (!confirm("Clear all FT8 decode history? This cannot be undone.")) return;
if (!await window.trxUi.confirm({ title: "Clear FT8 history?", message: "All stored FT8 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_ft8_decode");
window.resetFt8HistoryView();
@@ -388,7 +388,7 @@ hfAprsDecodeToggleBtn?.addEventListener("click", async () => {
});
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => {
if (!confirm("Clear all HF APRS decode history? This cannot be undone.")) return;
if (!await window.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_hf_aprs_decode");
window.resetHfAprsHistoryView();
@@ -250,9 +250,9 @@
var noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
var bmId = dom.bookmark ? dom.bookmark.value : "";
if (!satellite) { alert("Please enter a satellite name."); return; }
if (isNaN(noradId) || noradId <= 0) { alert("Please enter a valid NORAD catalog number."); return; }
if (!bmId) { alert("Please select a bookmark."); return; }
if (!satellite) { window.trxUi?.notify("Enter a satellite name.", { kind: "error" }); document.getElementById("scheduler-sat-name")?.focus(); return; }
if (isNaN(noradId) || noradId <= 0) { window.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" }); document.getElementById("scheduler-sat-norad")?.focus(); return; }
if (!bmId) { window.trxUi?.notify("Select a bookmark.", { kind: "error" }); document.getElementById("scheduler-sat-bookmark")?.focus(); return; }
var minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
var prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
@@ -295,7 +295,7 @@ satDom.typeFilter?.addEventListener("change", () => renderSatHistoryTable());
document
.getElementById("settings-clear-sat-history")
?.addEventListener("click", async () => {
if (!confirm("Clear all satellite decode history? This cannot be undone.")) return;
if (!await window.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_lrpt_decode");
window.resetSatHistoryView();
@@ -599,7 +599,7 @@
const bmId = bmEl.value;
if (!bmId) {
alert("Please select a primary bookmark.");
window.trxUi?.notify("Select a primary bookmark before saving.", { kind: "error" });
return;
}
@@ -887,7 +887,7 @@
var recEl = tr.querySelector('[data-field="record"]');
var exEl = tr.querySelector('[data-field="exclusive"]');
if (bmEl && !bmEl.value) { alert('Please select a bookmark.'); return; }
if (bmEl && !bmEl.value) { window.trxUi?.notify("Select a bookmark before saving.", { kind: "error" }); bmEl.focus(); return; }
entry.start_min = hhmmToMin(startEl.value);
entry.end_min = hhmmToMin(endEl.value);
@@ -1236,10 +1236,10 @@
return el ? el.value : "";
}
function resetScheduler() {
async function resetScheduler() {
const rig = currentRigId;
if (!rig) return;
if (!confirm("Reset scheduler for this rig to Disabled?")) return;
if (!await window.trxUi.confirm({ title: "Reset scheduler?", message: "This rig's scheduler configuration will be reset to Disabled.", confirmLabel: "Reset" })) return;
apiDeleteScheduler(rig)
.then(function () {
@@ -317,7 +317,7 @@ window.restoreVdesHistory = function(messages) {
};
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => {
if (!confirm("Clear all VDES decode history? This cannot be undone.")) return;
if (!await window.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_vdes_decode");
window.resetVdesHistoryView();
@@ -270,7 +270,7 @@ wsprDecodeToggleBtn?.addEventListener("click", async () => {
});
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => {
if (!confirm("Clear all WSPR decode history? This cannot be undone.")) return;
if (!await window.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_wspr_decode");
window.resetWsprHistoryView();
@@ -257,7 +257,7 @@
}
// Register module API
window.trx.screenshot = {
window.trx.modules.screenshot = {
captureSpectrumScreenshot,
buildSpectrumSnapshotCanvas,
saveCanvasAsPng,
@@ -1231,7 +1231,7 @@ small { color: var(--text-muted); }
}
.top-bar-actions {
display: flex;
align-items: center;
align-items: flex-start;
gap: 0.45rem 0.6rem;
min-width: 0;
flex-wrap: wrap;
@@ -1404,10 +1404,21 @@ small { color: var(--text-muted); }
padding: 0.9rem 0.2rem 0;
}
.header-rig-switch {
display: flex;
align-items: center;
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: 0.35rem;
}
.header-rig-summary {
display: block;
max-width: 20rem;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-rig-switch select {
min-width: 8rem;
height: 2rem;
@@ -1417,6 +1428,7 @@ small { color: var(--text-muted); }
background: var(--input-bg);
color: var(--text);
font-size: 0.85rem;
align-self: start;
}
.header-rig-switch button {
height: 2rem;
@@ -2839,6 +2851,268 @@ canvas:focus-visible, [tabindex]:focus-visible {
outline-offset: 2px;
}
/* ── Shared interaction primitives ───────────────────────────────────── */
.visually-hidden {
position: absolute !important;
width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.toast-region {
position: fixed;
z-index: 120;
right: var(--space-4);
bottom: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
width: min(28rem, calc(100vw - 2rem));
pointer-events: none;
}
.toast {
--toast-accent: var(--accent-yellow);
position: relative;
display: grid;
grid-template-columns: 1.25rem minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-3);
min-height: 3rem;
padding: 0.7rem 0.75rem;
border: 1px solid color-mix(in srgb, var(--toast-accent) 35%, var(--border-light));
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--card-bg) 96%, var(--toast-accent) 4%);
color: var(--text);
box-shadow: 0 14px 38px color-mix(in srgb, #000 38%, transparent), inset 3px 0 0 var(--toast-accent);
opacity: 0;
transform: translateY(0.65rem) scale(0.98);
transition: opacity var(--dur-base) var(--ease-out), transform var(--dur-base) var(--ease-out);
pointer-events: auto;
}
.toast::before {
display: grid;
place-items: center;
width: 1.25rem;
height: 1.25rem;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--toast-accent) 18%, transparent);
color: var(--toast-accent);
content: "i";
font-size: var(--fs-xs);
font-weight: 800;
}
.toast > span { min-width: 0; line-height: 1.35; }
.toast-visible { opacity: 1; transform: translateY(0); }
.toast-success { --toast-accent: var(--accent-green); }
.toast-success::before { content: "✓"; }
.toast-error { --toast-accent: var(--accent-red); }
.toast-error::before { content: "!"; }
.toast button {
width: auto;
min-height: 2rem;
height: 2rem;
padding: 0 var(--space-3);
border-color: color-mix(in srgb, var(--toast-accent) 55%, var(--btn-border));
color: var(--toast-accent);
font-size: var(--fs-sm);
font-weight: 700;
}
.ui-dialog {
max-width: none;
border: 0;
padding: 0;
border-radius: var(--radius-lg);
color: var(--text);
background: transparent;
box-shadow: 0 24px 70px color-mix(in srgb, #000 55%, transparent);
}
.ui-dialog::backdrop { background: rgba(3, 8, 18, 0.72); backdrop-filter: blur(5px); }
.ui-dialog-card {
width: min(29rem, calc(100vw - 2rem));
margin: 0;
padding: var(--space-6);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
background: linear-gradient(145deg, color-mix(in srgb, var(--card-bg) 94%, var(--text) 6%), var(--card-bg));
box-sizing: border-box;
}
.ui-dialog-card h2 { margin: 0 0 var(--space-2); color: var(--text-heading); font-size: var(--fs-md); line-height: 1.3; }
.ui-dialog-card p { margin: 0; color: var(--text-muted); line-height: 1.55; }
.ui-dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-6); }
.ui-dialog-actions button { min-width: 6rem; }
.ui-dialog-actions .danger { background: var(--accent-red); border-color: var(--accent-red); color: #fff; font-weight: 700; }
.ui-dialog-actions .danger:hover:not(:disabled) { background: color-mix(in srgb, var(--accent-red) 82%, #fff); border-color: color-mix(in srgb, var(--accent-red) 82%, #fff); }
.form-error { flex: 1 1 100%; min-height: 1.2em; color: var(--accent-red); font-size: var(--fs-sm); font-weight: 600; }
button.is-active {
border-color: var(--accent-green);
background: color-mix(in srgb, var(--btn-bg) 82%, var(--accent-green) 18%);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-green) 32%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent-green) 12%, transparent);
}
#ptt-btn.is-active { background: var(--accent-red) !important; border-color: var(--accent-red) !important; color: white !important; }
.is-busy { cursor: progress !important; opacity: 0.68; }
.operator-layout-picker {
display: inline-grid;
grid-template-columns: auto auto;
align-items: center;
min-height: 2rem;
padding-left: 0.6rem;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--input-bg) 88%, transparent);
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
overflow: visible;
}
.operator-layout-picker::before { content: "Layout"; }
.operator-layout-picker select {
width: auto;
min-height: 2rem;
max-width: 9rem;
padding: 0 1.75rem 0 0.45rem;
border: 0;
background-color: transparent;
color: var(--text);
font-size: var(--fs-sm);
font-weight: 600;
text-transform: none;
cursor: pointer;
}
.operator-layout-picker:focus-within { border-color: var(--accent-green); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-green) 20%, transparent); }
.operator-layout-picker select:focus-visible { outline: 0; }
.operator-layout-description {
grid-column: 1 / -1;
max-width: 22rem;
padding: 0 0.55rem 0.35rem 0;
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: 500;
letter-spacing: 0;
line-height: 1.35;
text-transform: none;
}
.advanced-radio-controls {
margin-top: var(--space-3);
border: 1px solid color-mix(in srgb, var(--border-light) 75%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--input-bg) 68%, transparent);
overflow: clip;
transition: border-color var(--dur-fast) var(--ease-standard), background-color var(--dur-fast) var(--ease-standard);
}
.advanced-radio-controls:hover { border-color: var(--border-light); }
.advanced-radio-controls[open] { background: color-mix(in srgb, var(--surface) 86%, transparent); }
.advanced-radio-controls summary {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0.65rem 0.75rem;
color: var(--text-heading);
font-size: var(--fs-sm);
font-weight: 700;
cursor: pointer;
list-style: none;
user-select: none;
}
.advanced-radio-controls summary::-webkit-details-marker { display: none; }
.advanced-radio-controls summary::before {
content: "";
color: var(--accent-text);
font-size: 1.2rem;
line-height: 1;
transform: rotate(0deg);
transition: transform var(--dur-base) var(--ease-standard);
}
.advanced-radio-controls[open] summary::before { transform: rotate(90deg); }
.advanced-radio-controls[open] summary { border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); }
.advanced-radio-body { display: grid; gap: var(--space-3); padding: var(--space-3); }
.advanced-radio-body > * { margin: 0; }
body[data-operator-layout="digital"] .controls-col-wfm { display: none !important; }
body[data-operator-layout="broadcast"] #tx-power-col,
body[data-operator-layout="broadcast"] #tx-meters,
body[data-operator-layout="broadcast"] #tx-limit-row,
body[data-operator-layout="broadcast"] #vfo-row,
body[data-operator-layout="broadcast"] #sam-controls-col,
body[data-operator-layout="broadcast"] .advanced-radio-controls {
display: none !important;
}
body[data-operator-layout="broadcast"] .controls-row {
grid-template-columns: minmax(8rem, 0.65fr) auto minmax(20rem, 2fr);
}
body[data-operator-layout="broadcast"] #wfm-controls-col,
body[data-operator-layout="broadcast"] #audio-row,
body[data-operator-layout="broadcast"] #spectrum-bw-row {
border-color: color-mix(in srgb, var(--accent-green) 42%, var(--border-light));
background: color-mix(in srgb, var(--accent-green) 7%, transparent);
}
body[data-operator-layout="broadcast"] #wfm-controls-col {
padding: var(--space-2) var(--space-3);
border: 1px solid color-mix(in srgb, var(--accent-green) 42%, var(--border-light));
border-radius: var(--radius-md);
}
body[data-operator-layout="broadcast"] #audio-row {
padding: var(--space-3);
border: 1px solid color-mix(in srgb, var(--accent-green) 42%, var(--border-light));
border-radius: var(--radius-md);
}
body[data-operator-layout="broadcast"] #spectrum-bw-row {
padding: var(--space-2);
border: 1px solid color-mix(in srgb, var(--accent-green) 42%, var(--border-light));
border-radius: var(--radius-sm);
}
body[data-operator-layout="broadcast"] #ais-bar-overlay,
body[data-operator-layout="broadcast"] #vdes-bar-overlay,
body[data-operator-layout="broadcast"] #ft8-bar-overlay,
body[data-operator-layout="broadcast"] #aprs-bar-overlay,
body[data-operator-layout="broadcast"] #hf-aprs-bar-overlay,
body[data-operator-layout="broadcast"] #cw-bar-overlay {
display: none !important;
}
@media (max-width: 760px) {
body[data-operator-layout="broadcast"] .controls-row { grid-template-columns: 1fr auto; }
body[data-operator-layout="broadcast"] #wfm-controls-col { grid-column: 1 / -1; }
}
.mobile-more-btn, .mobile-more-menu, .decoder-tab-select { display: none; }
.mobile-more-menu {
position: fixed;
right: max(0.75rem, env(safe-area-inset-right));
bottom: calc(5.4rem + env(safe-area-inset-bottom));
z-index: 80;
min-width: 12rem;
padding: var(--space-2);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--card-bg) 97%, transparent);
box-shadow: 0 18px 45px color-mix(in srgb, #000 42%, transparent);
}
.mobile-more-menu.is-open { display: grid; gap: 0.2rem; animation: trx-menu-in var(--dur-base) var(--ease-out); }
.mobile-more-menu button {
justify-content: flex-start;
width: 100%;
height: 2.65rem;
padding: 0 var(--space-3);
border-color: transparent;
background: transparent;
text-align: left;
}
.mobile-more-menu button:hover { background: var(--btn-hover-bg); border-color: transparent; }
.decoder-state-dot {
display: inline-block;
width: 0.45rem;
height: 0.45rem;
margin-left: 0.4rem;
border: 1px solid color-mix(in srgb, var(--text-muted) 70%, transparent);
border-radius: 50%;
background: color-mix(in srgb, var(--text-muted) 60%, transparent);
vertical-align: middle;
}
.decoder-state-dot[data-state="active"] { background: var(--accent-green); box-shadow: 0 0 0.35rem color-mix(in srgb, var(--accent-green) 65%, transparent); }
.decoder-state-dot[data-state="error"] { background: var(--accent-red); }
@keyframes trx-menu-in { from { opacity: 0; transform: translateY(0.4rem) scale(0.98); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
}
/* ── Scrollbars ───────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
@@ -2953,7 +3227,7 @@ canvas:focus-visible, [tabindex]:focus-visible {
bottom: calc(0.55rem + env(safe-area-inset-bottom));
z-index: 30;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.25rem;
padding: 0.38rem;
border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent);
@@ -2993,12 +3267,38 @@ canvas:focus-visible, [tabindex]:focus-visible {
.tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="digital-modes"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="statistics"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
.mobile-more-btn { display: flex; }
.mobile-more-btn[aria-expanded="true"] {
color: var(--accent-text);
background: color-mix(in srgb, var(--accent-green) 10%, transparent);
}
.tab .tab-label, .tab[data-tab] .tab-label { font-size: 0.75rem; }
.decoder-tab-select {
display: block;
width: 100%;
min-height: 2.8rem;
margin-bottom: var(--space-3);
padding: 0 var(--space-3);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--input-bg) 90%, var(--card-bg));
color: var(--text);
font-size: 1rem;
font-weight: 600;
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 5%, transparent);
}
#tab-digital-modes > .sub-tab-bar { display: none; }
.toast-region { bottom: calc(5.7rem + env(safe-area-inset-bottom)); }
.top-bar-actions {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0.45rem;
}
.operator-layout-picker { max-width: 100%; }
.operator-layout-picker select { max-width: 10rem; }
.header-rig-switch,
.header-style-pick {
flex: 1 1 12rem;
@@ -3910,6 +4210,27 @@ canvas:focus-visible, [tabindex]:focus-visible {
}
}
@media (max-width: 480px) {
.toast-region {
right: max(0.65rem, env(safe-area-inset-right));
width: calc(100vw - max(1.3rem, env(safe-area-inset-left) + env(safe-area-inset-right)));
}
.toast { grid-template-columns: 1.25rem minmax(0, 1fr); }
.toast button { grid-column: 2; justify-self: start; }
.ui-dialog-card { padding: var(--space-5); }
.ui-dialog-actions { flex-direction: column-reverse; }
.ui-dialog-actions button { width: 100%; }
}
/* Preserve legibility after the component-specific compact-mobile rules. */
@media (max-width: 640px) {
button, input, select, textarea, .tab-label, .sub-tab,
.label, .hint, .subtitle, .wfm-control-label, .wfm-intf-val,
.ft8-header, .ft8-message, .aprs-packet, .ais-message, .vdes-message {
font-size: max(0.75rem, 12px);
}
}
/* Theme styles have been moved to themes.css */
@@ -5061,3 +5382,11 @@ canvas:focus-visible, [tabindex]:focus-visible {
from { opacity: 0; }
to { opacity: 1; }
}
@media (max-width: 640px) {
button, input, select, textarea, .tab-label, .sub-tab,
.label, .hint, .subtitle, .wfm-control-label, .wfm-intf-val,
.ft8-header, .ft8-message, .aprs-packet, .ais-message, .vdes-message {
font-size: max(0.75rem, 12px);
}
}
@@ -0,0 +1,144 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import vm from "node:vm";
class ClassList {
constructor() { this.values = new Set(); }
add(...names) { names.forEach(name => this.values.add(name)); }
remove(...names) { names.forEach(name => this.values.delete(name)); }
contains(name) { return this.values.has(name); }
toggle(name, force) {
const enabled = force === undefined ? !this.contains(name) : force;
if (enabled) this.add(name); else this.remove(name);
return enabled;
}
}
class Element {
constructor(tagName, document) {
this.tagName = tagName.toUpperCase();
this.ownerDocument = document;
this.children = [];
this.dataset = {};
this.attributes = new Map();
this.classList = new ClassList();
this.listeners = new Map();
this.style = {};
this.options = [];
this.value = "";
this.textContent = "";
}
set id(value) { this._id = value; if (value) this.ownerDocument.elements.set(value, this); }
get id() { return this._id || ""; }
set className(value) { this.classList = new ClassList(); value.split(/\s+/).filter(Boolean).forEach(name => this.classList.add(name)); }
set innerHTML(value) {
this._innerHTML = value;
if (value.includes('value="confirm"')) {
const title = new Element("h2", this.ownerDocument); title.id = "ui-confirm-title";
const message = new Element("p", this.ownerDocument); message.id = "ui-confirm-message";
const confirm = new Element("button", this.ownerDocument); confirm.value = "confirm";
this.append(title, message, confirm);
this._confirmButton = confirm;
}
}
get innerHTML() { return this._innerHTML || ""; }
appendChild(child) { this.children.push(child); child.parentElement = this; return child; }
append(...children) { children.forEach(child => this.appendChild(child)); }
insertBefore(child) { return this.appendChild(child); }
remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter(child => child !== this); }
replaceChildren(...children) { this.children = []; this.options = []; this.append(...children); }
setAttribute(name, value) { this.attributes.set(name, String(value)); }
getAttribute(name) { return this.attributes.get(name); }
addEventListener(type, listener) { this.listeners.set(type, listener); }
dispatch(type, event = {}) { this.listeners.get(type)?.({ target: this, preventDefault() {}, ...event }); }
querySelector(selector) {
if (selector === '[value="confirm"]') return this._confirmButton || null;
return null;
}
querySelectorAll(selector) {
if (selector === ".tab[data-tab]") return this.children.filter(child => child.dataset.tab);
if (selector === ".sub-tab[data-subtab]") return this.children.filter(child => child.dataset.subtab);
if (selector === '[role="tab"]') return this.children.filter(child => child.getAttribute("role") === "tab");
return [];
}
add(option) { this.options.push(option); if (!this.value) this.value = option.value; }
showModal() { this.open = true; }
close(value) { this.returnValue = value; this.open = false; this.dispatch("close"); }
focus() { this.focused = true; }
click() { this.clicked = true; }
}
class DocumentFixture {
constructor() {
this.readyState = "loading";
this.elements = new Map();
this.body = new Element("body", this);
}
createElement(tagName) { return new Element(tagName, this); }
getElementById(id) { return this.elements.get(id) || null; }
querySelector() { return null; }
querySelectorAll() { return []; }
addEventListener() {}
}
const document = new DocumentFixture();
const storage = new Map();
const localStorage = {
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
setItem(key, value) { storage.set(key, String(value)); },
};
const window = { document, localStorage, addEventListener() {} };
const context = vm.createContext({
window, document, localStorage,
Option: class Option { constructor(label, value) { this.label = label; this.value = value; } },
MutationObserver: class MutationObserver { observe() {} },
requestAnimationFrame(callback) { callback(); },
setTimeout() { return 1; },
clearTimeout() {},
console,
});
const source = await readFile(new URL("../ui-core.js", import.meta.url), "utf8");
new vm.Script(source, { filename: "ui-core.js" }).runInContext(context);
const ui = window.trxUi;
const toast = ui.notify("Saved", { kind: "success" });
assert.equal(toast.getAttribute("role"), "status");
assert.equal(toast.classList.contains("toast-success"), true);
assert.equal(document.getElementById("toast-region").children.length, 1);
const confirmation = ui.confirm({ title: "Delete?", message: "Permanent", confirmLabel: "Delete" });
const dialog = document.getElementById("ui-confirm-dialog");
assert.equal(dialog.open, true);
assert.equal(document.getElementById("ui-confirm-title").textContent, "Delete?");
dialog.close("confirm");
assert.equal(await confirmation, true);
const tabBar = new Element("div", document);
const firstTab = new Element("button", document); firstTab.dataset.tab = "main"; firstTab.classList.add("active");
const secondTab = new Element("button", document); secondTab.dataset.tab = "map";
tabBar.append(firstTab, secondTab);
const mainPanel = new Element("section", document); mainPanel.id = "tab-main";
const mapPanel = new Element("section", document); mapPanel.id = "tab-map";
ui.prepareTabList(tabBar, "primary");
assert.equal(firstTab.getAttribute("aria-selected"), "true");
tabBar.dispatch("keydown", { target: firstTab, key: "ArrowRight" });
assert.equal(secondTab.focused, true);
assert.equal(secondTab.clicked, true);
localStorage.setItem("trxOperatorLayout:rig-a", "broadcast");
ui.setActiveRig("rig-a");
assert.equal(document.body.dataset.operatorLayout, "compact");
ui.setLayoutCapabilities({ broadcast: true });
ui.setActiveRig("rig-a");
assert.equal(document.body.dataset.operatorLayout, "broadcast");
ui.setActiveRig("rig-b");
ui.applyLayout("digital");
assert.equal(document.body.dataset.operatorLayout, "compact");
assert.equal(localStorage.getItem("trxOperatorLayout:rig-b"), "compact");
console.log("ui-core component tests passed");
@@ -0,0 +1,359 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
"use strict";
// Shared UI primitives. Keeping these outside app.js prevents navigation,
// feedback, dialogs, and layout preferences from growing separate state models.
(function initUiCore() {
const api = window.trxUi = window.trxUi || {};
function ensureLiveRegions() {
if (!document.getElementById("toast-region")) {
const region = document.createElement("div");
region.id = "toast-region";
region.className = "toast-region";
region.setAttribute("aria-live", "polite");
region.setAttribute("aria-atomic", "false");
document.body.appendChild(region);
}
if (!document.getElementById("ui-confirm-dialog")) {
const dialog = document.createElement("dialog");
dialog.id = "ui-confirm-dialog";
dialog.className = "ui-dialog";
dialog.innerHTML = `
<form method="dialog" class="ui-dialog-card">
<h2 id="ui-confirm-title">Confirm action</h2>
<p id="ui-confirm-message"></p>
<div class="ui-dialog-actions">
<button value="cancel" type="submit">Cancel</button>
<button value="confirm" type="submit" class="danger">Confirm</button>
</div>
</form>`;
document.body.appendChild(dialog);
}
}
api.notify = function notify(message, options = {}) {
ensureLiveRegions();
const { kind = "info", duration = kind === "error" ? 7000 : 3200, action = null } = options;
const toast = document.createElement("div");
toast.className = `toast toast-${kind}`;
toast.setAttribute("role", kind === "error" ? "alert" : "status");
const text = document.createElement("span");
text.textContent = message;
toast.appendChild(text);
if (action && typeof action.run === "function") {
const button = document.createElement("button");
button.type = "button";
button.textContent = action.label || "Retry";
button.addEventListener("click", () => { action.run(); toast.remove(); });
toast.appendChild(button);
}
document.getElementById("toast-region").appendChild(toast);
requestAnimationFrame(() => toast.classList.add("toast-visible"));
if (duration > 0) setTimeout(() => toast.remove(), duration);
return toast;
};
api.confirm = function confirmAction(options = {}) {
ensureLiveRegions();
const dialog = document.getElementById("ui-confirm-dialog");
document.getElementById("ui-confirm-title").textContent = options.title || "Confirm action";
document.getElementById("ui-confirm-message").textContent = options.message || "Continue?";
const confirmButton = dialog.querySelector('[value="confirm"]');
confirmButton.textContent = options.confirmLabel || "Confirm";
confirmButton.classList.toggle("danger", options.danger !== false);
return new Promise((resolve) => {
const finish = () => resolve(dialog.returnValue === "confirm");
dialog.addEventListener("close", finish, { once: true });
dialog.showModal();
});
};
api.setButtonState = function setButtonState(button, options = {}) {
if (!button) return;
const { active = false, activeLabel, inactiveLabel, busy = false, disabled = false } = options;
button.classList.toggle("is-active", active);
button.classList.toggle("is-busy", busy);
button.setAttribute("aria-pressed", String(active));
button.setAttribute("aria-busy", String(busy));
button.disabled = disabled || busy;
const label = active ? activeLabel : inactiveLabel;
if (label) button.textContent = label;
};
api.prepareTabList = function prepareTabList(bar, kind = "primary") {
if (!bar) return;
if (bar._accessibleTabsPrepared) return;
bar._accessibleTabsPrepared = true;
const selector = kind === "primary" ? ".tab[data-tab]" : ".sub-tab[data-subtab]";
const buttons = Array.from(bar.querySelectorAll(selector));
bar.setAttribute("role", "tablist");
buttons.forEach((button, index) => {
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(button.classList.contains("active")));
button.tabIndex = button.classList.contains("active") || (!buttons.some(b => b.classList.contains("active")) && index === 0) ? 0 : -1;
const key = button.dataset.tab || button.dataset.subtab;
button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`);
const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`);
if (panel) {
if (!button.id) button.id = `${kind}-tab-${key}`;
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", button.id);
}
});
bar.addEventListener("keydown", (event) => {
if (!buttons.includes(event.target)) return;
const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1
: event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
if (!direction) return;
event.preventDefault();
const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length];
next.focus();
next.click();
});
};
api.syncSelectedTab = function syncSelectedTab(bar, selected) {
if (!bar) return;
bar.querySelectorAll('[role="tab"]').forEach((tab) => {
const active = tab === selected;
tab.setAttribute("aria-selected", String(active));
tab.tabIndex = active ? 0 : -1;
});
};
const layouts = {
compact: { label: "Compact", description: "Essential tuning and audio controls", advanced: false, preferredTab: "main" },
broadcast: { label: "Broadcast", description: "WFM, RDS, stereo and interference monitoring", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, preferredTab: "main", capability: "broadcast" },
digital: { label: "Digital", description: "Decoder status, messages and background decoding", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, preferredTab: "digital-modes", capability: "digital" },
full: { label: "Full controls", description: "Every control supported by the selected rig", advanced: true, preferredTab: "main" },
};
const layoutCapabilities = { broadcast: false, digital: false };
let activeRigId = null;
function layoutStorageKey() {
return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout";
}
function savedLayoutName() {
return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact";
}
function layoutAvailable(layout) {
return !layout.capability || layoutCapabilities[layout.capability] === true;
}
function unavailableLayoutMessage() {
const unavailable = Object.values(layouts).filter(layout => !layoutAvailable(layout) && layout.unavailable);
return unavailable.length ? `Unavailable: ${unavailable.map(layout => layout.unavailable).join("; ")}.` : "";
}
function updateLayoutDescription(layout) {
const description = document.getElementById("operator-layout-description");
if (!description) return;
description.textContent = [layout.description, unavailableLayoutMessage()].filter(Boolean).join(" · ");
}
function refreshLayoutOptions() {
const select = document.getElementById("operator-layout-select");
if (!select) return;
const previous = select.value || document.body.dataset.operatorLayout || "compact";
select.replaceChildren();
Object.entries(layouts).forEach(([value, layout]) => {
if (!layoutAvailable(layout)) return;
select.add(new Option(layout.label, value));
});
const available = Array.from(select.options).some(option => option.value === previous);
select.value = available ? previous : "compact";
if (!available && previous !== "compact") api.applyLayout("compact", { persist: false });
select.title = unavailableLayoutMessage();
updateLayoutDescription(layouts[select.value] || layouts.compact);
}
api.setLayoutCapabilities = function setLayoutCapabilities(capabilities = {}) {
Object.keys(layoutCapabilities).forEach((name) => {
if (name in capabilities) layoutCapabilities[name] = Boolean(capabilities[name]);
});
refreshLayoutOptions();
const select = document.getElementById("operator-layout-select");
const saved = savedLayoutName();
if (select && Array.from(select.options).some(option => option.value === saved)) {
select.value = saved;
api.applyLayout(saved, { persist: false });
}
};
api.setActiveRig = function setActiveRig(rigId) {
activeRigId = typeof rigId === "string" && rigId ? rigId : null;
const saved = savedLayoutName();
const select = document.getElementById("operator-layout-select");
if (select) select.value = Array.from(select.options).some(option => option.value === saved) ? saved : "compact";
api.applyLayout(select?.value || saved, { persist: false });
};
api.applyLayout = function applyLayout(name, options = {}) {
const requestedLayout = layouts[name];
const permittedName = requestedLayout && layoutAvailable(requestedLayout) ? name : "compact";
const layout = layouts[permittedName];
document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact";
if (options.persist !== false) localStorage.setItem(layoutStorageKey(), document.body.dataset.operatorLayout);
const details = document.getElementById("advanced-radio-controls");
if (details) details.open = layout.advanced;
updateLayoutDescription(layout);
if (options.navigate && typeof window.navigateToTab === "function") {
window.navigateToTab(layout.preferredTab);
}
};
function installLayoutControls() {
const actions = document.querySelector(".top-bar-actions");
if (actions && !document.getElementById("operator-layout-select")) {
const label = document.createElement("label");
label.className = "operator-layout-picker";
label.innerHTML = '<span class="visually-hidden">Operator layout</span><select id="operator-layout-select" aria-label="Operator layout" aria-describedby="operator-layout-description"></select><span id="operator-layout-description" class="operator-layout-description"></span>';
const select = label.querySelector("select");
const savedLayout = savedLayoutName();
actions.insertBefore(label, actions.firstChild);
select.value = savedLayout;
refreshLayoutOptions();
if (savedLayout !== "broadcast" && layouts[savedLayout]) select.value = savedLayout;
select.addEventListener("change", () => api.applyLayout(select.value, { navigate: true }));
api.applyLayout(select.value);
}
const tray = document.querySelector(".controls-tray");
if (tray && !document.getElementById("advanced-radio-controls")) {
const details = document.createElement("details");
details.id = "advanced-radio-controls";
details.className = "advanced-radio-controls";
details.innerHTML = '<summary>Advanced radio controls</summary><div class="advanced-radio-body"></div>';
const body = details.querySelector(".advanced-radio-body");
["sdr-settings-row", "vchan-row", "tx-limit-row"].forEach((id) => {
const element = document.getElementById(id);
if (element) body.appendChild(element);
});
tray.appendChild(details);
api.applyLayout(savedLayoutName(), { persist: false });
}
}
function installMobileMore() {
const nav = document.querySelector(".tab-bar-nav");
if (!nav || document.getElementById("mobile-more-btn")) return;
const more = document.createElement("button");
more.id = "mobile-more-btn";
more.className = "tab mobile-more-btn";
more.type = "button";
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">More</span>';
more.setAttribute("aria-haspopup", "menu");
more.setAttribute("aria-expanded", "false");
const menu = document.createElement("div");
menu.id = "mobile-more-menu";
menu.className = "mobile-more-menu";
menu.setAttribute("role", "menu");
more.setAttribute("aria-controls", menu.id);
const closeMore = (restoreFocus = false) => {
if (!menu.classList.contains("is-open")) return;
menu.classList.remove("is-open");
more.setAttribute("aria-expanded", "false");
if (restoreFocus) more.focus();
};
api.closeMobileOverlays = closeMore;
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
const source = nav.querySelector(`[data-tab="${tabName}"]`);
if (!source) return;
const item = document.createElement("button");
item.type = "button";
item.setAttribute("role", "menuitem");
item.dataset.navigateTab = tabName;
item.textContent = source.textContent.trim();
item.addEventListener("click", () => {
if (typeof window.navigateToTab === "function") window.navigateToTab(tabName);
closeMore();
});
menu.appendChild(item);
});
more.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
more.setAttribute("aria-expanded", String(open));
if (open) menu.querySelector('[role="menuitem"]')?.focus();
});
document.addEventListener("click", (event) => {
if (!menu.contains(event.target) && !more.contains(event.target)) closeMore();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMore(true);
});
window.addEventListener("resize", () => closeMore());
window.addEventListener("popstate", () => closeMore());
nav.append(more, menu);
}
function installDecoderPicker() {
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
if (!bar || document.getElementById("decoder-tab-select")) return;
const select = document.createElement("select");
select.id = "decoder-tab-select";
select.className = "decoder-tab-select";
select.setAttribute("aria-label", "Decoder view");
const groups = [
["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]],
];
groups.forEach(([label, ids]) => {
const group = document.createElement("optgroup");
group.label = label;
ids.forEach((id) => {
const button = bar.querySelector(`[data-subtab="${id}"]`);
if (button) group.appendChild(new Option(button.textContent.trim(), id));
});
select.appendChild(group);
});
select.addEventListener("change", () => bar.querySelector(`[data-subtab="${select.value}"]`)?.click());
bar.insertAdjacentElement("afterend", select);
}
function installDecoderBadges() {
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
if (!bar) return;
bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => {
const id = button.dataset.subtab;
if (id === "overview" || button.querySelector(".decoder-state-dot")) return;
const dot = document.createElement("span");
dot.className = "decoder-state-dot";
dot.setAttribute("aria-hidden", "true");
button.appendChild(dot);
const status = document.getElementById(`${id}-status`);
if (!status) return;
const sync = () => {
const value = status.textContent.toLowerCase();
const state = /receiv|decod|connected|listening/.test(value) ? "active"
: /error|fail|disconnected/.test(value) ? "error" : "idle";
dot.dataset.state = state;
button.title = `${button.childNodes[0]?.textContent?.trim() || id}: ${status.textContent.trim()}`;
};
new MutationObserver(sync).observe(status, { childList: true, characterData: true, subtree: true });
sync();
});
}
api.init = function init() {
ensureLiveRegions();
installLayoutControls();
installMobileMore();
installDecoderPicker();
installDecoderBadges();
api.prepareTabList(document.querySelector(".tab-bar-nav"), "primary");
document.querySelectorAll(".sub-tab-bar").forEach(bar => api.prepareTabList(bar, "secondary"));
window.addEventListener("unhandledrejection", (event) => {
const message = event.reason?.message || "An operation failed unexpectedly";
api.notify(message, { kind: "error" });
});
};
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", api.init, { once: true });
else api.init();
})();
File diff suppressed because one or more lines are too long
@@ -29,6 +29,7 @@ define_gz_cache!(gz_index_html, status::index_html(), "index.html");
define_gz_cache!(gz_style_css, status::STYLE_CSS, "style.css");
define_gz_cache!(gz_themes_css, status::THEMES_CSS, "themes.css");
define_gz_cache!(gz_app_js, status::APP_JS, "app.js");
define_gz_cache!(gz_ui_core_js, status::UI_CORE_JS, "ui-core.js");
define_gz_cache!(gz_map_core_js, status::MAP_CORE_JS, "map-core.js");
define_gz_cache!(gz_screenshot_js, status::SCREENSHOT_JS, "screenshot.js");
define_gz_cache!(
@@ -75,6 +76,13 @@ define_gz_cache!(gz_bandplan_json, status::BANDPLAN_JSON, "bandplan.json");
// Vendored DSEG14 Classic font
// (binary woff2 — served directly, not through gz_cache)
// Vendored opus-decoder 0.7.11
define_gz_cache!(
gz_opus_decoder_js,
status::OPUS_DECODER_JS,
"opus-decoder-0.7.11.min.js"
);
// Vendored Leaflet 1.9.4
define_gz_cache!(gz_leaflet_js, status::LEAFLET_JS, "leaflet.js");
define_gz_cache!(gz_leaflet_css, status::LEAFLET_CSS, "leaflet.css");
@@ -173,6 +181,12 @@ pub(crate) async fn app_js(req: HttpRequest) -> impl Responder {
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/ui-core.js")]
pub(crate) async fn ui_core_js(req: HttpRequest) -> impl Responder {
let c = gz_ui_core_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
#[get("/map-core.js")]
pub(crate) async fn map_core_js(req: HttpRequest) -> impl Responder {
let c = gz_map_core_js();
@@ -341,6 +355,12 @@ pub(crate) async fn dseg14_classic_woff2() -> impl Responder {
.body(status::DSEG14_CLASSIC_WOFF2)
}
#[get("/vendor/opus-decoder-0.7.11.min.js")]
pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
let c = gz_opus_decoder_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
// ---------------------------------------------------------------------------
// Vendored Leaflet 1.9.4
// ---------------------------------------------------------------------------
@@ -643,6 +643,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::style_css)
.service(assets::themes_css)
.service(assets::app_js)
.service(assets::ui_core_js)
.service(assets::map_core_js)
.service(assets::screenshot_js)
.service(assets::decode_history_worker_js)
@@ -668,6 +669,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::bandplan_json)
// Vendored DSEG14 Classic font
.service(assets::dseg14_classic_woff2)
// Vendored opus-decoder 0.7.11
.service(assets::opus_decoder_js)
// Vendored Leaflet 1.9.4
.service(assets::leaflet_js)
.service(assets::leaflet_css)
@@ -388,6 +388,9 @@ struct RigListItem {
display_name: Option<String>,
manufacturer: String,
model: String,
supported_modes: Vec<trx_core::RigMode>,
tx: bool,
filter_controls: bool,
initialized: bool,
#[serde(skip_serializing_if = "Option::is_none")]
latitude: Option<f64>,
@@ -422,6 +425,9 @@ fn map_rig_entry(entry: &RemoteRigEntry) -> RigListItem {
display_name: entry.display_name.clone(),
manufacturer: entry.state.info.manufacturer.clone(),
model: entry.state.info.model.clone(),
supported_modes: entry.state.info.capabilities.supported_modes.clone(),
tx: entry.state.info.capabilities.tx,
filter_controls: entry.state.info.capabilities.filter_controls,
initialized: entry.state.initialized,
latitude: entry.state.server_latitude,
longitude: entry.state.server_longitude,
@@ -12,6 +12,7 @@ const INDEX_HTML: &str = include_str!("../assets/web/index.html");
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
pub const THEMES_CSS: &str = include_str!("../assets/web/themes.css");
pub const APP_JS: &str = include_str!("../assets/web/app.js");
pub const UI_CORE_JS: &str = include_str!("../assets/web/ui-core.js");
pub const MAP_CORE_JS: &str = include_str!("../assets/web/map-core.js");
pub const SCREENSHOT_JS: &str = include_str!("../assets/web/screenshot.js");
pub const DECODE_HISTORY_WORKER_JS: &str = include_str!("../assets/web/decode-history-worker.js");
@@ -40,6 +41,9 @@ pub const BANDPLAN_JSON: &str = include_str!("../assets/web/bandplan.json");
pub const DSEG14_CLASSIC_WOFF2: &[u8] =
include_bytes!("../assets/web/vendor/dseg14-classic-latin-400-normal.woff2");
// Vendored opus-decoder 0.7.11 browser build (WebAssembly embedded in JS)
pub const OPUS_DECODER_JS: &str = include_str!("../assets/web/vendor/opus-decoder-0.7.11.min.js");
// Vendored Leaflet 1.9.4
pub const LEAFLET_JS: &str = include_str!("../assets/web/vendor/leaflet.js");
pub const LEAFLET_CSS: &str = include_str!("../assets/web/vendor/leaflet.css");
@@ -68,3 +72,21 @@ pub fn index_html() -> &'static str {
.replace("{client_build_date}", CLIENT_BUILD_DATE)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shared_ui_core_is_embedded_and_loaded_before_app() {
let html = index_html();
let ui_core = html.find("/ui-core.js").expect("ui-core script is loaded");
let app = html.find("/app.js").expect("application script is loaded");
assert!(
ui_core < app,
"UI primitives must load before application code"
);
assert!(UI_CORE_JS.contains("window.trxUi"));
}
}
+11 -530
View File
@@ -6,13 +6,14 @@
#[cfg(feature = "ft2")]
use std::collections::HashMap;
use std::collections::{HashSet, VecDeque};
use std::collections::HashSet;
#[cfg(test)]
use std::collections::VecDeque;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use base64::Engine as _;
use bytes::Bytes;
use flate2::write::GzEncoder;
use flate2::Compression;
@@ -34,10 +35,9 @@ use trx_core::audio::{
AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB,
AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
};
use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, LrptImage, LrptProgress,
VdesMessage, WefaxMessage, WsprMessage,
};
#[cfg(test)]
use trx_core::decode::{AisMessage, AprsPacket, CwEvent};
use trx_core::decode::{DecodedMessage, Ft8Message, LrptImage, LrptProgress, WsprMessage};
use trx_core::rig::state::{RigMode, RigState};
use trx_core::vchan::SharedVChanManager;
use trx_cw::CwDecoder;
@@ -47,21 +47,11 @@ use trx_wspr::WsprDecoder;
use uuid::Uuid;
use crate::config::AudioConfig;
use crate::history_policy::{current_timestamp_ms, lock_or_recover};
#[cfg(test)]
use crate::history_policy::{enforce_capacity, prune_by_age, MAX_HISTORY_ENTRIES};
use trx_decode_log::DecoderLoggers;
const APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const HF_APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const AIS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const VDES_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const CW_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const FT8_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const WSPR_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const LRPT_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const WEFAX_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
/// Maximum entries per decoder history queue. Prevents unbounded memory growth
/// on busy channels (e.g. AIS near a port). Oldest entries are evicted when
/// the limit is reached, independent of the time-based pruning.
const MAX_HISTORY_ENTRIES: usize = 10_000;
/// Silence timeout before auto-finalising an LRPT pass (30 s without new MCUs).
const LRPT_PASS_SILENCE_TIMEOUT: Duration = Duration::from_secs(30);
const FT8_SAMPLE_RATE: u32 = 12_000;
@@ -75,13 +65,6 @@ const DECODE_AUDIO_GATE_RMS: f32 = 2.5e-4;
const AUDIO_STREAM_ERROR_LOG_INTERVAL: Duration = Duration::from_secs(60);
const AUDIO_STREAM_RECOVERY_DELAY: Duration = Duration::from_secs(1);
fn current_timestamp_ms() -> i64 {
match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
Ok(dur) => dur.as_millis() as i64,
Err(_) => 0,
}
}
#[cfg(feature = "ft2")]
fn retain_ft2_window(buf: &mut Vec<f32>) {
if buf.len() > FT2_ASYNC_BUFFER_SAMPLES {
@@ -360,509 +343,7 @@ fn classify_stream_error(err: &str) -> &'static str {
}
}
/// Per-rig decoder history store.
///
/// Replaces the previous process-wide `OnceLock` statics so that each rig
/// instance can maintain its own independent history. Pass an
/// `Arc<DecoderHistories>` into every decoder task and into the audio listener.
pub struct DecoderHistories {
pub ais: Mutex<VecDeque<(Instant, AisMessage)>>,
pub vdes: Mutex<VecDeque<(Instant, VdesMessage)>>,
pub aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
pub hf_aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
pub cw: Mutex<VecDeque<(Instant, CwEvent)>>,
pub ft8: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub ft4: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub ft2: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>,
/// Approximate total entry count across all decoders, maintained
/// atomically so `estimated_total_count()` avoids 11 lock acquisitions.
total_count: AtomicUsize,
}
/// Acquire a mutex, recovering from poisoning with a warning log.
fn lock_or_recover<'a, T>(mutex: &'a Mutex<T>, label: &str) -> std::sync::MutexGuard<'a, T> {
mutex.lock().unwrap_or_else(|e| {
tracing::warn!(
"Mutex for {} was poisoned (prior panic); recovering with potentially inconsistent data",
label
);
e.into_inner()
})
}
/// Enforce capacity limit on a history deque by evicting oldest entries.
fn enforce_capacity<T>(deque: &mut VecDeque<T>, max: usize) {
while deque.len() > max {
deque.pop_front();
}
}
/// Drop entries older than `retention` from the front of a time-tagged deque.
/// Uses `checked_sub` so an early `now` (before `retention` elapses since the
/// monotonic clock origin) is treated as "no entries are old enough to prune"
/// rather than panicking.
fn prune_by_age<T>(deque: &mut VecDeque<(Instant, T)>, retention: Duration, now: Instant) {
let Some(cutoff) = now.checked_sub(retention) else {
return;
};
while let Some((ts, _)) = deque.front() {
if *ts < cutoff {
deque.pop_front();
} else {
break;
}
}
}
impl DecoderHistories {
pub fn new() -> Arc<Self> {
Arc::new(Self {
ais: Mutex::new(VecDeque::new()),
vdes: Mutex::new(VecDeque::new()),
aprs: Mutex::new(VecDeque::new()),
hf_aprs: Mutex::new(VecDeque::new()),
cw: Mutex::new(VecDeque::new()),
ft8: Mutex::new(VecDeque::new()),
ft4: Mutex::new(VecDeque::new()),
ft2: Mutex::new(VecDeque::new()),
wspr: Mutex::new(VecDeque::new()),
lrpt: Mutex::new(VecDeque::new()),
wefax: Mutex::new(VecDeque::new()),
total_count: AtomicUsize::new(0),
})
}
/// Adjust the atomic total count after a record/prune/clear operation.
///
/// Uses a CAS loop for decrements to prevent underflow wrapping the
/// counter to `usize::MAX` (which would cause a capacity-overflow panic
/// when pre-allocating the history replay blob).
fn adjust_total_count(&self, old_len: usize, new_len: usize) {
if new_len > old_len {
self.total_count
.fetch_add(new_len - old_len, Ordering::Relaxed);
} else if old_len > new_len {
let delta = old_len - new_len;
let mut current = self.total_count.load(Ordering::Relaxed);
loop {
let next = current.saturating_sub(delta);
match self.total_count.compare_exchange_weak(
current,
next,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => break,
Err(actual) => current = actual,
}
}
}
}
// --- AIS ---
fn prune_ais(history: &mut VecDeque<(Instant, AisMessage)>) {
prune_by_age(history, AIS_HISTORY_RETENTION, Instant::now());
}
pub fn record_ais_message(&self, mut msg: AisMessage) {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.ais, "ais_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ais(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_ais_history(&self) -> Vec<AisMessage> {
let mut h = lock_or_recover(&self.ais, "ais_history");
let before = h.len();
Self::prune_ais(&mut h);
self.adjust_total_count(before, h.len());
h.iter().map(|(_, msg)| msg.clone()).collect()
}
// --- VDES ---
fn prune_vdes(history: &mut VecDeque<(Instant, VdesMessage)>) {
prune_by_age(history, VDES_HISTORY_RETENTION, Instant::now());
}
pub fn record_vdes_message(&self, mut msg: VdesMessage) {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.vdes, "vdes_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_vdes(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_vdes_history(&self) -> Vec<VdesMessage> {
let mut h = lock_or_recover(&self.vdes, "vdes_history");
let before = h.len();
Self::prune_vdes(&mut h);
self.adjust_total_count(before, h.len());
h.iter().map(|(_, msg)| msg.clone()).collect()
}
// --- APRS ---
fn prune_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
prune_by_age(history, APRS_HISTORY_RETENTION, Instant::now());
}
pub fn record_aprs_packet(&self, mut pkt: AprsPacket) {
if !pkt.crc_ok {
return;
}
if pkt.ts_ms.is_none() {
pkt.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.aprs, "aprs_history");
let before = h.len();
h.push_back((Instant::now(), pkt));
Self::prune_aprs(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_aprs_history(&self) -> Vec<AprsPacket> {
let mut h = lock_or_recover(&self.aprs, "aprs_history");
let before = h.len();
Self::prune_aprs(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, pkt): &(Instant, AprsPacket)| pkt.clone())
.collect()
}
pub fn clear_aprs_history(&self) {
let mut h = lock_or_recover(&self.aprs, "aprs_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- HF APRS ---
fn prune_hf_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
prune_by_age(history, HF_APRS_HISTORY_RETENTION, Instant::now());
}
pub fn record_hf_aprs_packet(&self, mut pkt: AprsPacket) {
if !pkt.crc_ok {
return;
}
if pkt.ts_ms.is_none() {
pkt.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
let before = h.len();
h.push_back((Instant::now(), pkt));
Self::prune_hf_aprs(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_hf_aprs_history(&self) -> Vec<AprsPacket> {
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
let before = h.len();
Self::prune_hf_aprs(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, pkt): &(Instant, AprsPacket)| pkt.clone())
.collect()
}
pub fn clear_hf_aprs_history(&self) {
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- CW ---
fn prune_cw(history: &mut VecDeque<(Instant, CwEvent)>) {
prune_by_age(history, CW_HISTORY_RETENTION, Instant::now());
}
pub fn record_cw_event(&self, evt: CwEvent) {
let mut h = lock_or_recover(&self.cw, "cw_history");
let before = h.len();
h.push_back((Instant::now(), evt));
Self::prune_cw(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_cw_history(&self) -> Vec<CwEvent> {
let mut h = lock_or_recover(&self.cw, "cw_history");
let before = h.len();
Self::prune_cw(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, evt): &(Instant, CwEvent)| evt.clone())
.collect()
}
pub fn clear_cw_history(&self) {
let mut h = lock_or_recover(&self.cw, "cw_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- FT8 ---
fn prune_ft8(history: &mut VecDeque<(Instant, Ft8Message)>) {
prune_by_age(history, FT8_HISTORY_RETENTION, Instant::now());
}
pub fn record_ft8_message(&self, msg: Ft8Message) {
let mut h = lock_or_recover(&self.ft8, "ft8_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ft8(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_ft8_history(&self) -> Vec<Ft8Message> {
let mut h = lock_or_recover(&self.ft8, "ft8_history");
let before = h.len();
Self::prune_ft8(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
.collect()
}
pub fn clear_ft8_history(&self) {
let mut h = lock_or_recover(&self.ft8, "ft8_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- FT4 ---
fn prune_ft4(history: &mut VecDeque<(Instant, Ft8Message)>) {
prune_by_age(history, FT8_HISTORY_RETENTION, Instant::now());
}
pub fn record_ft4_message(&self, msg: Ft8Message) {
let mut h = lock_or_recover(&self.ft4, "ft4_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ft4(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_ft4_history(&self) -> Vec<Ft8Message> {
let mut h = lock_or_recover(&self.ft4, "ft4_history");
let before = h.len();
Self::prune_ft4(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
.collect()
}
pub fn clear_ft4_history(&self) {
let mut h = lock_or_recover(&self.ft4, "ft4_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- FT2 ---
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
fn prune_ft2(history: &mut VecDeque<(Instant, Ft8Message)>) {
prune_by_age(history, FT8_HISTORY_RETENTION, Instant::now());
}
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
pub fn record_ft2_message(&self, msg: Ft8Message) {
let mut h = lock_or_recover(&self.ft2, "ft2_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ft2(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
pub fn snapshot_ft2_history(&self) -> Vec<Ft8Message> {
let mut h = lock_or_recover(&self.ft2, "ft2_history");
let before = h.len();
Self::prune_ft2(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
.collect()
}
pub fn clear_ft2_history(&self) {
let mut h = lock_or_recover(&self.ft2, "ft2_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- WSPR ---
fn prune_wspr(history: &mut VecDeque<(Instant, WsprMessage)>) {
prune_by_age(history, WSPR_HISTORY_RETENTION, Instant::now());
}
pub fn record_wspr_message(&self, msg: WsprMessage) {
let mut h = lock_or_recover(&self.wspr, "wspr_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_wspr(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_wspr_history(&self) -> Vec<WsprMessage> {
let mut h = lock_or_recover(&self.wspr, "wspr_history");
let before = h.len();
Self::prune_wspr(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg): &(Instant, WsprMessage)| msg.clone())
.collect()
}
pub fn clear_wspr_history(&self) {
let mut h = lock_or_recover(&self.wspr, "wspr_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- LRPT ---
fn prune_lrpt(history: &mut VecDeque<(Instant, LrptImage)>) {
prune_by_age(history, LRPT_HISTORY_RETENTION, Instant::now());
}
pub fn record_lrpt_image(&self, mut img: LrptImage) {
if img.ts_ms.is_none() {
img.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
let before = h.len();
h.push_back((Instant::now(), img));
Self::prune_lrpt(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_lrpt_history(&self) -> Vec<LrptImage> {
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
let before = h.len();
Self::prune_lrpt(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, img): &(Instant, LrptImage)| img.clone())
.collect()
}
pub fn clear_lrpt_history(&self) {
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- WEFAX ---
fn prune_wefax(history: &mut VecDeque<(Instant, WefaxMessage)>) {
prune_by_age(history, WEFAX_HISTORY_RETENTION, Instant::now());
}
pub fn record_wefax_message(&self, mut msg: WefaxMessage) {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
// Strip bulk PNG data before storing in memory/persistence.
msg.png_data = None;
let mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_wefax(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_wefax_history(&self) -> Vec<WefaxMessage> {
let mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len();
Self::prune_wefax(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg)| {
let mut m = msg.clone();
// Re-read PNG from disk so remote clients can save a local copy.
if m.png_data.is_none() {
if let Some(ref path) = m.path {
if let Ok(bytes) = std::fs::read(path) {
m.png_data =
Some(base64::engine::general_purpose::STANDARD.encode(&bytes));
}
}
}
m
})
.collect()
}
pub fn clear_wefax_history(&self) {
let mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
/// Returns a quick (non-pruning) estimate of the total number of history
/// entries across all decoders, used for pre-allocating the replay blob.
///
/// Uses an `AtomicUsize` counter maintained by record/prune/clear methods,
/// avoiding 9 separate mutex acquisitions.
pub fn estimated_total_count(&self) -> usize {
self.total_count.load(Ordering::Relaxed)
}
/// Rebuild the aggregate count after bulk restoration bypasses the normal
/// record methods.
pub(crate) fn recalculate_total_count(&self) {
let total = lock_or_recover(&self.ais, "ais_history").len()
+ lock_or_recover(&self.vdes, "vdes_history").len()
+ lock_or_recover(&self.aprs, "aprs_history").len()
+ lock_or_recover(&self.hf_aprs, "hf_aprs_history").len()
+ lock_or_recover(&self.cw, "cw_history").len()
+ lock_or_recover(&self.ft8, "ft8_history").len()
+ lock_or_recover(&self.ft4, "ft4_history").len()
+ lock_or_recover(&self.ft2, "ft2_history").len()
+ lock_or_recover(&self.wspr, "wspr_history").len()
+ lock_or_recover(&self.lrpt, "lrpt_history").len()
+ lock_or_recover(&self.wefax, "wefax_history").len();
self.total_count.store(total, Ordering::Relaxed);
}
}
pub use crate::decoder_history::DecoderHistories;
/// Spawn the audio capture thread.
///
+489
View File
@@ -0,0 +1,489 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Per-rig storage and lifecycle operations for decoded-message histories.
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use base64::Engine as _;
use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, Ft8Message, LrptImage, VdesMessage, WefaxMessage, WsprMessage,
};
use crate::history_policy::{
current_timestamp_ms, enforce_capacity, lock_or_recover, prune_by_age, HISTORY_RETENTION,
MAX_HISTORY_ENTRIES,
};
/// Per-rig decoder history store.
///
/// Replaces the previous process-wide `OnceLock` statics so that each rig
/// instance can maintain its own independent history. Pass an
/// `Arc<DecoderHistories>` into every decoder task and into the audio listener.
pub struct DecoderHistories {
pub ais: Mutex<VecDeque<(Instant, AisMessage)>>,
pub vdes: Mutex<VecDeque<(Instant, VdesMessage)>>,
pub aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
pub hf_aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
pub cw: Mutex<VecDeque<(Instant, CwEvent)>>,
pub ft8: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub ft4: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub ft2: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>,
/// Approximate total entry count across all decoders, maintained
/// atomically so `estimated_total_count()` avoids 11 lock acquisitions.
total_count: AtomicUsize,
}
impl DecoderHistories {
pub fn new() -> Arc<Self> {
Arc::new(Self {
ais: Mutex::new(VecDeque::new()),
vdes: Mutex::new(VecDeque::new()),
aprs: Mutex::new(VecDeque::new()),
hf_aprs: Mutex::new(VecDeque::new()),
cw: Mutex::new(VecDeque::new()),
ft8: Mutex::new(VecDeque::new()),
ft4: Mutex::new(VecDeque::new()),
ft2: Mutex::new(VecDeque::new()),
wspr: Mutex::new(VecDeque::new()),
lrpt: Mutex::new(VecDeque::new()),
wefax: Mutex::new(VecDeque::new()),
total_count: AtomicUsize::new(0),
})
}
/// Adjust the atomic total count after a record/prune/clear operation.
///
/// Uses a CAS loop for decrements to prevent underflow wrapping the
/// counter to `usize::MAX` (which would cause a capacity-overflow panic
/// when pre-allocating the history replay blob).
pub(crate) fn adjust_total_count(&self, old_len: usize, new_len: usize) {
if new_len > old_len {
self.total_count
.fetch_add(new_len - old_len, Ordering::Relaxed);
} else if old_len > new_len {
let delta = old_len - new_len;
let mut current = self.total_count.load(Ordering::Relaxed);
loop {
let next = current.saturating_sub(delta);
match self.total_count.compare_exchange_weak(
current,
next,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => break,
Err(actual) => current = actual,
}
}
}
}
// --- AIS ---
fn prune_ais(history: &mut VecDeque<(Instant, AisMessage)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_ais_message(&self, mut msg: AisMessage) {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.ais, "ais_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ais(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_ais_history(&self) -> Vec<AisMessage> {
let mut h = lock_or_recover(&self.ais, "ais_history");
let before = h.len();
Self::prune_ais(&mut h);
self.adjust_total_count(before, h.len());
h.iter().map(|(_, msg)| msg.clone()).collect()
}
// --- VDES ---
fn prune_vdes(history: &mut VecDeque<(Instant, VdesMessage)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_vdes_message(&self, mut msg: VdesMessage) {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.vdes, "vdes_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_vdes(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_vdes_history(&self) -> Vec<VdesMessage> {
let mut h = lock_or_recover(&self.vdes, "vdes_history");
let before = h.len();
Self::prune_vdes(&mut h);
self.adjust_total_count(before, h.len());
h.iter().map(|(_, msg)| msg.clone()).collect()
}
// --- APRS ---
fn prune_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_aprs_packet(&self, mut pkt: AprsPacket) {
if !pkt.crc_ok {
return;
}
if pkt.ts_ms.is_none() {
pkt.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.aprs, "aprs_history");
let before = h.len();
h.push_back((Instant::now(), pkt));
Self::prune_aprs(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_aprs_history(&self) -> Vec<AprsPacket> {
let mut h = lock_or_recover(&self.aprs, "aprs_history");
let before = h.len();
Self::prune_aprs(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, pkt): &(Instant, AprsPacket)| pkt.clone())
.collect()
}
pub fn clear_aprs_history(&self) {
let mut h = lock_or_recover(&self.aprs, "aprs_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- HF APRS ---
fn prune_hf_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_hf_aprs_packet(&self, mut pkt: AprsPacket) {
if !pkt.crc_ok {
return;
}
if pkt.ts_ms.is_none() {
pkt.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
let before = h.len();
h.push_back((Instant::now(), pkt));
Self::prune_hf_aprs(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_hf_aprs_history(&self) -> Vec<AprsPacket> {
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
let before = h.len();
Self::prune_hf_aprs(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, pkt): &(Instant, AprsPacket)| pkt.clone())
.collect()
}
pub fn clear_hf_aprs_history(&self) {
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- CW ---
fn prune_cw(history: &mut VecDeque<(Instant, CwEvent)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_cw_event(&self, evt: CwEvent) {
let mut h = lock_or_recover(&self.cw, "cw_history");
let before = h.len();
h.push_back((Instant::now(), evt));
Self::prune_cw(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_cw_history(&self) -> Vec<CwEvent> {
let mut h = lock_or_recover(&self.cw, "cw_history");
let before = h.len();
Self::prune_cw(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, evt): &(Instant, CwEvent)| evt.clone())
.collect()
}
pub fn clear_cw_history(&self) {
let mut h = lock_or_recover(&self.cw, "cw_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- FT8 ---
fn prune_ft8(history: &mut VecDeque<(Instant, Ft8Message)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_ft8_message(&self, msg: Ft8Message) {
let mut h = lock_or_recover(&self.ft8, "ft8_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ft8(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_ft8_history(&self) -> Vec<Ft8Message> {
let mut h = lock_or_recover(&self.ft8, "ft8_history");
let before = h.len();
Self::prune_ft8(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
.collect()
}
pub fn clear_ft8_history(&self) {
let mut h = lock_or_recover(&self.ft8, "ft8_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- FT4 ---
fn prune_ft4(history: &mut VecDeque<(Instant, Ft8Message)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_ft4_message(&self, msg: Ft8Message) {
let mut h = lock_or_recover(&self.ft4, "ft4_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ft4(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_ft4_history(&self) -> Vec<Ft8Message> {
let mut h = lock_or_recover(&self.ft4, "ft4_history");
let before = h.len();
Self::prune_ft4(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
.collect()
}
pub fn clear_ft4_history(&self) {
let mut h = lock_or_recover(&self.ft4, "ft4_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- FT2 ---
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
fn prune_ft2(history: &mut VecDeque<(Instant, Ft8Message)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
pub fn record_ft2_message(&self, msg: Ft8Message) {
let mut h = lock_or_recover(&self.ft2, "ft2_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ft2(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
#[cfg_attr(not(feature = "ft2"), allow(dead_code))]
pub fn snapshot_ft2_history(&self) -> Vec<Ft8Message> {
let mut h = lock_or_recover(&self.ft2, "ft2_history");
let before = h.len();
Self::prune_ft2(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
.collect()
}
pub fn clear_ft2_history(&self) {
let mut h = lock_or_recover(&self.ft2, "ft2_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- WSPR ---
fn prune_wspr(history: &mut VecDeque<(Instant, WsprMessage)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_wspr_message(&self, msg: WsprMessage) {
let mut h = lock_or_recover(&self.wspr, "wspr_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_wspr(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_wspr_history(&self) -> Vec<WsprMessage> {
let mut h = lock_or_recover(&self.wspr, "wspr_history");
let before = h.len();
Self::prune_wspr(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg): &(Instant, WsprMessage)| msg.clone())
.collect()
}
pub fn clear_wspr_history(&self) {
let mut h = lock_or_recover(&self.wspr, "wspr_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- LRPT ---
fn prune_lrpt(history: &mut VecDeque<(Instant, LrptImage)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_lrpt_image(&self, mut img: LrptImage) {
if img.ts_ms.is_none() {
img.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
let before = h.len();
h.push_back((Instant::now(), img));
Self::prune_lrpt(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_lrpt_history(&self) -> Vec<LrptImage> {
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
let before = h.len();
Self::prune_lrpt(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, img): &(Instant, LrptImage)| img.clone())
.collect()
}
pub fn clear_lrpt_history(&self) {
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
// --- WEFAX ---
fn prune_wefax(history: &mut VecDeque<(Instant, WefaxMessage)>) {
prune_by_age(history, HISTORY_RETENTION, Instant::now());
}
pub fn record_wefax_message(&self, mut msg: WefaxMessage) {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
// Strip bulk PNG data before storing in memory/persistence.
msg.png_data = None;
let mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_wefax(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_wefax_history(&self) -> Vec<WefaxMessage> {
let mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len();
Self::prune_wefax(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg)| {
let mut m = msg.clone();
// Re-read PNG from disk so remote clients can save a local copy.
if m.png_data.is_none() {
if let Some(ref path) = m.path {
if let Ok(bytes) = std::fs::read(path) {
m.png_data =
Some(base64::engine::general_purpose::STANDARD.encode(&bytes));
}
}
}
m
})
.collect()
}
pub fn clear_wefax_history(&self) {
let mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
/// Returns a quick (non-pruning) estimate of the total number of history
/// entries across all decoders, used for pre-allocating the replay blob.
///
/// Uses an `AtomicUsize` counter maintained by record/prune/clear methods,
/// avoiding 11 separate mutex acquisitions.
pub fn estimated_total_count(&self) -> usize {
self.total_count.load(Ordering::Relaxed)
}
/// Rebuild the aggregate count after bulk restoration bypasses the normal
/// record methods.
pub(crate) fn recalculate_total_count(&self) {
let total = lock_or_recover(&self.ais, "ais_history").len()
+ lock_or_recover(&self.vdes, "vdes_history").len()
+ lock_or_recover(&self.aprs, "aprs_history").len()
+ lock_or_recover(&self.hf_aprs, "hf_aprs_history").len()
+ lock_or_recover(&self.cw, "cw_history").len()
+ lock_or_recover(&self.ft8, "ft8_history").len()
+ lock_or_recover(&self.ft4, "ft4_history").len()
+ lock_or_recover(&self.ft2, "ft2_history").len()
+ lock_or_recover(&self.wspr, "wspr_history").len()
+ lock_or_recover(&self.lrpt, "lrpt_history").len()
+ lock_or_recover(&self.wefax, "wefax_history").len();
self.total_count.store(total, Ordering::Relaxed);
}
}
+56
View File
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Shared retention and synchronization policy for decoder histories.
use std::collections::VecDeque;
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, Instant};
pub(crate) const HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
/// Maximum entries per decoder history queue. Oldest entries are evicted on
/// busy channels independently of time-based pruning.
pub(crate) const MAX_HISTORY_ENTRIES: usize = 10_000;
pub(crate) fn current_timestamp_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or(0)
}
pub(crate) fn lock_or_recover<'a, T>(mutex: &'a Mutex<T>, label: &str) -> MutexGuard<'a, T> {
mutex.lock().unwrap_or_else(|error| {
tracing::warn!(
"Mutex for {} was poisoned (prior panic); recovering with potentially inconsistent data",
label
);
error.into_inner()
})
}
pub(crate) fn enforce_capacity<T>(deque: &mut VecDeque<T>, max: usize) {
while deque.len() > max {
deque.pop_front();
}
}
/// Drop entries older than `retention` from an ordered history queue.
pub(crate) fn prune_by_age<T>(
deque: &mut VecDeque<(Instant, T)>,
retention: Duration,
now: Instant,
) {
let Some(cutoff) = now.checked_sub(retention) else {
return;
};
while let Some((timestamp, _)) = deque.front() {
if *timestamp < cutoff {
deque.pop_front();
} else {
break;
}
}
}
+2
View File
@@ -4,7 +4,9 @@
mod audio;
mod config;
mod decoder_history;
mod error;
mod history_policy;
mod history_store;
mod listener;
mod rig_handle;