refactor: convert WSPR plugin to TypeScript
This commit is contained in:
@@ -1,251 +1,266 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
const wsprStatus = document.getElementById("wspr-status");
|
(() => {
|
||||||
const wsprPeriodEl = document.getElementById("wspr-period");
|
// src/plugins/wspr.ts
|
||||||
const wsprMessagesEl = document.getElementById("wspr-messages");
|
var wsprWindow = window;
|
||||||
const wsprFilterInput = document.getElementById("wspr-filter");
|
var wsprStatus = document.getElementById("wspr-status");
|
||||||
const WSPR_PERIOD_SECONDS = 120;
|
var wsprPeriodEl = document.getElementById("wspr-period");
|
||||||
let wsprFilterText = "";
|
var wsprMessagesEl = document.getElementById("wspr-messages");
|
||||||
let wsprMessageHistory = [];
|
var wsprFilterInput = document.getElementById("wspr-filter");
|
||||||
function currentWsprHistoryRetentionMs() {
|
var WSPR_PERIOD_SECONDS = 120;
|
||||||
return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
var wsprFilterText = "";
|
||||||
}
|
var wsprMessageHistory = [];
|
||||||
function pruneWsprMessageHistory() {
|
function finiteNumber(value) {
|
||||||
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
|
const number = typeof value === "number" ? value : Number(value);
|
||||||
wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
return Number.isFinite(number) ? number : null;
|
||||||
}
|
|
||||||
function scheduleWsprHistoryRender() {
|
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
|
||||||
window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory());
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
renderWsprHistory();
|
function currentWsprHistoryRetentionMs() {
|
||||||
}
|
return typeof wsprWindow.getDecodeHistoryRetentionMs === "function" ? wsprWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
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 = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
|
|
||||||
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 pruneWsprMessageHistory() {
|
||||||
}
|
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
|
||||||
function addWsprMessage(msg) {
|
wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs);
|
||||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
}
|
||||||
wsprMessageHistory.unshift(msg);
|
function scheduleWsprHistoryRender() {
|
||||||
pruneWsprMessageHistory();
|
if (typeof wsprWindow.trxScheduleUiFrameJob === "function") {
|
||||||
scheduleWsprHistoryRender();
|
wsprWindow.trxScheduleUiFrameJob("wspr-history", () => {
|
||||||
}
|
renderWsprHistory();
|
||||||
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
|
|
||||||
});
|
});
|
||||||
|
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 += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
|
||||||
} 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();
|
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);
|
|
||||||
}
|
}
|
||||||
});
|
function fmtWsprTime(tsMs) {
|
||||||
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => {
|
if (!tsMs) return "--:--:--";
|
||||||
if (!await window.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
try {
|
|
||||||
await postPath("/clear_wspr_decode");
|
|
||||||
window.resetWsprHistoryView();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("WSPR history clear failed", e);
|
|
||||||
}
|
}
|
||||||
});
|
function updateWsprPeriodTimer() {
|
||||||
window.onServerWspr = function(msg) {
|
if (!wsprPeriodEl) return;
|
||||||
wsprStatus.textContent = "Receiving";
|
const nowSec = Math.floor(Date.now() / 1e3);
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const remaining = WSPR_PERIOD_SECONDS - nowSec % WSPR_PERIOD_SECONDS;
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
|
||||||
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
const ss = String(remaining % 60).padStart(2, "0");
|
||||||
...msg,
|
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
|
||||||
freq_hz: next.rfHz
|
}
|
||||||
|
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 = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
|
||||||
|
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 += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
||||||
|
} 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);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ await build({
|
|||||||
vchan: path.join(sourceDir, "plugins", "vchan.js"),
|
vchan: path.join(sourceDir, "plugins", "vchan.js"),
|
||||||
vdes: path.join(sourceDir, "plugins", "vdes.js"),
|
vdes: path.join(sourceDir, "plugins", "vdes.js"),
|
||||||
wefax: path.join(sourceDir, "plugins", "wefax.js"),
|
wefax: path.join(sourceDir, "plugins", "wefax.js"),
|
||||||
wspr: path.join(sourceDir, "plugins", "wspr.js"),
|
|
||||||
},
|
},
|
||||||
outdir: outputDir,
|
outdir: outputDir,
|
||||||
bundle: false,
|
bundle: false,
|
||||||
@@ -50,6 +49,7 @@ await build({
|
|||||||
entryPoints: {
|
entryPoints: {
|
||||||
ft2: path.join(sourceDir, "plugins", "ft2.ts"),
|
ft2: path.join(sourceDir, "plugins", "ft2.ts"),
|
||||||
ft4: path.join(sourceDir, "plugins", "ft4.ts"),
|
ft4: path.join(sourceDir, "plugins", "ft4.ts"),
|
||||||
|
wspr: path.join(sourceDir, "plugins", "wspr.ts"),
|
||||||
},
|
},
|
||||||
outdir: outputDir,
|
outdir: outputDir,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
|
|||||||
@@ -1,292 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// 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 = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
|
|
||||||
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 += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
|
||||||
} 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);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// 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<void>;
|
||||||
|
postPath?: (path: string) => Promise<unknown>;
|
||||||
|
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||||
|
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 = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
|
||||||
|
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 += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
||||||
|
} 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<string>();
|
||||||
|
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<HTMLElement>(".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);
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user