Compare commits

..
Author SHA1 Message Date
sjg 730fd7a1a3 [refactor](trx-server): extract decoder history store
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 1s
2026-08-01 01:42:42 +02:00
sjg 8789ba8efa [refactor](trx-server): extract history policy
CI / lint (pull_request) Failing after 0s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 2s
2026-08-01 01:39:11 +02:00
sjg fc98c2a974 [refactor](trx-frontend): define module service boundaries 2026-08-01 01:38:16 +02:00
sjg 98b396bd98 [fix](trx-frontend): vendor Opus decoder 2026-08-01 01:31:42 +02:00
28 changed files with 173 additions and 1404 deletions
+1 -2
View File
@@ -274,8 +274,7 @@ mod tests {
// Pseudo-random noise vs gradient — correlation should be low. // Pseudo-random noise vs gradient — correlation should be low.
let noise: Vec<u8> = (0..256) let noise: Vec<u8> = (0..256)
.map(|i| (i as u32).wrapping_mul(1_103_515_245).wrapping_add(12_345)) .map(|i| ((i * 1103515245 + 12345) as u32 >> 8 & 0xff) as u8)
.map(|value| ((value >> 8) & 0xff) as u8)
.collect(); .collect();
let r = asm.correlation_with_last(&noise).expect("r"); let r = asm.correlation_with_last(&noise).expect("r");
assert!( assert!(
@@ -23,7 +23,6 @@ window.onDecoderRegistryReady = function (fn) {
for (const fn of _decoderRegistryReadyCallbacks) fn(); for (const fn of _decoderRegistryReadyCallbacks) fn();
_decoderRegistryReadyCallbacks.length = 0; _decoderRegistryReadyCallbacks.length = 0;
hideUnsupportedDecoderTabs(); hideUnsupportedDecoderTabs();
refreshOperatorLayoutCapabilities();
} }
} catch (e) { } catch (e) {
console.error("Failed to fetch decoder registry:", e); console.error("Failed to fetch decoder registry:", e);
@@ -334,19 +333,8 @@ function applyCapabilities(caps) {
const txAudioBtn = document.getElementById("tx-audio-btn"); const txAudioBtn = document.getElementById("tx-audio-btn");
const txVolSlider = document.getElementById("tx-vol"); const txVolSlider = document.getElementById("tx-vol");
const txVolControl = txVolSlider ? txVolSlider.closest(".vol-label") : null; const txVolControl = txVolSlider ? txVolSlider.closest(".vol-label") : null;
const hasPowerControl = !caps.filter_controls; if (txPowerCol) txPowerCol.style.display = caps.tx ? "" : "none";
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 (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 (txMetersRow) txMetersRow.style.display = caps.tx ? "" : "none";
if (txAudioBtn) txAudioBtn.style.display = caps.tx ? "" : "none"; if (txAudioBtn) txAudioBtn.style.display = caps.tx ? "" : "none";
if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none"; if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none";
@@ -356,7 +344,7 @@ function applyCapabilities(caps) {
// TX limit row // TX limit row
const txLimitRow = document.getElementById("tx-limit-row"); const txLimitRow = document.getElementById("tx-limit-row");
if (txLimitRow) txLimitRow.style.display = caps.tx_limit ? "" : "none"; if (txLimitRow && !caps.tx_limit) txLimitRow.style.display = "none";
// VFO row // VFO row
const vfoRow = document.getElementById("vfo-row"); const vfoRow = document.getElementById("vfo-row");
@@ -448,7 +436,6 @@ const signalSplitValueEl = document.getElementById("signal-split-value");
const overviewPeakHoldEl = document.getElementById("overview-peak-hold"); const overviewPeakHoldEl = document.getElementById("overview-peak-hold");
const themeToggleBtn = document.getElementById("theme-toggle"); const themeToggleBtn = document.getElementById("theme-toggle");
const headerRigSwitchSelect = document.getElementById("header-rig-switch-select"); const headerRigSwitchSelect = document.getElementById("header-rig-switch-select");
const headerRigSummary = document.getElementById("header-rig-summary");
const headerStylePickSelect = document.getElementById("header-style-pick-select"); const headerStylePickSelect = document.getElementById("header-style-pick-select");
const rdsPsOverlay = document.getElementById("rds-ps-overlay"); const rdsPsOverlay = document.getElementById("rds-ps-overlay");
const tabMainEl = document.getElementById("tab-main"); const tabMainEl = document.getElementById("tab-main");
@@ -853,8 +840,6 @@ let jogMult = loadSetting("jogMult", 1); // divisor: 1, 10, 100
let jogStep = Math.max(Math.round(jogUnit / jogMult), 1); let jogStep = Math.max(Math.round(jogUnit / jogMult), 1);
let minFreqStepHz = 1; let minFreqStepHz = 1;
let lastModeName = ""; let lastModeName = "";
let lastWfmCci = 0;
let lastWfmAci = 0;
const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"]; const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"];
function vfoColor(idx) { function vfoColor(idx) {
if (idx < VFO_COLORS.length) return VFO_COLORS[idx]; if (idx < VFO_COLORS.length) return VFO_COLORS[idx];
@@ -908,7 +893,6 @@ async function restorePreviousTuneState() {
let lastRigIds = []; let lastRigIds = [];
let lastRigDisplayNames = {}; let lastRigDisplayNames = {};
let lastActiveRigId = null; let lastActiveRigId = null;
let rigSwitchInProgress = false;
let lastCityLabel = ""; let lastCityLabel = "";
let sseSessionId = null; let sseSessionId = null;
const originalTitle = document.title; const originalTitle = document.title;
@@ -1253,20 +1237,6 @@ function populateRigPicker(selectEl, rigIds, activeRigId, disabled) {
selectEl.disabled = 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) { function updateRigSubtitle(activeRigId) {
if (!rigSubtitle) return; if (!rigSubtitle) return;
const name = (activeRigId && lastRigDisplayNames[activeRigId]) || activeRigId || "--"; const name = (activeRigId && lastRigDisplayNames[activeRigId]) || activeRigId || "--";
@@ -1303,8 +1273,6 @@ function applyRigList(activeRigId, rigIds, displayNames) {
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId); updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
if (rigListChanged) { if (rigListChanged) {
if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId);
if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId);
@@ -1335,7 +1303,6 @@ async function refreshRigList() {
} }
}); });
serverRigs = rigs; serverRigs = rigs;
refreshOperatorLayoutCapabilities();
serverActiveRigId = data.active_remote || null; serverActiveRigId = data.active_remote || null;
applyRigList(data.active_remote, rigIds, displayNames); applyRigList(data.active_remote, rigIds, displayNames);
window.trx.modules.map?.syncAprsReceiverMarker(); window.trx.modules.map?.syncAprsReceiverMarker();
@@ -1344,26 +1311,10 @@ async function refreshRigList() {
} }
} }
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) { function showHint(msg, duration) {
powerHint.textContent = msg; powerHint.textContent = msg;
if (hintTimer) clearTimeout(hintTimer); if (hintTimer) clearTimeout(hintTimer);
if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration); 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 supportedModes = [];
let supportedBands = []; let supportedBands = [];
@@ -2878,7 +2829,7 @@ function showUnsupportedFreqPopup(hz) {
const now = Date.now(); const now = Date.now();
if (now - lastUnsupportedFreqPopupAt < 1200) return; if (now - lastUnsupportedFreqPopupAt < 1200) return;
lastUnsupportedFreqPopupAt = now; lastUnsupportedFreqPopupAt = now;
window.trxUi?.notify(message.replaceAll("\n", " "), { kind: "error", duration: 7000 }); window.alert(message);
} }
// Convert dBm (wire format) to S-units (S1=-121dBm, S9=-73dBm, 6dB/S-unit). // Convert dBm (wire format) to S-units (S1=-121dBm, S9=-73dBm, 6dB/S-unit).
@@ -3342,14 +3293,8 @@ function render(update) {
wfmStFlagEl.classList.toggle("wfm-st-flag-stereo", detected); wfmStFlagEl.classList.toggle("wfm-st-flag-stereo", detected);
wfmStFlagEl.classList.toggle("wfm-st-flag-mono", !detected); wfmStFlagEl.classList.toggle("wfm-st-flag-mono", !detected);
} }
if (typeof update.filter.wfm_cci === "number") { if (typeof update.filter.wfm_cci === "number") updateIntfBar(wfmCciFillEl, wfmCciValEl, update.filter.wfm_cci);
lastWfmCci = Math.max(0, Math.min(100, update.filter.wfm_cci)); if (typeof update.filter.wfm_aci === "number") updateIntfBar(wfmAciFillEl, wfmAciValEl, update.filter.wfm_aci);
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") { if (samStereoWidthEl && typeof update.filter.sam_stereo_width === "number") {
samStereoWidthEl.value = String(Math.round(update.filter.sam_stereo_width * 100)); samStereoWidthEl.value = String(Math.round(update.filter.sam_stereo_width * 100));
} }
@@ -3476,11 +3421,7 @@ function render(update) {
if (update.status && typeof update.status.tx_en === "boolean" && update.status.tx_en !== prevRenderData.txEn) { if (update.status && typeof update.status.tx_en === "boolean" && update.status.tx_en !== prevRenderData.txEn) {
prevRenderData.txEn = update.status.tx_en; prevRenderData.txEn = update.status.tx_en;
lastTxEn = update.status.tx_en; lastTxEn = update.status.tx_en;
window.trxUi?.setButtonState(pttBtn, { pttBtn.textContent = update.status.tx_en ? "PTT On" : "PTT Off";
active: update.status.tx_en,
activeLabel: "Stop TX",
inactiveLabel: "Start TX",
});
if (update.status.tx_en) { if (update.status.tx_en) {
pttBtn.style.background = "var(--accent-red)"; pttBtn.style.background = "var(--accent-red)";
pttBtn.style.borderColor = "var(--accent-red)"; pttBtn.style.borderColor = "var(--accent-red)";
@@ -3589,15 +3530,11 @@ function render(update) {
bandLabel.textContent = typeof update.band === "string" ? update.band : "--"; bandLabel.textContent = typeof update.band === "string" ? update.band : "--";
} }
if (typeof update.enabled === "boolean") { if (typeof update.enabled === "boolean") {
window.trxUi?.setButtonState(powerBtn, { powerBtn.disabled = false;
active: update.enabled, powerBtn.textContent = update.enabled ? "Power Off" : "Power On";
activeLabel: "Power Off",
inactiveLabel: "Power On",
});
} else { } else {
powerBtn.disabled = true; powerBtn.disabled = true;
powerBtn.textContent = "Power unavailable"; powerBtn.textContent = "Toggle Power";
powerBtn.setAttribute("aria-pressed", "false");
powerHint.textContent = "State unknown"; powerHint.textContent = "State unknown";
} }
lastControl = update.enabled; lastControl = update.enabled;
@@ -3712,11 +3649,7 @@ function render(update) {
} }
powerHint.textContent = readyText(); powerHint.textContent = readyText();
lastLocked = update.status && update.status.lock === true; lastLocked = update.status && update.status.lock === true;
window.trxUi?.setButtonState(lockBtn, { lockBtn.textContent = lastLocked ? "Unlock" : "Lock";
active: lastLocked,
activeLabel: "Unlock Tuning",
inactiveLabel: "Lock Tuning",
});
const tx = update.status && update.status.tx ? update.status.tx : null; const tx = update.status && update.status.tx ? update.status.tx : null;
txMeters.style.display = lastHasTx ? "" : "none"; txMeters.style.display = lastHasTx ? "" : "none";
@@ -3903,16 +3836,12 @@ function scheduleUiFrameJob(key, job) {
window.trxScheduleUiFrameJob = scheduleUiFrameJob; window.trxScheduleUiFrameJob = scheduleUiFrameJob;
async function postPath(path, options = {}) { async function postPath(path) {
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. // Auto-append remote so each tab targets its own rig.
// Skip when the caller already included remote (e.g. /select_rig). // Skip when the caller already included remote (e.g. /select_rig).
if (targetRigId && !path.includes("remote=")) { if (lastActiveRigId && !path.includes("remote=")) {
const sep = path.includes("?") ? "&" : "?"; const sep = path.includes("?") ? "&" : "?";
path = `${path}${sep}remote=${encodeURIComponent(targetRigId)}`; path = `${path}${sep}remote=${encodeURIComponent(lastActiveRigId)}`;
} }
const resp = await fetch(path, { method: "POST" }); const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) { if (authEnabled && resp.status === 401) {
@@ -3957,60 +3886,44 @@ async function switchRigFromSelect(selectEl) {
return; return;
} }
const prevRig = lastActiveRigId; const prevRig = lastActiveRigId;
const nextRig = selectEl.value; lastActiveRigId = selectEl.value;
if (nextRig === prevRig || rigSwitchInProgress) return; if (prevRig && prevRig !== lastActiveRigId) {
rigSwitchInProgress = true; resetDecoderStateOnRigSwitch();
setControlPending(selectEl, true); }
selectEl.closest(".header-rig-switch")?.classList.add("is-switching"); updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(nextRig, true); if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId);
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`); if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId);
if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || "");
window.trx.modules.map?.syncAprsReceiverMarker();
// Switch this session's rig and reconnect SSE to the new rig's
// state channel.
try { try {
const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : ""; const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : "";
await postPath(`/select_rig?remote=${encodeURIComponent(nextRig)}${sidParam}`, { allowDuringRigSwitch: true, remote: null }); await postPath(`/select_rig?remote=${encodeURIComponent(selectEl.value)}${sidParam}`);
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.modules.map?.syncAprsReceiverMarker();
connect(); connect();
stopSpectrumStreaming();
startSpectrumStreaming();
stopMeterStreaming();
startMeterStreaming();
if (rxActive) {
stopRxAudio();
startRxAudio();
}
showHint(`Rig: ${lastRigDisplayNames[lastActiveRigId] || lastActiveRigId}`, 1500);
} catch (err) { } catch (err) {
console.error("select_rig failed:", 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");
} }
// 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);
} }
if (headerRigSwitchSelect) { if (headerRigSwitchSelect) {
headerRigSwitchSelect.addEventListener("change", () => { switchRigFromSelect(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.addEventListener("click", async () => {
setControlPending(powerBtn, true); powerBtn.disabled = true;
showHint("Sending..."); showHint("Sending...");
try { try {
await postPath("/toggle_power"); await postPath("/toggle_power");
@@ -4019,12 +3932,12 @@ powerBtn.addEventListener("click", async () => {
showHint("Toggle failed", 2000); showHint("Toggle failed", 2000);
console.error(err); console.error(err);
} finally { } finally {
setControlPending(powerBtn, false); powerBtn.disabled = false;
} }
}); });
pttBtn.addEventListener("click", async () => { pttBtn.addEventListener("click", async () => {
setControlPending(pttBtn, true); pttBtn.disabled = true;
showHint("Toggling PTT…"); showHint("Toggling PTT…");
try { try {
const desired = lastTxEn ? "false" : "true"; const desired = lastTxEn ? "false" : "true";
@@ -4034,7 +3947,7 @@ pttBtn.addEventListener("click", async () => {
showHint("PTT toggle failed", 2000); showHint("PTT toggle failed", 2000);
console.error(err); console.error(err);
} finally { } finally {
setControlPending(pttBtn, false); pttBtn.disabled = false;
} }
}); });
@@ -4067,7 +3980,7 @@ async function applyCenterFreqFromInput() {
return; return;
} }
centerFreqDirty = false; centerFreqDirty = false;
setControlPending(centerFreqEl, true); centerFreqEl.disabled = true;
showHint("Setting central frequency…"); showHint("Setting central frequency…");
try { try {
await postPath(`/set_center_freq?hz=${parsed}`); await postPath(`/set_center_freq?hz=${parsed}`);
@@ -4076,7 +3989,7 @@ async function applyCenterFreqFromInput() {
showHint("Set central freq failed", 2000); showHint("Set central freq failed", 2000);
console.error(err); console.error(err);
} finally { } finally {
setControlPending(centerFreqEl, false); centerFreqEl.disabled = false;
} }
} }
@@ -4277,7 +4190,7 @@ async function applyModeFromPicker() {
return; return;
} }
updateWfmControls(); updateWfmControls();
setControlPending(modeEl, true); modeEl.disabled = true;
showHint("Setting mode…"); showHint("Setting mode…");
try { try {
if (typeof vchanInterceptMode === "function" && await vchanInterceptMode(mode)) { if (typeof vchanInterceptMode === "function" && await vchanInterceptMode(mode)) {
@@ -4295,7 +4208,7 @@ async function applyModeFromPicker() {
showHint("Set mode failed", 2000); showHint("Set mode failed", 2000);
console.error(err); console.error(err);
} finally { } finally {
setControlPending(modeEl, false); modeEl.disabled = false;
} }
} }
@@ -4314,7 +4227,7 @@ txLimitBtn.addEventListener("click", async () => {
showHint("Limit missing", 1500); showHint("Limit missing", 1500);
return; return;
} }
setControlPending(txLimitBtn, true); txLimitBtn.disabled = true;
showHint("Setting TX limit…"); showHint("Setting TX limit…");
try { try {
await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`); await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`);
@@ -4323,22 +4236,22 @@ txLimitBtn.addEventListener("click", async () => {
showHint("TX limit failed", 2000); showHint("TX limit failed", 2000);
console.error(err); console.error(err);
} finally { } finally {
setControlPending(txLimitBtn, false); txLimitBtn.disabled = false;
} }
}); });
lockBtn.addEventListener("click", async () => { lockBtn.addEventListener("click", async () => {
setControlPending(lockBtn, true); lockBtn.disabled = true;
showHint("Toggling lock…"); showHint("Toggling lock…");
try { try {
const nextLock = !lastLocked; const nextLock = lockBtn.textContent === "Lock";
await postPath(nextLock ? "/lock" : "/unlock"); await postPath(nextLock ? "/lock" : "/unlock");
showHint("Lock toggled", 1500); showHint("Lock toggled", 1500);
} catch (err) { } catch (err) {
showHint("Lock toggle failed", 2000); showHint("Lock toggle failed", 2000);
console.error(err); console.error(err);
} finally { } finally {
setControlPending(lockBtn, false); lockBtn.disabled = false;
} }
}); });
@@ -4355,7 +4268,7 @@ const MODE_BW_DEFAULTS = {
FM: [12_500, 2_500, 25_000, 500], FM: [12_500, 2_500, 25_000, 500],
AIS: [25_000, 12_500, 50_000, 500], AIS: [25_000, 12_500, 50_000, 500],
VDES: [100_000, 25_000, 200_000, 1_000], VDES: [100_000, 25_000, 200_000, 1_000],
WFM: [180_000, 60_000,300_000,5_000], WFM: [180_000, 50_000,300_000,5_000],
DIG: [3_000, 300, 6_000, 100], DIG: [3_000, 300, 6_000, 100],
PKT: [25_000, 300, 50_000, 500], PKT: [25_000, 300, 50_000, 500],
}; };
@@ -4405,8 +4318,7 @@ async function applyBwDefaultForMode(mode, sendToServer) {
scheduleSpectrumDraw(); scheduleSpectrumDraw();
} }
if (sendToServer) { if (sendToServer) {
try { await postPath(`/set_bandwidth?hz=${def}`); } try { await postPath(`/set_bandwidth?hz=${def}`); } catch (_) {}
catch (error) { window.trxUi?.notify("Default bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: () => applyBwDefaultForMode(mode, true) } }); }
} }
} }
@@ -4433,115 +4345,67 @@ async function applyBandwidthFromInput() {
if (Number.isFinite(lastFreqHz)) { if (Number.isFinite(lastFreqHz)) {
await ensureTunedBandwidthCoverage(lastFreqHz); await ensureTunedBandwidthCoverage(lastFreqHz);
} }
} catch (error) { } catch (_) {}
window.trxUi?.notify("Bandwidth could not be changed", { kind: "error", action: { label: "Retry", run: applyBandwidthFromInput } });
}
} }
function estimateOccupiedBandwidth(data, centerHz, interference = {}) { function estimateBandwidthAroundPeak(data, centerHz) {
if (!data || !isBinsArray(data.bins) || data.bins.length < 3 || !Number.isFinite(centerHz)) { if (!data || !isBinsArray(data.bins) || data.bins.length < 3 || !Number.isFinite(centerHz)) {
return null; return null;
} }
const bins = data.bins; const bins = data.bins;
const maxIdx = bins.length - 1; const maxIdx = bins.length - 1;
const hzPerBin = data.sample_rate / maxIdx;
const fullLoHz = data.center_hz - data.sample_rate / 2; const fullLoHz = data.center_hz - data.sample_rate / 2;
const centerIdx = Math.max( const centerIdx = Math.max(
1, 1,
Math.min(maxIdx - 1, Math.round(((centerHz - fullLoHz) / data.sample_rate) * maxIdx)), Math.min(maxIdx - 1, Math.round(((centerHz - fullLoHz) / data.sample_rate) * maxIdx)),
); );
const mode = (modeEl ? modeEl.value : "USB").toUpperCase(); const searchRadius = Math.max(6, Math.min(120, Math.round(maxIdx * 0.03)));
const [defaultBw, minBw, maxBw, stepBw] = mwDefaultsForMode(mode); const searchLo = Math.max(1, centerIdx - searchRadius);
const oneSided = mode === "USB" || mode === "DIG" || mode === "CW" const searchHi = Math.min(maxIdx - 1, centerIdx + searchRadius);
? 1
: mode === "LSB" || mode === "CWR" ? -1 : 0; let peakIdx = centerIdx;
const isWfm = mode === "WFM"; 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 sorted = [...bins].sort((a, b) => a - b);
const noise = sorted[Math.floor(sorted.length * 0.2)]; const noise = sorted[Math.floor(sorted.length * 0.2)];
const maxSpanBins = Math.max(2, Math.ceil(maxBw / hzPerBin)); const peak = bins[peakIdx];
const searchHalfBins = oneSided === 0 ? Math.ceil(maxSpanBins / 2) : maxSpanBins; const threshold = Math.max(noise + 4, peak - Math.max(8, (peak - noise) * 0.35));
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;
// A threshold relative to the noise floor finds occupied bandwidth much let left = peakIdx;
// more reliably than one relative to the peak. The latter fails for WFM, let right = peakIdx;
// whose multiplex spectrum has peaks, notches, and no narrow centre carrier. let belowCount = 0;
const threshold = noise + Math.max(3, Math.min(isWfm ? 6 : 10, snr * (isWfm ? 0.18 : 0.28))); for (let i = peakIdx; i > 1; i--) {
const allowedGap = Math.max(isWfm ? 4 : 2, Math.ceil((isWfm ? 12_000 : stepBw) / hzPerBin)); if (bins[i] < threshold) belowCount += 1;
else belowCount = 0;
function occupiedExtent(direction, limitBins) { if (belowCount >= 2) break;
let lastOccupied = centerIdx; left = i;
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;
} }
let rawBw; belowCount = 0;
if (oneSided !== 0) { for (let i = peakIdx; i < maxIdx - 1; i++) {
rawBw = occupiedExtent(oneSided, maxSpanBins); if (bins[i] < threshold) belowCount += 1;
} else { else belowCount = 0;
const leftHz = occupiedExtent(-1, searchHalfBins); if (belowCount >= 2) break;
const rightHz = occupiedExtent(1, searchHalfBins); right = i;
// A symmetric RF filter must contain the larger of the two sidebands.
rawBw = 2 * Math.max(leftHz, rightHz);
} }
// Add a transition-band margin. Weak WFM deliberately falls back to the const shoulderPad = Math.max(1, Math.round((right - left) * 0.08));
// 60 kHz mode floor above: a narrower filter trades stereo/RDS content for left = Math.max(0, left - shoulderPad);
// a useful improvement in intelligibility when the signal is very poor. right = Math.min(maxIdx, right + shoulderPad);
rawBw *= isWfm ? 1.08 : 1.12;
if (isWfm) { const hzPerBin = data.sample_rate / maxIdx;
const aci = Math.max(0, Math.min(100, Number(interference.aci) || 0)) / 100; const rawBw = Math.max(hzPerBin, (right - left) * hzPerBin);
const cci = Math.max(0, Math.min(100, Number(interference.cci) || 0)) / 100; const [, minBw, maxBw, stepBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB");
// 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)); const clamped = Math.max(minBw, Math.min(maxBw, rawBw));
return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw); return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw);
} }
async function applyAutoBandwidth() { async function applyAutoBandwidth() {
if (!lastSpectrumData || lastFreqHz == null) return; if (!lastSpectrumData || lastFreqHz == null) return;
// WFM interference telemetry belongs to the primary DSP channel. Do not const estimated = estimateBandwidthAroundPeak(lastSpectrumData, lastFreqHz);
// 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) { if (!Number.isFinite(estimated) || estimated <= 0) {
syncBandwidthInput(currentBandwidthHz); syncBandwidthInput(currentBandwidthHz);
return; return;
@@ -4553,24 +4417,13 @@ async function applyAutoBandwidth() {
if (lastSpectrumData) { if (lastSpectrumData) {
scheduleSpectrumDraw(); 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 { try {
if (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(estimated)) return; if (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(estimated)) return;
await postPath(`/set_bandwidth?hz=${estimated}`); await postPath(`/set_bandwidth?hz=${estimated}`);
if (Number.isFinite(lastFreqHz)) { if (Number.isFinite(lastFreqHz)) {
await ensureTunedBandwidthCoverage(lastFreqHz); await ensureTunedBandwidthCoverage(lastFreqHz);
} }
} catch (error) { } catch (_) {}
window.trxUi?.notify("Automatic bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: applyAutoBandwidth } });
}
} }
if (spectrumBwInput) { if (spectrumBwInput) {
@@ -4659,7 +4512,6 @@ function _initMapWhenReady() {
} }
function navigateToTab(name, options = {}) { function navigateToTab(name, options = {}) {
window.trxUi?.closeMobileOverlays?.();
const { updateHistory = true, replaceHistory = false } = options; const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") { if (authEnabled && !authRole && name !== "main") {
showAuthGate(false); showAuthGate(false);
@@ -4670,7 +4522,6 @@ function navigateToTab(name, options = {}) {
_activeTab = name; _activeTab = name;
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active"); btn.classList.add("active");
window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn);
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const panel = document.getElementById(`tab-${name}`); const panel = document.getElementById(`tab-${name}`);
panel.style.display = ""; panel.style.display = "";
@@ -4699,7 +4550,6 @@ function navigateToTab(name, options = {}) {
refreshRecorderStatus(); refreshRecorderStatus();
} }
} }
window.navigateToTab = navigateToTab;
document.querySelector(".tab-bar").addEventListener("click", (e) => { document.querySelector(".tab-bar").addEventListener("click", (e) => {
const btn = e.target.closest(".tab[data-tab]"); const btn = e.target.closest(".tab[data-tab]");
@@ -4858,7 +4708,7 @@ if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => { headerAuthBtn.addEventListener("click", async () => {
if (authRole) { if (authRole) {
// Logged in - show logout confirmation // Logged in - show logout confirmation
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) { if (confirm("Are you sure you want to logout?")) {
await authLogout(); await authLogout();
} }
} else { } else {
@@ -5059,15 +4909,11 @@ function latLonToMaidenhead(lat, lon) {
function _wireSubTabBar(bar) { function _wireSubTabBar(bar) {
if (bar._subtabWired) return; if (bar._subtabWired) return;
bar._subtabWired = true; bar._subtabWired = true;
window.trxUi?.prepareTabList(bar, "secondary");
bar.addEventListener("click", (e) => { bar.addEventListener("click", (e) => {
const btn = e.target.closest(".sub-tab[data-subtab]"); const btn = e.target.closest(".sub-tab[data-subtab]");
if (!btn) return; if (!btn) return;
bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active")); bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("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; const parent = bar.parentElement;
parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none"); parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none");
const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`); const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`);
@@ -5558,7 +5404,6 @@ function configureRxStream(nextInfo) {
ensureRxAudioContext(nextSampleRate); ensureRxAudioContext(nextSampleRate);
rxGainNode.gain.value = rxVolSlider.value / 100; rxGainNode.gain.value = rxVolSlider.value / 100;
rxActive = true; rxActive = true;
window.trxUi?.setButtonState(rxAudioBtn, { active: true, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
setAudioLevel(0); setAudioLevel(0);
rxAudioBtn.style.borderColor = "#00d17f"; rxAudioBtn.style.borderColor = "#00d17f";
rxAudioBtn.style.color = "#00d17f"; rxAudioBtn.style.color = "#00d17f";
@@ -5760,7 +5605,6 @@ function startRxAudio() {
// If TX was active when WS closed, release PTT // If TX was active when WS closed, release PTT
if (txActive) { stopTxAudio(); } if (txActive) { stopTxAudio(); }
rxActive = false; rxActive = false;
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
streamInfo = null; streamInfo = null;
updateWfmControls(); updateWfmControls();
rxAudioBtn.style.borderColor = ""; rxAudioBtn.style.borderColor = "";
@@ -5787,7 +5631,6 @@ function startRxAudio() {
function stopRxAudio() { function stopRxAudio() {
rxActive = false; rxActive = false;
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
streamInfo = null; streamInfo = null;
if (audioWs) { audioWs.close(); audioWs = null; } if (audioWs) { audioWs.close(); audioWs = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; } if (audioCtx) { audioCtx.close(); audioCtx = null; }
@@ -5826,7 +5669,6 @@ function startTxAudio() {
}).then(async (stream) => { }).then(async (stream) => {
txStream = stream; txStream = stream;
txActive = true; txActive = true;
window.trxUi?.setButtonState(txAudioBtn, { active: true, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" });
txAudioBtn.style.borderColor = "#e55353"; txAudioBtn.style.borderColor = "#e55353";
txAudioBtn.style.color = "#e55353"; txAudioBtn.style.color = "#e55353";
audioStatus.textContent = "RX+TX"; audioStatus.textContent = "RX+TX";
@@ -5904,7 +5746,6 @@ function startTxAudio() {
async function stopTxAudio() { async function stopTxAudio() {
if (!txActive) return; if (!txActive) return;
txActive = false; txActive = false;
window.trxUi?.setButtonState(txAudioBtn, { active: false, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" });
clearTxTimeout(); clearTxTimeout();
// Release PTT automatically // Release PTT automatically
@@ -6150,7 +5991,7 @@ function renderRecorderFiles() {
el.querySelectorAll(".rec-delete-btn").forEach(function (btn) { el.querySelectorAll(".rec-delete-btn").forEach(function (btn) {
btn.addEventListener("click", async function () { btn.addEventListener("click", async function () {
const name = btn.dataset.name; const name = btn.dataset.name;
if (!await window.trxUi.confirm({ title: "Delete recording?", message: `${name} will be permanently removed.`, confirmLabel: "Delete" })) return; if (!confirm("Delete recording " + name + "?")) return;
try { try {
const resp = await fetch("/api/recorder/files/" + encodeURIComponent(name), { method: "DELETE" }); const resp = await fetch("/api/recorder/files/" + encodeURIComponent(name), { method: "DELETE" });
if (!resp.ok) throw new Error("HTTP " + resp.status); if (!resp.ok) throw new Error("HTTP " + resp.status);
@@ -6158,7 +5999,6 @@ function renderRecorderFiles() {
renderRecorderFiles(); renderRecorderFiles();
} catch (e) { } catch (e) {
console.error("Delete failed", e); console.error("Delete failed", e);
window.trxUi?.notify("Recording could not be deleted", { kind: "error" });
} }
}); });
}); });
@@ -7095,13 +6935,6 @@ function startSpectrumStreaming() {
const rds = lastSpectrumData?.rds; const rds = lastSpectrumData?.rds;
lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds }; lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds };
window.lastSpectrumData = lastSpectrumData; 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. // Server confirmed a new center — clear optimistic pending value.
if (spectrumCenterPendingHz !== null && Math.abs(centerHz - spectrumCenterPendingHz) < 1000) { if (spectrumCenterPendingHz !== null && Math.abs(centerHz - spectrumCenterPendingHz) < 1000) {
spectrumCenterPendingHz = null; spectrumCenterPendingHz = null;
@@ -8564,12 +8397,7 @@ if (spectrumCanvas || overviewCanvas) {
await ensureTunedBandwidthCoverage(lastFreqHz, currentBandwidthHz); await ensureTunedBandwidthCoverage(lastFreqHz, currentBandwidthHz);
} }
} }
} catch (error) { } catch (_) {}
window.trxUi?.notify("Bandwidth could not be changed", {
kind: "error",
action: { label: "Retry", run: () => postPath(`/set_bandwidth?hz=${Math.round(currentBandwidthHz)}`) },
});
}
_bwDragEdge = null; _bwDragEdge = null;
_bwDragCanvas = null; _bwDragCanvas = null;
return; return;
@@ -43,7 +43,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="subtitle" id="location-subtitle" style="display:none;"></div> <div class="subtitle" id="location-subtitle" style="display:none;"></div>
</div> </div>
</div> </div>
<div class="tab-bar-nav" aria-label="Primary navigation"> <div class="tab-bar-nav">
<button class="tab active" data-tab="main"> <button class="tab active" data-tab="main">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-home"/></svg> <svg class="tab-icon" aria-hidden="true"><use href="#icon-home"/></svg>
<span class="tab-label">Main</span> <span class="tab-label">Main</span>
@@ -85,7 +85,6 @@ 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> <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"> <div class="header-rig-switch">
<select id="header-rig-switch-select" aria-label="Select active rig"></select> <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>
<div class="header-style-pick"> <div class="header-style-pick">
<select id="header-style-pick-select" aria-label="Select UI style"> <select id="header-style-pick-select" aria-label="Select UI style">
@@ -141,14 +140,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="spectrum-wrap"> <div class="spectrum-wrap">
<div id="spectrum-bookmark-axis"></div> <div id="spectrum-bookmark-axis"></div>
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></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" aria-describedby="spectrum-text-summary"></canvas> <canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display"></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-zoom-indicator" aria-hidden="true"></div>
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></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-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-bookmark-side-right" class="spectrum-bookmark-side spectrum-bookmark-side-right" aria-hidden="true"></div>
<div id="spectrum-tooltip"></div> <div id="spectrum-tooltip"></div>
<canvas id="spectrum-waterfall-canvas" tabindex="0" role="img" aria-label="Waterfall display" aria-describedby="spectrum-text-summary" style="display:none;"></canvas> <canvas id="spectrum-waterfall-canvas" tabindex="0" role="img" aria-label="Waterfall display" style="display:none;"></canvas>
<div id="spectrum-freq-axis"> <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-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> <button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">&rsaquo;</button>
@@ -206,12 +204,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="label"><span>Signal strength</span></div> <div class="label"><span>Signal strength</span></div>
</div> </div>
<div class="freq-field frequency-col"> <div class="freq-field frequency-col">
<input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" /> <input class="status-input" id="freq" type="text" value="--" />
<div class="label" id="freq-label"><span>Frequency</span></div> <div class="label"><span>Frequency</span></div>
</div> </div>
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;"> <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="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" /> <input class="status-input" id="center-freq" type="text" value="--" />
<div class="label" id="center-freq-label"><span>Center Frequency</span></div> <div class="label"><span>Center Frequency</span></div>
</div> </div>
<div class="freq-field unit-col"> <div class="freq-field unit-col">
<div class="jog-step" id="jog-step"> <div class="jog-step" id="jog-step">
@@ -237,7 +235,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="controls-col label-below-col"> <div class="controls-col label-below-col">
<div class="label"><span>Mode</span></div> <div class="label"><span>Mode</span></div>
<div class="inline"> <div class="inline">
<select class="status-input" id="mode" aria-label="Demodulation mode"></select> <select class="status-input" id="mode"></select>
</div> </div>
</div> </div>
<div class="controls-col controls-col-center"> <div class="controls-col controls-col-center">
@@ -309,9 +307,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="controls-col controls-col-power label-below-col" id="tx-power-col">
<div class="label"><span>Transmit / Power</span></div> <div class="label"><span>Transmit / Power</span></div>
<div class="btn-grid"> <div class="btn-grid">
<button id="ptt-btn" type="button" aria-pressed="false">Start TX</button> <button id="ptt-btn" type="button">Toggle PTT</button>
<button id="power-btn" type="button" aria-pressed="false">Power On</button> <button id="power-btn" type="button">Toggle Power</button>
<button id="lock-btn" type="button" aria-pressed="false">Lock Tuning</button> <button id="lock-btn" type="button">Lock</button>
</div> </div>
</div> </div>
</div> </div>
@@ -358,14 +356,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
<div class="full-row label-below-row" id="vchan-row"> <div class="full-row label-below-row" id="vchan-row">
<div class="label"><span>Channels</span></div> <div class="label"><span>Channels / Scheduler</span></div>
<div class="channel-scheduler-controls"> <div class="channel-scheduler-controls">
<div class="vchan-picker" id="vchan-picker"></div> <div class="vchan-picker" id="vchan-picker"></div>
</div>
</div>
<details id="scheduler-controls" class="advanced-radio-controls scheduler-controls-section">
<summary>Scheduler controls</summary>
<div class="advanced-radio-body">
<div class="scheduler-control-row" style="display:none"> <div class="scheduler-control-row" style="display:none">
<div class="scheduler-release-wrap"> <div class="scheduler-release-wrap">
<button id="scheduler-release-btn" type="button">Release to Scheduler</button> <button id="scheduler-release-btn" type="button">Release to Scheduler</button>
@@ -388,7 +381,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
</div> </div>
</details> </div>
<div class="full-row label-below-row"> <div class="full-row label-below-row">
<div class="label"><span>Signal</span></div> <div class="label"><span>Signal</span></div>
<div class="signal" style="gap: 1rem;"> <div class="signal" style="gap: 1rem;">
@@ -419,24 +412,20 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="tx-limit-btn" type="button">Set</button> <button id="tx-limit-btn" type="button">Set</button>
</div> </div>
</div> </div>
<details id="audio-controls" class="advanced-radio-controls audio-controls-section"> <div class="full-row label-below-row" id="audio-row">
<summary>Audio controls</summary> <div class="label"><span>Audio</span></div>
<div class="advanced-radio-body"> <div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;">
<div class="full-row" id="audio-row"> <button id="rx-audio-btn" type="button">Play Audio</button>
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;"> <button id="tx-audio-btn" type="button">Transmit Audio</button>
<button id="rx-audio-btn" type="button">Play Audio</button> <label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
<button id="tx-audio-btn" type="button">Transmit Audio</button> <label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label> <label class="vol-label" id="sdr-squelch-wrap" style="display:none;">SQL<input type="range" id="sdr-squelch" min="0" max="100" value="0" class="vol-slider" /><small class="vol-pct" id="sdr-squelch-pct">Open</small><button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set squelch to current noise level">Auto</button></label>
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label> <div id="audio-level">
<label class="vol-label" id="sdr-squelch-wrap" style="display:none;">SQL<input type="range" id="sdr-squelch" min="0" max="100" value="0" class="vol-slider" /><small class="vol-pct" id="sdr-squelch-pct">Open</small><button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set squelch to current noise level">Auto</button></label> <div id="audio-level-fill"></div>
<div id="audio-level">
<div id="audio-level-fill"></div>
</div>
<small id="audio-status" style="min-width: 60px;">Off</small>
</div>
</div> </div>
<small id="audio-status" style="min-width: 60px;">Off</small>
</div> </div>
</details> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -506,7 +495,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
</label> </label>
</div> </div>
<div class="bm-form-actions"> <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="submit" class="bm-save-btn">Save</button>
<button type="button" id="bm-form-cancel">Cancel</button> <button type="button" id="bm-form-cancel">Cancel</button>
</div> </div>
@@ -542,7 +530,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
<div id="tab-digital-modes" class="tab-panel" style="display:none;"> <div id="tab-digital-modes" class="tab-panel" style="display:none;">
<div class="sub-tab-bar" aria-label="Decoder views"> <div class="sub-tab-bar">
<button class="sub-tab active" data-subtab="overview">Overview</button> <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="ais">AIS</button>
<button class="sub-tab" data-subtab="vdes">VDES</button> <button class="sub-tab" data-subtab="vdes">VDES</button>
@@ -1639,7 +1627,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<script defer src="/vendor/leaflet.js"></script> <script defer src="/vendor/leaflet.js"></script>
<script defer src="/leaflet-ais-tracksymbol.js"></script> <script defer src="/leaflet-ais-tracksymbol.js"></script>
<script defer src="/webgl-renderer.js"></script> <script defer src="/webgl-renderer.js"></script>
<script defer src="/ui-core.js"></script>
<script defer src="/app.js"></script> <script defer src="/app.js"></script>
<script> <script>
// Lazy plugin loader: loads plugin scripts when their tab/feature is first activated // Lazy plugin loader: loads plugin scripts when their tab/feature is first activated
@@ -1654,41 +1641,16 @@ SPDX-License-Identifier: GPL-2.0-or-later
'settings': ['/vchan.js', '/scheduler.js'] 'settings': ['/vchan.js', '/scheduler.js']
}; };
var loaded = new Set(); var loaded = new Set();
var loading = new Map();
function loadScript(src) {
if (loaded.has(src)) return Promise.resolve();
if (loading.has(src)) return loading.get(src);
var request = new Promise(function(resolve, reject) {
var s = document.createElement('script');
s.src = src;
s.onload = function() {
loaded.add(src);
loading.delete(src);
resolve();
};
s.onerror = function() {
loading.delete(src);
reject(new Error('Failed to load plugin script: ' + src));
};
document.body.appendChild(s);
});
loading.set(src, request);
return request;
}
function loadPlugins(tab) { function loadPlugins(tab) {
var scripts = pluginScripts[tab]; var scripts = pluginScripts[tab];
if (!scripts) return Promise.resolve(); if (!scripts) return;
return scripts.reduce(function(sequence, src) { scripts.forEach(function(src) {
return sequence.then(function() { return loadScript(src); }); if (loaded.has(src)) return;
}, Promise.resolve()); loaded.add(src);
} var s = document.createElement('script');
s.src = src;
function requestPlugins(tab) { s.defer = true;
return loadPlugins(tab).catch(function(err) { document.body.appendChild(s);
console.error(err);
}); });
} }
// Eager plugin loading is triggered by app.js (after window.trx is set up) // Eager plugin loading is triggered by app.js (after window.trx is set up)
@@ -1696,14 +1658,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
// loading them before app.js would cause map-core.js to crash when // loading them before app.js would cause map-core.js to crash when
// window.trx is not yet defined. // window.trx is not yet defined.
window.loadEagerPlugins = function() { window.loadEagerPlugins = function() {
return Promise.all( ['digital-modes', 'map-data', 'bookmarks', 'settings'].forEach(loadPlugins);
['digital-modes', 'map-data', 'bookmarks', 'settings'].map(requestPlugins)
);
}; };
// Load others on tab switch // Load others on tab switch
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
var tab = e.target.closest('[data-tab]'); var tab = e.target.closest('[data-tab]');
if (tab) requestPlugins(tab.dataset.tab); if (tab) loadPlugins(tab.dataset.tab);
}); });
window.loadPluginsForTab = loadPlugins; window.loadPluginsForTab = loadPlugins;
})(); })();
@@ -382,7 +382,7 @@ window.pruneAisHistoryView = function() {
}; };
document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all AIS decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_ais_decode"); await postPath("/clear_ais_decode");
window.resetAisHistoryView(); window.resetAisHistoryView();
@@ -442,7 +442,7 @@ window.restoreAprsHistory = function(packets) {
}; };
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all APRS decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_aprs_decode"); await postPath("/clear_aprs_decode");
window.resetAprsHistoryView(); window.resetAprsHistoryView();
@@ -215,10 +215,10 @@
}); });
} }
async function resetBackgroundDecode() { function resetBackgroundDecode() {
const rigId = currentRigId; const rigId = currentRigId;
if (!rigId) return; if (!rigId) return;
if (!await window.trxUi.confirm({ title: "Reset background decoding?", message: "The current background decode configuration will be permanently reset.", confirmLabel: "Reset" })) return; if (!confirm("Reset background decode configuration? This cannot be undone.")) return;
apiResetConfig(rigId) apiResetConfig(rigId)
.then(function (saved) { .then(function (saved) {
currentConfig = saved; currentConfig = saved;
@@ -331,13 +331,8 @@ async function bmSave(e) {
const comment = document.getElementById("bm-comment").value.trim(); const comment = document.getElementById("bm-comment").value.trim();
const decoders = bmReadDecoders(); const decoders = bmReadDecoders();
const formError = document.getElementById("bm-form-error");
if (formError) formError.textContent = "";
if (!name || !Number.isFinite(freq_hz) || !mode) { if (!name || !Number.isFinite(freq_hz) || !mode) {
if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode."; alert("Name, Frequency, and Mode are required.");
const invalid = !name ? document.getElementById("bm-name")
: !Number.isFinite(freq_hz) ? document.getElementById("bm-freq") : document.getElementById("bm-mode");
invalid?.focus();
return; return;
} }
@@ -378,13 +373,12 @@ async function bmSave(e) {
await bmFetch(document.getElementById("bm-category-filter").value); await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) { } catch (err) {
console.error("Failed to save bookmark:", err); console.error("Failed to save bookmark:", err);
if (formError) formError.textContent = "Failed to save bookmark: " + err.message; alert("Failed to save bookmark: " + err.message);
window.trxUi?.notify("Bookmark could not be saved", { kind: "error" });
} }
} }
async function bmDelete(id) { async function bmDelete(id) {
if (!await window.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return; if (!confirm("Delete this bookmark?")) return;
const bm = bmList.find((b) => b.id === id); const bm = bmList.find((b) => b.id === id);
const scope = bm ? bm.scope : undefined; const scope = bm ? bm.scope : undefined;
try { try {
@@ -395,7 +389,7 @@ async function bmDelete(id) {
await bmFetch(document.getElementById("bm-category-filter").value); await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) { } catch (err) {
console.error("Failed to delete bookmark:", err); console.error("Failed to delete bookmark:", err);
window.trxUi?.notify("Failed to delete bookmark: " + err.message, { kind: "error" }); alert("Failed to delete bookmark: " + err.message);
} }
} }
@@ -561,12 +555,7 @@ async function bmMoveSelected() {
const target = document.getElementById("bm-move-target")?.value; const target = document.getElementById("bm-move-target")?.value;
if (!target) return; if (!target) return;
const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target; const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
if (!await window.trxUi.confirm({ if (!confirm(`Move ${ids.length} bookmark${ids.length > 1 ? "s" : ""} to "${targetLabel}"?`)) return;
title: "Move selected bookmarks?",
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
confirmLabel: "Move",
danger: false,
})) return;
try { try {
// Group selected IDs by their owning scope (skip if already in target). // Group selected IDs by their owning scope (skip if already in target).
const byScope = {}; const byScope = {};
@@ -588,7 +577,7 @@ async function bmMoveSelected() {
await bmFetch(document.getElementById("bm-category-filter").value); await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) { } catch (err) {
console.error("Failed to move bookmarks:", err); console.error("Failed to move bookmarks:", err);
window.trxUi?.notify("Failed to move bookmarks: " + err.message, { kind: "error" }); alert("Failed to move bookmarks: " + err.message);
} }
} }
@@ -609,11 +598,7 @@ function bmSyncSelectAllCheckbox() {
async function bmDeleteSelected() { async function bmDeleteSelected() {
const ids = Array.from(bmSelected); const ids = Array.from(bmSelected);
if (ids.length === 0) return; if (ids.length === 0) return;
if (!await window.trxUi.confirm({ if (!confirm(`Delete ${ids.length} selected bookmark${ids.length > 1 ? "s" : ""}?`)) return;
title: "Delete selected bookmarks?",
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
confirmLabel: "Delete",
})) return;
try { try {
// Group selected IDs by their owning scope. // Group selected IDs by their owning scope.
const byScope = {}; const byScope = {};
@@ -634,7 +619,7 @@ async function bmDeleteSelected() {
await bmFetch(document.getElementById("bm-category-filter").value); await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) { } catch (err) {
console.error("Failed to delete bookmarks:", err); console.error("Failed to delete bookmarks:", err);
window.trxUi?.notify("Failed to delete bookmarks: " + err.message, { kind: "error" }); alert("Failed to delete bookmarks: " + err.message);
} }
} }
@@ -364,7 +364,7 @@ window.resetCwHistoryView = function() {
}; };
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all CW decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_cw_decode"); await postPath("/clear_cw_decode");
window.resetCwHistoryView(); window.resetCwHistoryView();
@@ -190,7 +190,7 @@ ft2DecodeToggleBtn?.addEventListener("click", async () => {
}); });
document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear FT2 history?", message: "All stored FT2 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all FT2 decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_ft2_decode"); await postPath("/clear_ft2_decode");
window.resetFt2HistoryView(); window.resetFt2HistoryView();
@@ -190,7 +190,7 @@ ft4DecodeToggleBtn?.addEventListener("click", async () => {
}); });
document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear FT4 history?", message: "All stored FT4 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all FT4 decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_ft4_decode"); await postPath("/clear_ft4_decode");
window.resetFt4HistoryView(); window.resetFt4HistoryView();
@@ -462,7 +462,7 @@ ft8DecodeToggleBtn?.addEventListener("click", async () => {
}); });
document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear FT8 history?", message: "All stored FT8 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all FT8 decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_ft8_decode"); await postPath("/clear_ft8_decode");
window.resetFt8HistoryView(); window.resetFt8HistoryView();
@@ -388,7 +388,7 @@ hfAprsDecodeToggleBtn?.addEventListener("click", async () => {
}); });
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all HF APRS decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_hf_aprs_decode"); await postPath("/clear_hf_aprs_decode");
window.resetHfAprsHistoryView(); window.resetHfAprsHistoryView();
@@ -250,9 +250,9 @@
var noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN; var noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
var bmId = dom.bookmark ? dom.bookmark.value : ""; var bmId = dom.bookmark ? dom.bookmark.value : "";
if (!satellite) { window.trxUi?.notify("Enter a satellite name.", { kind: "error" }); document.getElementById("scheduler-sat-name")?.focus(); return; } if (!satellite) { alert("Please enter a satellite name."); 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 (isNaN(noradId) || noradId <= 0) { alert("Please enter a valid NORAD catalog number."); return; }
if (!bmId) { window.trxUi?.notify("Select a bookmark.", { kind: "error" }); document.getElementById("scheduler-sat-bookmark")?.focus(); return; } if (!bmId) { alert("Please select a bookmark."); return; }
var minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5; var minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
var prio = dom.priority ? parseInt(dom.priority.value, 10) : 0; var prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
@@ -295,7 +295,7 @@ satDom.typeFilter?.addEventListener("change", () => renderSatHistoryTable());
document document
.getElementById("settings-clear-sat-history") .getElementById("settings-clear-sat-history")
?.addEventListener("click", async () => { ?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all satellite decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_lrpt_decode"); await postPath("/clear_lrpt_decode");
window.resetSatHistoryView(); window.resetSatHistoryView();
@@ -599,7 +599,7 @@
const bmId = bmEl.value; const bmId = bmEl.value;
if (!bmId) { if (!bmId) {
window.trxUi?.notify("Select a primary bookmark before saving.", { kind: "error" }); alert("Please select a primary bookmark.");
return; return;
} }
@@ -887,7 +887,7 @@
var recEl = tr.querySelector('[data-field="record"]'); var recEl = tr.querySelector('[data-field="record"]');
var exEl = tr.querySelector('[data-field="exclusive"]'); var exEl = tr.querySelector('[data-field="exclusive"]');
if (bmEl && !bmEl.value) { window.trxUi?.notify("Select a bookmark before saving.", { kind: "error" }); bmEl.focus(); return; } if (bmEl && !bmEl.value) { alert('Please select a bookmark.'); return; }
entry.start_min = hhmmToMin(startEl.value); entry.start_min = hhmmToMin(startEl.value);
entry.end_min = hhmmToMin(endEl.value); entry.end_min = hhmmToMin(endEl.value);
@@ -1236,10 +1236,10 @@
return el ? el.value : ""; return el ? el.value : "";
} }
async function resetScheduler() { function resetScheduler() {
const rig = currentRigId; const rig = currentRigId;
if (!rig) return; if (!rig) return;
if (!await window.trxUi.confirm({ title: "Reset scheduler?", message: "This rig's scheduler configuration will be reset to Disabled.", confirmLabel: "Reset" })) return; if (!confirm("Reset scheduler for this rig to Disabled?")) return;
apiDeleteScheduler(rig) apiDeleteScheduler(rig)
.then(function () { .then(function () {
@@ -317,7 +317,7 @@ window.restoreVdesHistory = function(messages) {
}; };
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all VDES decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_vdes_decode"); await postPath("/clear_vdes_decode");
window.resetVdesHistoryView(); window.resetVdesHistoryView();
@@ -270,7 +270,7 @@ wsprDecodeToggleBtn?.addEventListener("click", async () => {
}); });
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!confirm("Clear all WSPR decode history? This cannot be undone.")) return;
try { try {
await postPath("/clear_wspr_decode"); await postPath("/clear_wspr_decode");
window.resetWsprHistoryView(); window.resetWsprHistoryView();
@@ -1231,7 +1231,7 @@ small { color: var(--text-muted); }
} }
.top-bar-actions { .top-bar-actions {
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: 0.45rem 0.6rem; gap: 0.45rem 0.6rem;
min-width: 0; min-width: 0;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1404,21 +1404,10 @@ small { color: var(--text-muted); }
padding: 0.9rem 0.2rem 0; padding: 0.9rem 0.2rem 0;
} }
.header-rig-switch { .header-rig-switch {
display: grid; display: flex;
grid-template-columns: minmax(0, 1fr); align-items: center;
align-items: start;
gap: 0.35rem; 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 { .header-rig-switch select {
min-width: 8rem; min-width: 8rem;
height: 2rem; height: 2rem;
@@ -1428,7 +1417,6 @@ small { color: var(--text-muted); }
background: var(--input-bg); background: var(--input-bg);
color: var(--text); color: var(--text);
font-size: 0.85rem; font-size: 0.85rem;
align-self: start;
} }
.header-rig-switch button { .header-rig-switch button {
height: 2rem; height: 2rem;
@@ -2851,256 +2839,6 @@ canvas:focus-visible, [tabindex]:focus-visible {
outline-offset: 2px; 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-flex;
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; }
.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 ───────────────────────────────────────────────────────── */ /* ── Scrollbars ───────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
@@ -3215,7 +2953,7 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
bottom: calc(0.55rem + env(safe-area-inset-bottom)); bottom: calc(0.55rem + env(safe-area-inset-bottom));
z-index: 30; z-index: 30;
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.25rem; gap: 0.25rem;
padding: 0.38rem; padding: 0.38rem;
border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent); border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent);
@@ -3255,38 +2993,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; } .tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="digital-modes"] .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-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 { .top-bar-actions {
width: 100%; width: 100%;
justify-content: flex-start; justify-content: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.45rem; gap: 0.45rem;
} }
.operator-layout-picker { max-width: 100%; }
.operator-layout-picker select { max-width: 10rem; }
.header-rig-switch, .header-rig-switch,
.header-style-pick { .header-style-pick {
flex: 1 1 12rem; flex: 1 1 12rem;
@@ -4198,27 +3910,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
} }
} }
@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 */ /* Theme styles have been moved to themes.css */
@@ -5370,11 +5061,3 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } 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);
}
}
@@ -1,144 +0,0 @@
// 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");
@@ -1,355 +0,0 @@
// 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", advanced: false, audio: false, scheduler: false, preferredTab: "main" },
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
full: { label: "Full controls", advanced: true, audio: true, scheduler: 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 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();
}
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;
const audioDetails = document.getElementById("audio-controls");
if (audioDetails) audioDetails.open = layout.audio;
const schedulerDetails = document.getElementById("scheduler-controls");
if (schedulerDetails) schedulerDetails.open = layout.scheduler;
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"></select>';
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();
})();
@@ -29,7 +29,6 @@ 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_style_css, status::STYLE_CSS, "style.css");
define_gz_cache!(gz_themes_css, status::THEMES_CSS, "themes.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_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_map_core_js, status::MAP_CORE_JS, "map-core.js");
define_gz_cache!(gz_screenshot_js, status::SCREENSHOT_JS, "screenshot.js"); define_gz_cache!(gz_screenshot_js, status::SCREENSHOT_JS, "screenshot.js");
define_gz_cache!( define_gz_cache!(
@@ -181,12 +180,6 @@ pub(crate) async fn app_js(req: HttpRequest) -> impl Responder {
static_asset_response(&req, "application/javascript; charset=utf-8", c) 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")] #[get("/map-core.js")]
pub(crate) async fn map_core_js(req: HttpRequest) -> impl Responder { pub(crate) async fn map_core_js(req: HttpRequest) -> impl Responder {
let c = gz_map_core_js(); let c = gz_map_core_js();
@@ -643,7 +643,6 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::style_css) .service(assets::style_css)
.service(assets::themes_css) .service(assets::themes_css)
.service(assets::app_js) .service(assets::app_js)
.service(assets::ui_core_js)
.service(assets::map_core_js) .service(assets::map_core_js)
.service(assets::screenshot_js) .service(assets::screenshot_js)
.service(assets::decode_history_worker_js) .service(assets::decode_history_worker_js)
@@ -388,9 +388,6 @@ struct RigListItem {
display_name: Option<String>, display_name: Option<String>,
manufacturer: String, manufacturer: String,
model: String, model: String,
supported_modes: Vec<trx_core::RigMode>,
tx: bool,
filter_controls: bool,
initialized: bool, initialized: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
latitude: Option<f64>, latitude: Option<f64>,
@@ -425,9 +422,6 @@ fn map_rig_entry(entry: &RemoteRigEntry) -> RigListItem {
display_name: entry.display_name.clone(), display_name: entry.display_name.clone(),
manufacturer: entry.state.info.manufacturer.clone(), manufacturer: entry.state.info.manufacturer.clone(),
model: entry.state.info.model.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, initialized: entry.state.initialized,
latitude: entry.state.server_latitude, latitude: entry.state.server_latitude,
longitude: entry.state.server_longitude, longitude: entry.state.server_longitude,
@@ -12,7 +12,6 @@ const INDEX_HTML: &str = include_str!("../assets/web/index.html");
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css"); pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
pub const THEMES_CSS: &str = include_str!("../assets/web/themes.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 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 MAP_CORE_JS: &str = include_str!("../assets/web/map-core.js");
pub const SCREENSHOT_JS: &str = include_str!("../assets/web/screenshot.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"); pub const DECODE_HISTORY_WORKER_JS: &str = include_str!("../assets/web/decode-history-worker.js");
@@ -72,23 +71,3 @@ pub fn index_html() -> &'static str {
.replace("{client_build_date}", CLIENT_BUILD_DATE) .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"));
assert!(html.contains("<summary>Scheduler controls</summary>"));
assert!(html.contains("<summary>Audio controls</summary>"));
}
}
+8 -9
View File
@@ -6,9 +6,7 @@
#[cfg(feature = "ft2")] #[cfg(feature = "ft2")]
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::HashSet; use std::collections::{HashSet, VecDeque};
#[cfg(test)]
use std::collections::VecDeque;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -35,9 +33,10 @@ use trx_core::audio::{
AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, 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, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
}; };
#[cfg(test)] use trx_core::decode::{
use trx_core::decode::{AisMessage, AprsPacket, CwEvent}; AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, LrptImage, LrptProgress,
use trx_core::decode::{DecodedMessage, Ft8Message, LrptImage, LrptProgress, WsprMessage}; WsprMessage,
};
use trx_core::rig::state::{RigMode, RigState}; use trx_core::rig::state::{RigMode, RigState};
use trx_core::vchan::SharedVChanManager; use trx_core::vchan::SharedVChanManager;
use trx_cw::CwDecoder; use trx_cw::CwDecoder;
@@ -47,9 +46,9 @@ use trx_wspr::WsprDecoder;
use uuid::Uuid; use uuid::Uuid;
use crate::config::AudioConfig; use crate::config::AudioConfig;
use crate::history_policy::{current_timestamp_ms, lock_or_recover}; use crate::history_policy::{
#[cfg(test)] current_timestamp_ms, enforce_capacity, lock_or_recover, prune_by_age, MAX_HISTORY_ENTRIES,
use crate::history_policy::{enforce_capacity, prune_by_age, MAX_HISTORY_ENTRIES}; };
use trx_decode_log::DecoderLoggers; use trx_decode_log::DecoderLoggers;
/// Silence timeout before auto-finalising an LRPT pass (30 s without new MCUs). /// Silence timeout before auto-finalising an LRPT pass (30 s without new MCUs).
+1 -18
View File
@@ -465,25 +465,8 @@ impl DecoderHistories {
/// entries across all decoders, used for pre-allocating the replay blob. /// entries across all decoders, used for pre-allocating the replay blob.
/// ///
/// Uses an `AtomicUsize` counter maintained by record/prune/clear methods, /// Uses an `AtomicUsize` counter maintained by record/prune/clear methods,
/// avoiding 11 separate mutex acquisitions. /// avoiding 9 separate mutex acquisitions.
pub fn estimated_total_count(&self) -> usize { pub fn estimated_total_count(&self) -> usize {
self.total_count.load(Ordering::Relaxed) 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);
}
} }
+5 -139
View File
@@ -19,7 +19,7 @@ use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod};
use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde::{de::DeserializeOwned, Deserialize, Serialize};
use trx_core::decode::{ use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, Ft8Message, LrptImage, VdesMessage, WefaxMessage, WsprMessage, AisMessage, AprsPacket, CwEvent, Ft8Message, VdesMessage, WefaxMessage, WsprMessage,
}; };
use crate::audio::DecoderHistories; use crate::audio::DecoderHistories;
@@ -118,11 +118,6 @@ pub fn load_all(db: &PickleDb, rig_id: &str, histories: &Arc<DecoderHistories>)
h.push_back(e); h.push_back(e);
} }
} }
if let Ok(mut h) = histories.hf_aprs.lock() {
for e in load_key::<AprsPacket>(db, &k("hf_aprs")) {
h.push_back(e);
}
}
if let Ok(mut h) = histories.cw.lock() { if let Ok(mut h) = histories.cw.lock() {
for e in load_key::<CwEvent>(db, &k("cw")) { for e in load_key::<CwEvent>(db, &k("cw")) {
h.push_back(e); h.push_back(e);
@@ -133,32 +128,16 @@ pub fn load_all(db: &PickleDb, rig_id: &str, histories: &Arc<DecoderHistories>)
h.push_back(e); h.push_back(e);
} }
} }
if let Ok(mut h) = histories.ft4.lock() {
for e in load_key::<Ft8Message>(db, &k("ft4")) {
h.push_back(e);
}
}
if let Ok(mut h) = histories.ft2.lock() {
for e in load_key::<Ft8Message>(db, &k("ft2")) {
h.push_back(e);
}
}
if let Ok(mut h) = histories.wspr.lock() { if let Ok(mut h) = histories.wspr.lock() {
for e in load_key::<WsprMessage>(db, &k("wspr")) { for e in load_key::<WsprMessage>(db, &k("wspr")) {
h.push_back(e); h.push_back(e);
} }
} }
if let Ok(mut h) = histories.lrpt.lock() {
for e in load_key::<LrptImage>(db, &k("lrpt")) {
h.push_back(e);
}
}
if let Ok(mut h) = histories.wefax.lock() { if let Ok(mut h) = histories.wefax.lock() {
for e in load_key::<WefaxMessage>(db, &k("wefax")) { for e in load_key::<WefaxMessage>(db, &k("wefax")) {
h.push_back(e); h.push_back(e);
} }
} }
histories.recalculate_total_count();
} }
/// Flush `histories` to the database under `rig_id`-prefixed keys and sync. /// Flush `histories` to the database under `rig_id`-prefixed keys and sync.
@@ -183,11 +162,6 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
drop(h); drop(h);
save_key(db, &k("aprs"), &snapshot); save_key(db, &k("aprs"), &snapshot);
} }
if let Ok(h) = histories.hf_aprs.lock() {
let snapshot = h.clone();
drop(h);
save_key(db, &k("hf_aprs"), &snapshot);
}
if let Ok(h) = histories.cw.lock() { if let Ok(h) = histories.cw.lock() {
let snapshot = h.clone(); let snapshot = h.clone();
drop(h); drop(h);
@@ -198,26 +172,11 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
drop(h); drop(h);
save_key(db, &k("ft8"), &snapshot); save_key(db, &k("ft8"), &snapshot);
} }
if let Ok(h) = histories.ft4.lock() {
let snapshot = h.clone();
drop(h);
save_key(db, &k("ft4"), &snapshot);
}
if let Ok(h) = histories.ft2.lock() {
let snapshot = h.clone();
drop(h);
save_key(db, &k("ft2"), &snapshot);
}
if let Ok(h) = histories.wspr.lock() { if let Ok(h) = histories.wspr.lock() {
let snapshot = h.clone(); let snapshot = h.clone();
drop(h); drop(h);
save_key(db, &k("wspr"), &snapshot); save_key(db, &k("wspr"), &snapshot);
} }
if let Ok(h) = histories.lrpt.lock() {
let snapshot = h.clone();
drop(h);
save_key(db, &k("lrpt"), &snapshot);
}
if let Ok(h) = histories.wefax.lock() { if let Ok(h) = histories.wefax.lock() {
let snapshot = h.clone(); let snapshot = h.clone();
drop(h); drop(h);
@@ -226,38 +185,20 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
let _ = db.dump(); let _ = db.dump();
} }
fn flush_all_rigs(db: &Mutex<PickleDb>, rig_histories: &[(String, Arc<DecoderHistories>)]) {
let Ok(mut guard) = db.lock() else {
tracing::warn!("history database mutex poisoned; skipping periodic flush");
return;
};
for (rig_id, histories) in rig_histories {
flush_all(&mut guard, rig_id, histories);
}
}
/// Spawn a Tokio task that flushes all rigs' histories to disk every 60 seconds. /// Spawn a Tokio task that flushes all rigs' histories to disk every 60 seconds.
///
/// Snapshot cloning, JSON serialization, and disk I/O run on Tokio's blocking
/// pool so a large history database cannot stall an async runtime worker.
pub fn spawn_flush_task( pub fn spawn_flush_task(
db: Arc<Mutex<PickleDb>>, db: Arc<Mutex<PickleDb>>,
rig_histories: Vec<(String, Arc<DecoderHistories>)>, rig_histories: Vec<(String, Arc<DecoderHistories>)>,
) { ) {
tokio::spawn(async move { tokio::spawn(async move {
let rig_histories = Arc::new(rig_histories);
let mut interval = tokio::time::interval(Duration::from_secs(60)); let mut interval = tokio::time::interval(Duration::from_secs(60));
interval.tick().await; // consume the immediate first tick interval.tick().await; // consume the immediate first tick
loop { loop {
interval.tick().await; interval.tick().await;
let db = Arc::clone(&db); if let Ok(mut guard) = db.lock() {
let rig_histories = Arc::clone(&rig_histories); for (rig_id, histories) in &rig_histories {
if let Err(err) = tokio::task::spawn_blocking(move || { flush_all(&mut guard, rig_id, histories);
flush_all_rigs(&db, rig_histories.as_slice()); }
})
.await
{
tracing::warn!(error = %err, "history flush worker failed");
} }
} }
}); });
@@ -361,79 +302,4 @@ mod tests {
let _ = std::fs::remove_file(&db_file); let _ = std::fs::remove_file(&db_file);
let _ = std::fs::remove_dir(&dir); let _ = std::fs::remove_dir(&dir);
} }
#[test]
fn flush_and_load_all_restores_previously_omitted_histories() {
let db_file = std::env::temp_dir().join(format!(
"trx_history_all_{}_{}.db",
std::process::id(),
now_unix_ms()
));
let mut db = PickleDb::new(
&db_file,
PickleDbDumpPolicy::DumpUponRequest,
SerializationMethod::Json,
);
let source = DecoderHistories::new();
let now = Instant::now();
source.hf_aprs.lock().unwrap().push_back((
now,
AprsPacket {
rig_id: Some("rig-a".into()),
ts_ms: Some(now_unix_ms()),
src_call: "TEST".into(),
dest_call: "APRS".into(),
path: String::new(),
info: "history".into(),
info_bytes: Vec::new(),
packet_type: "position".into(),
crc_ok: true,
lat: None,
lon: None,
symbol_table: None,
symbol_code: None,
},
));
for (queue, mode) in [(&source.ft4, "FT4"), (&source.ft2, "FT2")] {
queue.lock().unwrap().push_back((
now,
Ft8Message {
rig_id: Some("rig-a".into()),
ts_ms: now_unix_ms(),
snr_db: -10.0,
dt_s: 0.1,
freq_hz: 1_000.0,
message: mode.into(),
},
));
}
source.lrpt.lock().unwrap().push_back((
now,
LrptImage {
rig_id: Some("rig-a".into()),
pass_start_ms: now_unix_ms(),
pass_end_ms: now_unix_ms(),
mcu_count: 1,
path: "/tmp/lrpt.png".into(),
ts_ms: Some(now_unix_ms()),
satellite: None,
channels: None,
geo_bounds: None,
ground_track: None,
},
));
flush_all(&mut db, "rig-a", &source);
let restored = DecoderHistories::new();
load_all(&db, "rig-a", &restored);
assert_eq!(restored.hf_aprs.lock().unwrap().len(), 1);
assert_eq!(restored.ft4.lock().unwrap().len(), 1);
assert_eq!(restored.ft2.lock().unwrap().len(), 1);
assert_eq!(restored.lrpt.lock().unwrap().len(), 1);
assert_eq!(restored.estimated_total_count(), 4);
let _ = std::fs::remove_file(db_file);
}
} }
@@ -251,9 +251,9 @@ fn mul_freq_domain(buf: &mut [FftComplex<f32>], h_freq: &[FftComplex<f32>], scal
unsafe { unsafe {
mul_freq_domain_neon(buf, h_freq, scale); mul_freq_domain_neon(buf, h_freq, scale);
} }
return;
} }
#[cfg(not(target_arch = "aarch64"))]
mul_freq_domain_scalar(buf, h_freq, scale); mul_freq_domain_scalar(buf, h_freq, scale);
} }