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);
+});