From 90d57a290e825d5485b2d003eae7bcb3db8e4cc6 Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Sun, 8 Feb 2026 22:05:44 +0100 Subject: [PATCH] [feat](trx-frontend-http): persist settings and APRS state across refresh Add localStorage persistence (trx_ prefix) for UI settings: - Jog step, RX/TX volume (app.js) - CW WPM, tone, threshold, auto-detect flags (cw.js) - APRS decoded packets and running state (aprs.js) APRS decoder auto-restarts on page refresh if it was active, and all decoded packets plus map markers are restored from storage. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Stanislaw Grams --- .../trx-frontend-http/assets/web/app.js | 37 ++++++++++--- .../assets/web/plugins/aprs.js | 52 +++++++++++++++---- .../assets/web/plugins/cw.js | 19 +++++++ 3 files changed, 93 insertions(+), 15 deletions(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index 11162ba..8e6547d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -1,3 +1,15 @@ +// --- Persistent settings (localStorage) --- +const STORAGE_PREFIX = "trx_"; +function saveSetting(key, value) { + try { localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value)); } catch(e) {} +} +function loadSetting(key, fallback) { + try { + const v = localStorage.getItem(STORAGE_PREFIX + key); + return v !== null ? JSON.parse(v) : fallback; + } catch(e) { return fallback; } +} + const freqEl = document.getElementById("freq"); const modeEl = document.getElementById("mode"); const bandLabel = document.getElementById("band-label"); @@ -32,7 +44,7 @@ let hintTimer = null; let sigMeasuring = false; let sigSamples = []; let lastFreqHz = null; -let jogStep = 1000; // default 1 kHz +let jogStep = loadSetting("jogStep", 1000); const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"]; function vfoColor(idx) { if (idx < VFO_COLORS.length) return VFO_COLORS[idx]; @@ -562,6 +574,12 @@ jogStepEl.addEventListener("click", (e) => { jogStep = parseInt(btn.dataset.step, 10); jogStepEl.querySelectorAll("button").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); + saveSetting("jogStep", jogStep); +}); + +// Restore active jog step button from saved setting +jogStepEl.querySelectorAll("button").forEach((b) => { + b.classList.toggle("active", parseInt(b.dataset.step, 10) === jogStep); }); modeBtn.addEventListener("click", async () => { @@ -1076,24 +1094,31 @@ txAudioBtn.addEventListener("click", startTxAudio); const rxVolPct = document.getElementById("rx-vol-pct"); const txVolPct = document.getElementById("tx-vol-pct"); +// Restore saved volumes +rxVolSlider.value = loadSetting("rxVol", 80); +txVolSlider.value = loadSetting("txVol", 80); +rxVolPct.textContent = `${rxVolSlider.value}%`; +txVolPct.textContent = `${txVolSlider.value}%`; + function updateVolSlider(slider, pctEl, gainNode) { pctEl.textContent = `${slider.value}%`; if (gainNode) gainNode.gain.value = slider.value / 100; } -rxVolSlider.addEventListener("input", () => updateVolSlider(rxVolSlider, rxVolPct, rxGainNode)); -txVolSlider.addEventListener("input", () => updateVolSlider(txVolSlider, txVolPct, txGainNode)); +rxVolSlider.addEventListener("input", () => { updateVolSlider(rxVolSlider, rxVolPct, rxGainNode); saveSetting("rxVol", Number(rxVolSlider.value)); }); +txVolSlider.addEventListener("input", () => { updateVolSlider(txVolSlider, txVolPct, txGainNode); saveSetting("txVol", Number(txVolSlider.value)); }); -function volWheel(slider, pctEl, getGain) { +function volWheel(slider, pctEl, getGain, storageKey) { slider.addEventListener("wheel", (e) => { e.preventDefault(); const step = e.deltaY < 0 ? 2 : -2; slider.value = Math.max(0, Math.min(100, Number(slider.value) + step)); updateVolSlider(slider, pctEl, getGain()); + saveSetting(storageKey, Number(slider.value)); }, { passive: false }); } -volWheel(rxVolSlider, rxVolPct, () => rxGainNode); -volWheel(txVolSlider, txVolPct, () => txGainNode); +volWheel(rxVolSlider, rxVolPct, () => rxGainNode, "rxVol"); +volWheel(txVolSlider, txVolPct, () => txGainNode, "txVol"); document.getElementById("copyright-year").textContent = new Date().getFullYear(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js index cae8edd..adeda69 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js @@ -9,6 +9,9 @@ let aprsWs = null; let aprsAudioCtx = null; let aprsDecoder = null; +// Persistent packet history +let aprsPacketHistory = loadSetting("aprsPackets", []); + // CRC-16-CCITT lookup table const CRC_CCITT_TABLE = new Uint16Array(256); (function initCrc() { @@ -448,14 +451,11 @@ function escapeAprsInfo(str) { return out; } -function addAprsPacket(pkt) { - const tag = pkt.crcOk ? "[APRS]" : "[APRS-CRC-FAIL]"; - console.log(tag, `${pkt.srcCall}>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: ${pkt.info}`, pkt); +function renderAprsRow(pkt) { const row = document.createElement("div"); row.className = "aprs-packet"; if (!pkt.crcOk) row.style.opacity = "0.5"; - const now = new Date(); - const ts = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); const crcTag = pkt.crcOk ? "" : ' [CRC]'; let symbolHtml = ""; if (pkt.symbolTable && pkt.symbolCode) { @@ -473,6 +473,22 @@ function addAprsPacket(pkt) { posHtml = ` ${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}`; } row.innerHTML = `${ts}${symbolHtml}${pkt.srcCall}>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: ${escapeAprsInfo(pkt.info)}${posHtml}${crcTag}`; + return row; +} + +function addAprsPacket(pkt) { + const tag = pkt.crcOk ? "[APRS]" : "[APRS-CRC-FAIL]"; + console.log(tag, `${pkt.srcCall}>${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: ${pkt.info}`, pkt); + + // Stamp timestamp for persistence + pkt._ts = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + + // Persist to history + aprsPacketHistory.unshift(pkt); + if (aprsPacketHistory.length > APRS_MAX_PACKETS) aprsPacketHistory.length = APRS_MAX_PACKETS; + saveSetting("aprsPackets", aprsPacketHistory); + + const row = renderAprsRow(pkt); if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) { window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode); } @@ -483,7 +499,7 @@ function addAprsPacket(pkt) { } function startAprs() { - if (aprsActive) { stopAprs(); return; } + if (aprsActive) return; if (!hasWebCodecs) { aprsStatus.textContent = "Requires Chrome/Edge"; return; @@ -565,6 +581,7 @@ function startAprs() { }); aprsActive = true; + saveSetting("aprsRunning", true); aprsToggleBtn.style.borderColor = "#00d17f"; aprsToggleBtn.style.color = "#00d17f"; aprsToggleBtn.textContent = "Stop APRS"; @@ -590,7 +607,7 @@ function startAprs() { }; aprsWs.onclose = () => { - stopAprs(); + stopAprs(false); }; aprsWs.onerror = () => { @@ -598,8 +615,9 @@ function startAprs() { }; } -function stopAprs() { +function stopAprs(explicit) { aprsActive = false; + if (explicit) saveSetting("aprsRunning", false); if (aprsWs) { aprsWs.close(); aprsWs = null; } if (aprsAudioCtx) { aprsAudioCtx.close(); aprsAudioCtx = null; } if (aprsDecoder) { @@ -612,4 +630,20 @@ function stopAprs() { aprsStatus.textContent = "Stopped"; } -aprsToggleBtn.addEventListener("click", startAprs); +aprsToggleBtn.addEventListener("click", () => { + if (aprsActive) { stopAprs(true); } else { startAprs(); } +}); + +// Restore saved packets and map markers on page load +for (let i = aprsPacketHistory.length - 1; i >= 0; i--) { + const pkt = aprsPacketHistory[i]; + aprsPacketsEl.prepend(renderAprsRow(pkt)); + if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) { + window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode); + } +} + +// Auto-start APRS if it was running before page refresh +if (loadSetting("aprsRunning", false) && hasWebCodecs) { + startAprs(); +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js index 59493ce..bba38fe 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js @@ -11,6 +11,16 @@ const cwWpmAutoCheck = document.getElementById("cw-wpm-auto"); const cwToneAutoCheck = document.getElementById("cw-tone-auto"); const CW_MAX_LINES = 200; +// Restore saved CW settings +cwWpmInput.value = loadSetting("cwWpm", 15); +cwToneInput.value = loadSetting("cwTone", 700); +cwThresholdInput.value = loadSetting("cwThreshold", 5); +cwThresholdVal.textContent = (cwThresholdInput.value / 100).toFixed(2); +cwWpmAutoCheck.checked = loadSetting("cwWpmAuto", true); +cwToneAutoCheck.checked = loadSetting("cwToneAuto", true); +cwWpmInput.readOnly = cwWpmAutoCheck.checked; +cwToneInput.readOnly = cwToneAutoCheck.checked; + let cwActive = false; let cwWs = null; let cwAudioCtx = null; @@ -36,18 +46,25 @@ const MORSE_TABLE = { // Update threshold display cwThresholdInput.addEventListener("input", () => { cwThresholdVal.textContent = (cwThresholdInput.value / 100).toFixed(2); + saveSetting("cwThreshold", Number(cwThresholdInput.value)); }); // Toggle readonly on WPM input based on Auto checkbox cwWpmAutoCheck.addEventListener("change", () => { cwWpmInput.readOnly = cwWpmAutoCheck.checked; + saveSetting("cwWpmAuto", cwWpmAutoCheck.checked); }); // Toggle readonly on Tone input based on Auto checkbox cwToneAutoCheck.addEventListener("change", () => { cwToneInput.readOnly = cwToneAutoCheck.checked; + saveSetting("cwToneAuto", cwToneAutoCheck.checked); }); +// Save WPM/Tone when manually changed +cwWpmInput.addEventListener("change", () => { saveSetting("cwWpm", Number(cwWpmInput.value)); }); +cwToneInput.addEventListener("change", () => { saveSetting("cwTone", Number(cwToneInput.value)); }); + function createCwDecoder(sampleRate) { let wpm = parseInt(cwWpmInput.value, 10) || 15; let toneFreq = parseInt(cwToneInput.value, 10) || 700; @@ -163,6 +180,7 @@ function createCwDecoder(sampleRate) { if (Math.abs(detectedFreq - toneFreq) > TONE_SCAN_STEP) { recomputeGoertzel(detectedFreq); cwToneInput.value = detectedFreq; + saveSetting("cwTone", detectedFreq); } } } @@ -201,6 +219,7 @@ function createCwDecoder(sampleRate) { if (newWpm !== wpm) { wpm = newWpm; cwWpmInput.value = wpm; + saveSetting("cwWpm", wpm); } }