From d96624be4fc0ca50986302a146205dcd20f7a1ac Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 1 Aug 2026 12:15:19 +0200 Subject: [PATCH] refactor: convert WSPR plugin to TypeScript --- .../assets/web/generated/wspr.js | 497 +++++++++--------- .../trx-frontend-http/frontend/build.mjs | 2 +- .../frontend/src/plugins/wspr.js | 292 ---------- .../frontend/src/plugins/wspr.ts | 331 ++++++++++++ .../frontend/tests/wspr.test.mjs | 35 ++ 5 files changed, 623 insertions(+), 534 deletions(-) delete mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.ts create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wspr.test.mjs diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js index 29b11658..752fc4dc 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js @@ -1,251 +1,266 @@ "use strict"; -const wsprStatus = document.getElementById("wspr-status"); -const wsprPeriodEl = document.getElementById("wspr-period"); -const wsprMessagesEl = document.getElementById("wspr-messages"); -const wsprFilterInput = document.getElementById("wspr-filter"); -const WSPR_PERIOD_SECONDS = 120; -let wsprFilterText = ""; -let wsprMessageHistory = []; -function currentWsprHistoryRetentionMs() { - return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; -} -function pruneWsprMessageHistory() { - const cutoffMs = Date.now() - currentWsprHistoryRetentionMs(); - wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); -} -function scheduleWsprHistoryRender() { - if (typeof window.trxScheduleUiFrameJob === "function") { - window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory()); - return; +(() => { + // src/plugins/wspr.ts + var wsprWindow = window; + var wsprStatus = document.getElementById("wspr-status"); + var wsprPeriodEl = document.getElementById("wspr-period"); + var wsprMessagesEl = document.getElementById("wspr-messages"); + var wsprFilterInput = document.getElementById("wspr-filter"); + var WSPR_PERIOD_SECONDS = 120; + var wsprFilterText = ""; + var wsprMessageHistory = []; + function finiteNumber(value) { + const number = typeof value === "number" ? value : Number(value); + return Number.isFinite(number) ? number : null; } - renderWsprHistory(); -} -function fmtWsprTime(tsMs) { - if (!tsMs) return "--:--:--"; - return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); -} -function updateWsprPeriodTimer() { - if (!wsprPeriodEl) return; - const nowSec = Math.floor(Date.now() / 1e3); - const remaining = WSPR_PERIOD_SECONDS - nowSec % WSPR_PERIOD_SECONDS; - const mm = String(Math.floor(remaining / 60)).padStart(2, "0"); - const ss = String(remaining % 60).padStart(2, "0"); - wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`; -} -updateWsprPeriodTimer(); -setInterval(updateWsprPeriodTimer, 500); -function renderWsprRow(msg) { - const row = document.createElement("div"); - row.className = "ft8-row"; - row.dataset.decoder = "wspr"; - const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--"; - const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--"; - const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null; - const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? baseHz + msg.freq_hz : null; - const freq = Number.isFinite(rfHz) ? rfHz.toFixed(0) : "--"; - const message = (msg.message || "").toString(); - row.dataset.message = message.toUpperCase(); - row.innerHTML = `${fmtWsprTime(msg.ts_ms)}${snr}${dt}${freq}${renderWsprMessage(message)}`; - applyWsprFilterToRow(row); - return row; -} -function renderWsprHistory() { - pruneWsprMessageHistory(); - if (!wsprMessagesEl) return; - const fragment = document.createDocumentFragment(); - for (let i = 0; i < wsprMessageHistory.length; i += 1) { - fragment.appendChild(renderWsprRow(wsprMessageHistory[i])); + function currentWsprHistoryRetentionMs() { + return typeof wsprWindow.getDecodeHistoryRetentionMs === "function" ? wsprWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; } - wsprMessagesEl.replaceChildren(fragment); -} -function addWsprMessage(msg) { - msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); - wsprMessageHistory.unshift(msg); - pruneWsprMessageHistory(); - scheduleWsprHistoryRender(); -} -function normalizeServerWsprMessage(msg) { - const raw = (msg.message || "").toString(); - const grids = extractAllGrids(raw); - const station = extractLikelyCallsign(raw); - const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null; - const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? baseHz + Number(msg.freq_hz) : Number.isFinite(msg.freq_hz) ? Number(msg.freq_hz) : null; - return { - raw, - grids, - station, - rfHz, - history: { - receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, - ts_ms: msg.ts_ms, - snr_db: msg.snr_db, - dt_s: msg.dt_s, - freq_hz: msg.freq_hz, - message: raw - } - }; -} -window.onServerWsprBatch = function(messages) { - if (!Array.isArray(messages) || messages.length === 0) return; - wsprStatus.textContent = "Receiving"; - const normalized = []; - for (const msg of messages) { - const next = normalizeServerWsprMessage(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "wspr", next.station, { - ...msg, - freq_hz: next.rfHz + function pruneWsprMessageHistory() { + const cutoffMs = Date.now() - currentWsprHistoryRetentionMs(); + wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs); + } + function scheduleWsprHistoryRender() { + if (typeof wsprWindow.trxScheduleUiFrameJob === "function") { + wsprWindow.trxScheduleUiFrameJob("wspr-history", () => { + renderWsprHistory(); }); + return; } - next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now(); - normalized.push(next.history); - } - normalized.reverse(); - wsprMessageHistory = normalized.concat(wsprMessageHistory); - pruneWsprMessageHistory(); - scheduleWsprHistoryRender(); -}; -window.restoreWsprHistory = function(messages) { - window.onServerWsprBatch(messages); -}; -window.pruneWsprHistoryView = function() { - pruneWsprMessageHistory(); - renderWsprHistory(); -}; -function escapeWsprHtml(input) { - return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); -} -function renderWsprMessage(message) { - let out = ""; - let i = 0; - while (i < message.length) { - const ch = message[i]; - if (isAlphaNum(ch)) { - let j = i + 1; - while (j < message.length && isAlphaNum(message[j])) j++; - const token = message.slice(i, j); - const grid = token.toUpperCase(); - if (isMaidenheadGridToken(grid)) { - out += `${grid}`; - } else { - out += escapeWsprHtml(token); - } - i = j; - } else { - out += escapeWsprHtml(ch); - i += 1; - } - } - return out; -} -function extractAllGrids(message) { - const out = []; - const seen = /* @__PURE__ */ new Set(); - const parts = message.toUpperCase().split(/[^A-Z0-9]+/); - for (const token of parts) { - if (!token) continue; - if (isMaidenheadGridToken(token) && !seen.has(token)) { - seen.add(token); - out.push(token); - } - } - return out; -} -function extractLikelyCallsign(message) { - const parts = String(message || "").toUpperCase().split(/[^A-Z0-9/]+/); - for (const token of parts) { - if (!token) continue; - if (token.length < 3 || token.length > 12) continue; - if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue; - if (isMaidenheadGridToken(token)) continue; - if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token; - } - return null; -} -function isFtxFarewellToken(token) { - const normalized = String(token || "").trim().toUpperCase(); - return normalized === "RR73" || normalized === "73" || normalized === "RR"; -} -function isMaidenheadGridToken(token) { - const normalized = String(token || "").trim().toUpperCase(); - return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized); -} -function isAlphaNum(ch) { - return /[A-Za-z0-9]/.test(ch); -} -function activateWsprHistoryLocator(targetEl) { - const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]"); - if (!locatorEl) return false; - const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase(); - if (!grid) return false; - if (typeof window.navigateToMapLocator === "function") { - window.navigateToMapLocator(grid, "wspr"); - } - return true; -} -function applyWsprFilterToRow(row) { - if (!wsprFilterText) { - row.style.display = ""; - return; - } - const message = row.dataset.message || ""; - row.style.display = message.includes(wsprFilterText) ? "" : "none"; -} -function applyWsprFilterToAll() { - const rows = wsprMessagesEl.querySelectorAll(".ft8-row"); - rows.forEach((row) => applyWsprFilterToRow(row)); -} -window.resetWsprHistoryView = function() { - wsprMessagesEl.innerHTML = ""; - wsprMessageHistory = []; - renderWsprHistory(); - if (window.clearMapMarkersByType) window.clearMapMarkersByType("wspr"); -}; -if (wsprFilterInput) { - wsprFilterInput.addEventListener("input", () => { - wsprFilterText = wsprFilterInput.value.trim().toUpperCase(); renderWsprHistory(); - }); -} -if (wsprMessagesEl) { - wsprMessagesEl.addEventListener("click", (event) => { - if (!activateWsprHistoryLocator(event.target)) return; - event.preventDefault(); - event.stopPropagation(); - }); - wsprMessagesEl.addEventListener("keydown", (event) => { - if (event.key !== "Enter" && event.key !== " ") return; - if (!activateWsprHistoryLocator(event.target)) return; - event.preventDefault(); - event.stopPropagation(); - }); -} -const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn"); -wsprDecodeToggleBtn?.addEventListener("click", async () => { - try { - await window.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn); - await postPath("/toggle_wspr_decode"); - } catch (e) { - console.error("WSPR toggle failed", e); } -}); -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; - try { - await postPath("/clear_wspr_decode"); - window.resetWsprHistoryView(); - } catch (e) { - console.error("WSPR history clear failed", e); + function fmtWsprTime(tsMs) { + if (!tsMs) return "--:--:--"; + return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } -}); -window.onServerWspr = function(msg) { - wsprStatus.textContent = "Receiving"; - const next = normalizeServerWsprMessage(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "wspr", next.station, { - ...msg, - freq_hz: next.rfHz + function updateWsprPeriodTimer() { + if (!wsprPeriodEl) return; + const nowSec = Math.floor(Date.now() / 1e3); + const remaining = WSPR_PERIOD_SECONDS - nowSec % WSPR_PERIOD_SECONDS; + const mm = String(Math.floor(remaining / 60)).padStart(2, "0"); + const ss = String(remaining % 60).padStart(2, "0"); + wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`; + } + updateWsprPeriodTimer(); + setInterval(updateWsprPeriodTimer, 500); + function renderWsprRow(msg) { + const row = document.createElement("div"); + row.className = "ft8-row"; + row.dataset.decoder = "wspr"; + const snr = finiteNumber(msg.snr_db); + const delta = finiteNumber(msg.dt_s); + const baseHz = finiteNumber(wsprWindow.ft8BaseHz); + const offsetHz = finiteNumber(msg.freq_hz); + const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : null; + const freq = rfHz?.toFixed(0) ?? "--"; + const message = msg.message ?? ""; + row.dataset.message = message.toUpperCase(); + row.innerHTML = `${fmtWsprTime(msg.ts_ms)}${snr?.toFixed(1) ?? "--"}${delta?.toFixed(2) ?? "--"}${freq}${renderWsprMessage(message)}`; + applyWsprFilterToRow(row); + return row; + } + function renderWsprHistory() { + pruneWsprMessageHistory(); + if (!wsprMessagesEl) return; + const fragment = document.createDocumentFragment(); + for (let i = 0; i < wsprMessageHistory.length; i += 1) { + const message = wsprMessageHistory[i]; + if (message) fragment.appendChild(renderWsprRow(message)); + } + wsprMessagesEl.replaceChildren(fragment); + } + function addWsprMessage(msg) { + msg._tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now(); + wsprMessageHistory.unshift(msg); + pruneWsprMessageHistory(); + scheduleWsprHistoryRender(); + } + function normalizeServerWsprMessage(msg) { + const raw = msg.message ?? ""; + const grids = extractAllGrids(raw); + const station = extractLikelyCallsign(raw); + const baseHz = finiteNumber(wsprWindow.ft8BaseHz); + const offsetHz = finiteNumber(msg.freq_hz); + const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : offsetHz; + return { + raw, + grids, + station, + rfHz, + history: { + receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null, + ts_ms: msg.ts_ms, + snr_db: msg.snr_db, + dt_s: msg.dt_s, + freq_hz: msg.freq_hz, + message: raw + } + }; + } + wsprWindow.onServerWsprBatch = function(messages) { + if (!Array.isArray(messages) || messages.length === 0) return; + if (wsprStatus) wsprStatus.textContent = "Receiving"; + const normalized = []; + for (const msg of messages) { + const next = normalizeServerWsprMessage(msg); + if (next.grids.length > 0 && wsprWindow.mapAddLocator) { + wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { + ...msg, + ...next.rfHz === null ? {} : { freq_hz: next.rfHz } + }); + } + next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now(); + normalized.push(next.history); + } + normalized.reverse(); + wsprMessageHistory = normalized.concat(wsprMessageHistory); + pruneWsprMessageHistory(); + scheduleWsprHistoryRender(); + }; + wsprWindow.restoreWsprHistory = function(messages) { + const callback = wsprWindow.onServerWsprBatch; + if (typeof callback === "function") callback(messages); + }; + wsprWindow.pruneWsprHistoryView = function() { + pruneWsprMessageHistory(); + renderWsprHistory(); + }; + function escapeWsprHtml(input) { + return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); + } + function renderWsprMessage(message) { + let out = ""; + let i = 0; + while (i < message.length) { + const ch = message[i]; + if (isAlphaNum(ch)) { + let j = i + 1; + while (j < message.length && isAlphaNum(message[j])) j++; + const token = message.slice(i, j); + const grid = token.toUpperCase(); + if (isMaidenheadGridToken(grid)) { + out += `${grid}`; + } else { + out += escapeWsprHtml(token); + } + i = j; + } else { + out += escapeWsprHtml(ch ?? ""); + i += 1; + } + } + return out; + } + function extractAllGrids(message) { + const out = []; + const seen = /* @__PURE__ */ new Set(); + const parts = message.toUpperCase().split(/[^A-Z0-9]+/); + for (const token of parts) { + if (!token) continue; + if (isMaidenheadGridToken(token) && !seen.has(token)) { + seen.add(token); + out.push(token); + } + } + return out; + } + function extractLikelyCallsign(message) { + const parts = message.toUpperCase().split(/[^A-Z0-9/]+/); + for (const token of parts) { + if (!token) continue; + if (token.length < 3 || token.length > 12) continue; + if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue; + if (isMaidenheadGridToken(token)) continue; + if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token; + } + return null; + } + function isFtxFarewellToken(token) { + const normalized = token.trim().toUpperCase(); + return normalized === "RR73" || normalized === "73" || normalized === "RR"; + } + function isMaidenheadGridToken(token) { + const normalized = token.trim().toUpperCase(); + return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized); + } + function isAlphaNum(ch) { + return ch !== void 0 && /[A-Za-z0-9]/.test(ch); + } + function activateWsprHistoryLocator(target) { + if (!(target instanceof Element)) return false; + const locatorEl = target.closest(".ft8-locator[data-locator-grid]"); + if (!locatorEl) return false; + const grid = (locatorEl.dataset.locatorGrid || "").toUpperCase(); + if (!grid) return false; + if (typeof wsprWindow.navigateToMapLocator === "function") { + wsprWindow.navigateToMapLocator(grid, "wspr"); + } + return true; + } + function applyWsprFilterToRow(row) { + if (!wsprFilterText) { + row.style.display = ""; + return; + } + const message = row.dataset.message || ""; + row.style.display = message.includes(wsprFilterText) ? "" : "none"; + } + wsprWindow.resetWsprHistoryView = function() { + if (wsprMessagesEl) wsprMessagesEl.innerHTML = ""; + wsprMessageHistory = []; + renderWsprHistory(); + if (wsprWindow.clearMapMarkersByType) wsprWindow.clearMapMarkersByType("wspr"); + }; + if (wsprFilterInput) { + wsprFilterInput.addEventListener("input", () => { + wsprFilterText = wsprFilterInput.value.trim().toUpperCase(); + renderWsprHistory(); }); } - addWsprMessage(next.history); -}; + if (wsprMessagesEl) { + wsprMessagesEl.addEventListener("click", (event) => { + if (!activateWsprHistoryLocator(event.target)) return; + event.preventDefault(); + event.stopPropagation(); + }); + wsprMessagesEl.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + if (!activateWsprHistoryLocator(event.target)) return; + event.preventDefault(); + event.stopPropagation(); + }); + } + var wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn"); + wsprDecodeToggleBtn?.addEventListener("click", () => { + void (async () => { + try { + await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn); + await wsprWindow.postPath?.("/toggle_wspr_decode"); + } catch (error) { + console.error("WSPR toggle failed", error); + } + })(); + }); + document.getElementById("settings-clear-wspr-history")?.addEventListener("click", () => { + void (async () => { + if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await wsprWindow.postPath?.("/clear_wspr_decode"); + wsprWindow.resetWsprHistoryView?.(); + } catch (error) { + console.error("WSPR history clear failed", error); + } + })(); + }); + wsprWindow.onServerWspr = function(msg) { + if (wsprStatus) wsprStatus.textContent = "Receiving"; + const next = normalizeServerWsprMessage(msg); + if (next.grids.length > 0 && wsprWindow.mapAddLocator) { + wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { + ...msg, + ...next.rfHz === null ? {} : { freq_hz: next.rfHz } + }); + } + addWsprMessage(next.history); + }; +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs index 8a1825ef..c74e7bfc 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs @@ -35,7 +35,6 @@ await build({ vchan: path.join(sourceDir, "plugins", "vchan.js"), vdes: path.join(sourceDir, "plugins", "vdes.js"), wefax: path.join(sourceDir, "plugins", "wefax.js"), - wspr: path.join(sourceDir, "plugins", "wspr.js"), }, outdir: outputDir, bundle: false, @@ -50,6 +49,7 @@ await build({ entryPoints: { ft2: path.join(sourceDir, "plugins", "ft2.ts"), ft4: path.join(sourceDir, "plugins", "ft4.ts"), + wspr: path.join(sourceDir, "plugins", "wspr.ts"), }, outdir: outputDir, bundle: true, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.js deleted file mode 100644 index a5392325..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.js +++ /dev/null @@ -1,292 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Stan Grams -// -// SPDX-License-Identifier: GPL-2.0-or-later - -// --- WSPR Decoder Plugin (server-side decode) --- -const wsprStatus = document.getElementById("wspr-status"); -const wsprPeriodEl = document.getElementById("wspr-period"); -const wsprMessagesEl = document.getElementById("wspr-messages"); -const wsprFilterInput = document.getElementById("wspr-filter"); -const WSPR_PERIOD_SECONDS = 120; -let wsprFilterText = ""; -let wsprMessageHistory = []; - -function currentWsprHistoryRetentionMs() { - return typeof window.getDecodeHistoryRetentionMs === "function" - ? window.getDecodeHistoryRetentionMs() - : 24 * 60 * 60 * 1000; -} - -function pruneWsprMessageHistory() { - const cutoffMs = Date.now() - currentWsprHistoryRetentionMs(); - wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); -} - -function scheduleWsprHistoryRender() { - if (typeof window.trxScheduleUiFrameJob === "function") { - window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory()); - return; - } - renderWsprHistory(); -} - -function fmtWsprTime(tsMs) { - if (!tsMs) return "--:--:--"; - return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); -} - -function updateWsprPeriodTimer() { - if (!wsprPeriodEl) return; - const nowSec = Math.floor(Date.now() / 1000); - const remaining = WSPR_PERIOD_SECONDS - (nowSec % WSPR_PERIOD_SECONDS); - const mm = String(Math.floor(remaining / 60)).padStart(2, "0"); - const ss = String(remaining % 60).padStart(2, "0"); - wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`; -} - -updateWsprPeriodTimer(); -setInterval(updateWsprPeriodTimer, 500); - -function renderWsprRow(msg) { - const row = document.createElement("div"); - row.className = "ft8-row"; - row.dataset.decoder = "wspr"; - const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--"; - const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--"; - const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null; - const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? (baseHz + msg.freq_hz) : null; - const freq = Number.isFinite(rfHz) ? rfHz.toFixed(0) : "--"; - const message = (msg.message || "").toString(); - row.dataset.message = message.toUpperCase(); - row.innerHTML = `${fmtWsprTime(msg.ts_ms)}${snr}${dt}${freq}${renderWsprMessage(message)}`; - applyWsprFilterToRow(row); - return row; -} - -function renderWsprHistory() { - pruneWsprMessageHistory(); - if (!wsprMessagesEl) return; - const fragment = document.createDocumentFragment(); - for (let i = 0; i < wsprMessageHistory.length; i += 1) { - fragment.appendChild(renderWsprRow(wsprMessageHistory[i])); - } - wsprMessagesEl.replaceChildren(fragment); -} - -function addWsprMessage(msg) { - msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); - wsprMessageHistory.unshift(msg); - pruneWsprMessageHistory(); - scheduleWsprHistoryRender(); -} - -function normalizeServerWsprMessage(msg) { - const raw = (msg.message || "").toString(); - const grids = extractAllGrids(raw); - const station = extractLikelyCallsign(raw); - const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null; - const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) - ? (baseHz + Number(msg.freq_hz)) - : (Number.isFinite(msg.freq_hz) ? Number(msg.freq_hz) : null); - return { - raw, - grids, - station, - rfHz, - history: { - receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, - ts_ms: msg.ts_ms, - snr_db: msg.snr_db, - dt_s: msg.dt_s, - freq_hz: msg.freq_hz, - message: raw, - }, - }; -} - -window.onServerWsprBatch = function(messages) { - if (!Array.isArray(messages) || messages.length === 0) return; - wsprStatus.textContent = "Receiving"; - const normalized = []; - for (const msg of messages) { - const next = normalizeServerWsprMessage(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "wspr", next.station, { - ...msg, - freq_hz: next.rfHz, - }); - } - next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now(); - normalized.push(next.history); - } - normalized.reverse(); - wsprMessageHistory = normalized.concat(wsprMessageHistory); - pruneWsprMessageHistory(); - scheduleWsprHistoryRender(); -}; - -window.restoreWsprHistory = function(messages) { - window.onServerWsprBatch(messages); -}; - -window.pruneWsprHistoryView = function() { - pruneWsprMessageHistory(); - renderWsprHistory(); -}; - -function escapeWsprHtml(input) { - return input - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll("\"", """); -} - -function renderWsprMessage(message) { - let out = ""; - let i = 0; - while (i < message.length) { - const ch = message[i]; - if (isAlphaNum(ch)) { - let j = i + 1; - while (j < message.length && isAlphaNum(message[j])) j++; - const token = message.slice(i, j); - const grid = token.toUpperCase(); - if (isMaidenheadGridToken(grid)) { - out += `${grid}`; - } else { - out += escapeWsprHtml(token); - } - i = j; - } else { - out += escapeWsprHtml(ch); - i += 1; - } - } - return out; -} - -function extractAllGrids(message) { - const out = []; - const seen = new Set(); - const parts = message.toUpperCase().split(/[^A-Z0-9]+/); - for (const token of parts) { - if (!token) continue; - if (isMaidenheadGridToken(token) && !seen.has(token)) { - seen.add(token); - out.push(token); - } - } - return out; -} - -function extractLikelyCallsign(message) { - const parts = String(message || "").toUpperCase().split(/[^A-Z0-9/]+/); - for (const token of parts) { - if (!token) continue; - if (token.length < 3 || token.length > 12) continue; - if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue; - if (isMaidenheadGridToken(token)) continue; - if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token; - } - return null; -} - -function isFtxFarewellToken(token) { - const normalized = String(token || "").trim().toUpperCase(); - return normalized === "RR73" || normalized === "73" || normalized === "RR"; -} - -function isMaidenheadGridToken(token) { - const normalized = String(token || "").trim().toUpperCase(); - return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized); -} - -function isAlphaNum(ch) { - return /[A-Za-z0-9]/.test(ch); -} - -function activateWsprHistoryLocator(targetEl) { - const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]"); - if (!locatorEl) return false; - const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase(); - if (!grid) return false; - if (typeof window.navigateToMapLocator === "function") { - window.navigateToMapLocator(grid, "wspr"); - } - return true; -} - -function applyWsprFilterToRow(row) { - if (!wsprFilterText) { - row.style.display = ""; - return; - } - const message = row.dataset.message || ""; - row.style.display = message.includes(wsprFilterText) ? "" : "none"; -} - -function applyWsprFilterToAll() { - const rows = wsprMessagesEl.querySelectorAll(".ft8-row"); - rows.forEach((row) => applyWsprFilterToRow(row)); -} - -window.resetWsprHistoryView = function() { - wsprMessagesEl.innerHTML = ""; - wsprMessageHistory = []; - renderWsprHistory(); - if (window.clearMapMarkersByType) window.clearMapMarkersByType("wspr"); -}; - -if (wsprFilterInput) { - wsprFilterInput.addEventListener("input", () => { - wsprFilterText = wsprFilterInput.value.trim().toUpperCase(); - renderWsprHistory(); - }); -} - -if (wsprMessagesEl) { - wsprMessagesEl.addEventListener("click", (event) => { - if (!activateWsprHistoryLocator(event.target)) return; - event.preventDefault(); - event.stopPropagation(); - }); - wsprMessagesEl.addEventListener("keydown", (event) => { - if (event.key !== "Enter" && event.key !== " ") return; - if (!activateWsprHistoryLocator(event.target)) return; - event.preventDefault(); - event.stopPropagation(); - }); -} - -const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn"); -wsprDecodeToggleBtn?.addEventListener("click", async () => { - try { - await window.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn); - await postPath("/toggle_wspr_decode"); - } catch (e) { - console.error("WSPR toggle failed", e); - } -}); - -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; - try { - await postPath("/clear_wspr_decode"); - window.resetWsprHistoryView(); - } catch (e) { - console.error("WSPR history clear failed", e); - } -}); - -window.onServerWspr = function(msg) { - wsprStatus.textContent = "Receiving"; - const next = normalizeServerWsprMessage(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "wspr", next.station, { - ...msg, - freq_hz: next.rfHz, - }); - } - addWsprMessage(next.history); -}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.ts new file mode 100644 index 00000000..492e2777 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.ts @@ -0,0 +1,331 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +export {}; + +interface WsprMessage { + message?: string | undefined; + ts_ms?: number | undefined; + _tsMs?: number | undefined; + snr_db?: number | undefined; + dt_s?: number | undefined; + freq_hz?: number | undefined; + receiver?: unknown; + [key: string]: unknown; +} +interface WsprBridge { + getDecodeHistoryRetentionMs?: () => number; + trxScheduleUiFrameJob?: (key: string, job: () => void) => void; + ft8BaseHz?: number; + getDecodeRigMeta?: () => unknown; + mapAddLocator?: (raw: string, grids: string[], type: "wspr", station: string | null, message: WsprMessage) => void; + navigateToMapLocator?: (grid: string, type: "wspr") => void; + clearMapMarkersByType?: (type: "wspr") => void; + takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise; + postPath?: (path: string) => Promise; + trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise }; + onServerWsprBatch?: (messages: WsprMessage[]) => void; + restoreWsprHistory?: (messages: WsprMessage[]) => void; + pruneWsprHistoryView?: () => void; + resetWsprHistoryView?: () => void; + onServerWspr?: (message: WsprMessage) => void; + [key: string]: unknown; +} +const wsprWindow = window as unknown as WsprBridge; + +// --- WSPR Decoder Plugin (server-side decode) --- +const wsprStatus = document.getElementById("wspr-status"); +const wsprPeriodEl = document.getElementById("wspr-period"); +const wsprMessagesEl = document.getElementById("wspr-messages"); +const wsprFilterInput = document.getElementById("wspr-filter") as HTMLInputElement | null; +const WSPR_PERIOD_SECONDS = 120; +let wsprFilterText = ""; +let wsprMessageHistory: WsprMessage[] = []; + +function finiteNumber(value: unknown): number | null { + const number = typeof value === "number" ? value : Number(value); + return Number.isFinite(number) ? number : null; +} + +function currentWsprHistoryRetentionMs(): number { + return typeof wsprWindow.getDecodeHistoryRetentionMs === "function" + ? wsprWindow.getDecodeHistoryRetentionMs() + : 24 * 60 * 60 * 1000; +} + +function pruneWsprMessageHistory(): void { + const cutoffMs = Date.now() - currentWsprHistoryRetentionMs(); + wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs); +} + +function scheduleWsprHistoryRender(): void { + if (typeof wsprWindow.trxScheduleUiFrameJob === "function") { + wsprWindow.trxScheduleUiFrameJob("wspr-history", () => { renderWsprHistory(); }); + return; + } + renderWsprHistory(); +} + +function fmtWsprTime(tsMs: number | undefined): string { + if (!tsMs) return "--:--:--"; + return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function updateWsprPeriodTimer(): void { + if (!wsprPeriodEl) return; + const nowSec = Math.floor(Date.now() / 1000); + const remaining = WSPR_PERIOD_SECONDS - (nowSec % WSPR_PERIOD_SECONDS); + const mm = String(Math.floor(remaining / 60)).padStart(2, "0"); + const ss = String(remaining % 60).padStart(2, "0"); + wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`; +} + +updateWsprPeriodTimer(); +setInterval(updateWsprPeriodTimer, 500); + +function renderWsprRow(msg: WsprMessage): HTMLDivElement { + const row = document.createElement("div"); + row.className = "ft8-row"; + row.dataset.decoder = "wspr"; + const snr = finiteNumber(msg.snr_db); + const delta = finiteNumber(msg.dt_s); + const baseHz = finiteNumber(wsprWindow.ft8BaseHz); + const offsetHz = finiteNumber(msg.freq_hz); + const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : null; + const freq = rfHz?.toFixed(0) ?? "--"; + const message = msg.message ?? ""; + row.dataset.message = message.toUpperCase(); + row.innerHTML = `${fmtWsprTime(msg.ts_ms)}${snr?.toFixed(1) ?? "--"}${delta?.toFixed(2) ?? "--"}${freq}${renderWsprMessage(message)}`; + applyWsprFilterToRow(row); + return row; +} + +function renderWsprHistory(): void { + pruneWsprMessageHistory(); + if (!wsprMessagesEl) return; + const fragment = document.createDocumentFragment(); + for (let i = 0; i < wsprMessageHistory.length; i += 1) { + const message = wsprMessageHistory[i]; + if (message) fragment.appendChild(renderWsprRow(message)); + } + wsprMessagesEl.replaceChildren(fragment); +} + +function addWsprMessage(msg: WsprMessage): void { + msg._tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now(); + wsprMessageHistory.unshift(msg); + pruneWsprMessageHistory(); + scheduleWsprHistoryRender(); +} + +function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: string[]; station: string | null; rfHz: number | null; history: WsprMessage } { + const raw = msg.message ?? ""; + const grids = extractAllGrids(raw); + const station = extractLikelyCallsign(raw); + const baseHz = finiteNumber(wsprWindow.ft8BaseHz); + const offsetHz = finiteNumber(msg.freq_hz); + const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : offsetHz; + return { + raw, + grids, + station, + rfHz, + history: { + receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null, + ts_ms: msg.ts_ms, + snr_db: msg.snr_db, + dt_s: msg.dt_s, + freq_hz: msg.freq_hz, + message: raw, + }, + }; +} + +wsprWindow.onServerWsprBatch = function(messages: WsprMessage[]) { + if (!Array.isArray(messages) || messages.length === 0) return; + if (wsprStatus) wsprStatus.textContent = "Receiving"; + const normalized: WsprMessage[] = []; + for (const msg of messages) { + const next = normalizeServerWsprMessage(msg); + if (next.grids.length > 0 && wsprWindow.mapAddLocator) { + wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { + ...msg, + ...(next.rfHz === null ? {} : { freq_hz: next.rfHz }), + }); + } + next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now(); + normalized.push(next.history); + } + normalized.reverse(); + wsprMessageHistory = normalized.concat(wsprMessageHistory); + pruneWsprMessageHistory(); + scheduleWsprHistoryRender(); +}; + +wsprWindow.restoreWsprHistory = function(messages: WsprMessage[]) { + const callback = wsprWindow.onServerWsprBatch; + if (typeof callback === "function") callback(messages); +}; + +wsprWindow.pruneWsprHistoryView = function() { + pruneWsprMessageHistory(); + renderWsprHistory(); +}; + +function escapeWsprHtml(input: string): string { + return input + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """); +} + +function renderWsprMessage(message: string): string { + let out = ""; + let i = 0; + while (i < message.length) { + const ch = message[i]; + if (isAlphaNum(ch)) { + let j = i + 1; + while (j < message.length && isAlphaNum(message[j])) j++; + const token = message.slice(i, j); + const grid = token.toUpperCase(); + if (isMaidenheadGridToken(grid)) { + out += `${grid}`; + } else { + out += escapeWsprHtml(token); + } + i = j; + } else { + out += escapeWsprHtml(ch ?? ""); + i += 1; + } + } + return out; +} + +function extractAllGrids(message: string): string[] { + const out: string[] = []; + const seen = new Set(); + const parts = message.toUpperCase().split(/[^A-Z0-9]+/); + for (const token of parts) { + if (!token) continue; + if (isMaidenheadGridToken(token) && !seen.has(token)) { + seen.add(token); + out.push(token); + } + } + return out; +} + +function extractLikelyCallsign(message: string): string | null { + const parts = message.toUpperCase().split(/[^A-Z0-9/]+/); + for (const token of parts) { + if (!token) continue; + if (token.length < 3 || token.length > 12) continue; + if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue; + if (isMaidenheadGridToken(token)) continue; + if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token; + } + return null; +} + +function isFtxFarewellToken(token: string): boolean { + const normalized = token.trim().toUpperCase(); + return normalized === "RR73" || normalized === "73" || normalized === "RR"; +} + +function isMaidenheadGridToken(token: string): boolean { + const normalized = token.trim().toUpperCase(); + return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized); +} + +function isAlphaNum(ch: string | undefined): boolean { + return ch !== undefined && /[A-Za-z0-9]/.test(ch); +} + +function activateWsprHistoryLocator(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + const locatorEl = target.closest(".ft8-locator[data-locator-grid]"); + if (!locatorEl) return false; + const grid = (locatorEl.dataset.locatorGrid || "").toUpperCase(); + if (!grid) return false; + if (typeof wsprWindow.navigateToMapLocator === "function") { + wsprWindow.navigateToMapLocator(grid, "wspr"); + } + return true; +} + +function applyWsprFilterToRow(row: HTMLElement): void { + if (!wsprFilterText) { + row.style.display = ""; + return; + } + const message = row.dataset.message || ""; + row.style.display = message.includes(wsprFilterText) ? "" : "none"; +} + +wsprWindow.resetWsprHistoryView = function() { + if (wsprMessagesEl) wsprMessagesEl.innerHTML = ""; + wsprMessageHistory = []; + renderWsprHistory(); + if (wsprWindow.clearMapMarkersByType) wsprWindow.clearMapMarkersByType("wspr"); +}; + +if (wsprFilterInput) { + wsprFilterInput.addEventListener("input", () => { + wsprFilterText = wsprFilterInput.value.trim().toUpperCase(); + renderWsprHistory(); + }); +} + +if (wsprMessagesEl) { + wsprMessagesEl.addEventListener("click", (event) => { + if (!activateWsprHistoryLocator(event.target)) return; + event.preventDefault(); + event.stopPropagation(); + }); + wsprMessagesEl.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + if (!activateWsprHistoryLocator(event.target)) return; + event.preventDefault(); + event.stopPropagation(); + }); +} + +const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn"); +wsprDecodeToggleBtn?.addEventListener("click", () => { + void (async () => { + try { + await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn); + await wsprWindow.postPath?.("/toggle_wspr_decode"); + } catch (error: unknown) { + console.error("WSPR toggle failed", error); + } + })(); +}); + +document.getElementById("settings-clear-wspr-history")?.addEventListener("click", () => { + void (async () => { + if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await wsprWindow.postPath?.("/clear_wspr_decode"); + wsprWindow.resetWsprHistoryView?.(); + } catch (error: unknown) { + console.error("WSPR history clear failed", error); + } + })(); +}); + +wsprWindow.onServerWspr = function(msg: WsprMessage) { + if (wsprStatus) wsprStatus.textContent = "Receiving"; + const next = normalizeServerWsprMessage(msg); + if (next.grids.length > 0 && wsprWindow.mapAddLocator) { + wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { + ...msg, + ...(next.rfHz === null ? {} : { freq_hz: next.rfHz }), + }); + } + addWsprMessage(next.history); +}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wspr.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wspr.test.mjs new file mode 100644 index 00000000..9e341ef4 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wspr.test.mjs @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +test("WSPR entry forwards decoded locators at their absolute frequency", async () => { + const forwarded = []; + const window = { + ft8BaseHz: 14_095_600, + trxUi: { confirm: async () => true }, + mapAddLocator: (...args) => { forwarded.push(args); }, + }; + const context = vm.createContext({ + window, + document: { getElementById: () => null, createDocumentFragment: () => ({ appendChild() {} }) }, + setInterval() { return 1; }, + Date, + Number, + String, + Set, + Element: class Element {}, + console, + }); + const source = await readFile(new URL("../../assets/web/generated/wspr.js", import.meta.url), "utf8"); + new vm.Script(source).runInContext(context); + + window.onServerWspr({ message: "SP0ABC JO91 37", freq_hz: 1_420, ts_ms: Date.now() }); + assert.equal(forwarded.length, 1); + assert.equal(forwarded[0][1][0], "JO91"); + assert.equal(forwarded[0][4].freq_hz, 14_097_020); +});