refactor: convert VDES plugin to TypeScript

This commit is contained in:
sjg
2026-08-01 12:30:17 +02:00
parent 2cbcc23fec
commit 0ab0f80986
6 changed files with 409 additions and 365 deletions
@@ -10,7 +10,7 @@ const pluginGroups = {
}; };
const loaded = /* @__PURE__ */ new Set(); const loaded = /* @__PURE__ */ new Set();
const loading = /* @__PURE__ */ new Map(); const loading = /* @__PURE__ */ new Map();
const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js"]); const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js"]);
function loadLegacyScript(path) { function loadLegacyScript(path) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const script = document.createElement("script"); const script = document.createElement("script");
@@ -1,282 +1,267 @@
"use strict"; "use strict";
const vdesStatus = document.getElementById("vdes-status"); (() => {
const vdesMessagesEl = document.getElementById("vdes-messages"); // src/plugins/vdes.ts
const vdesFilterInput = document.getElementById("vdes-filter"); var vdesWindow = window;
const vdesBarOverlay = document.getElementById("vdes-bar-overlay"); var escapeVdesHtml = (input) => vdesWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
const vdesChannelSummaryEl = document.getElementById("vdes-channel-summary"); var vdesStatus = document.getElementById("vdes-status");
const vdesFrameCountEl = document.getElementById("vdes-frame-count"); var vdesMessagesEl = document.getElementById("vdes-messages");
const vdesLatestSeenEl = document.getElementById("vdes-latest-seen"); var vdesFilterInput = document.getElementById("vdes-filter");
const VDES_BAR_WINDOW_MS = 15 * 60 * 1e3; var vdesBarOverlay = document.getElementById("vdes-bar-overlay");
let vdesFilterText = ""; var vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
let vdesMessageHistory = []; var vdesFrameCountEl = document.getElementById("vdes-frame-count");
function currentVdesHistoryRetentionMs() { var vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; var VDES_BAR_WINDOW_MS = 15 * 60 * 1e3;
} var vdesFilterText = "";
function pruneVdesMessageHistory() { var vdesMessageHistory = [];
const cutoffMs = Date.now() - currentVdesHistoryRetentionMs(); function currentVdesHistoryRetentionMs() {
vdesMessageHistory = vdesMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs); return typeof vdesWindow.getDecodeHistoryRetentionMs === "function" ? vdesWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
}
function scheduleVdesUi(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job);
return;
} }
job(); function pruneVdesMessageHistory() {
} const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
function scheduleVdesHistoryRender() { vdesMessageHistory = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
scheduleVdesUi("vdes-history", () => renderVdesHistory());
}
function scheduleVdesBarUpdate() {
scheduleVdesUi("vdes-bar", () => updateVdesBar());
}
function currentVdesCenterText() {
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
const hz = raw ? Number(raw) : 0;
if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency";
return `100 kHz @ ${(hz / 1e6).toFixed(3)} MHz`;
}
function vdesAgeText(tsMs) {
if (!Number.isFinite(tsMs)) return "just now";
const deltaMs = Math.max(0, Date.now() - tsMs);
const seconds = Math.round(deltaMs / 1e3);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
return `${hours}h ago`;
}
function vdesHexPreview(rawBytes) {
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
return rawBytes.slice(0, 20).map((value) => Number(value).toString(16).padStart(2, "0")).join(" ").toUpperCase();
}
function updateVdesSummary() {
pruneVdesMessageHistory();
if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText();
} }
if (vdesFrameCountEl) { function scheduleVdesUi(key, job) {
const count = vdesMessageHistory.length; if (typeof vdesWindow.trxScheduleUiFrameJob === "function") {
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`; vdesWindow.trxScheduleUiFrameJob(key, job);
return;
}
job();
} }
if (vdesLatestSeenEl) { function scheduleVdesHistoryRender() {
const latest = vdesMessageHistory[0]; scheduleVdesUi("vdes-history", () => {
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet"; renderVdesHistory();
});
} }
} function scheduleVdesBarUpdate() {
function applyVdesFilterToRow(row) { scheduleVdesUi("vdes-bar", () => {
if (!vdesFilterText) { updateVdesBar();
row.style.display = ""; });
return;
} }
const text = row.dataset.filterText || ""; function currentVdesCenterText() {
row.style.display = text.includes(vdesFilterText) ? "" : "none"; const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
} const hz = raw ? Number(raw) : 0;
function applyVdesFilterToAll() { if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency";
if (!vdesMessagesEl) return; return `100 kHz @ ${(hz / 1e6).toFixed(3)} MHz`;
vdesMessagesEl.querySelectorAll(".vdes-message").forEach((row) => applyVdesFilterToRow(row));
}
function renderVdesRow(msg) {
const row = document.createElement("div");
row.className = "vdes-message";
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
const title = msg.vessel_name || "VDES Burst";
const label = msg.callsign || "VDES";
const info = msg.destination || "";
const labelText = msg.message_label || "";
const linkText = Number.isFinite(msg.link_id) ? `LID ${msg.link_id}` : "";
const syncText = Number.isFinite(msg.sync_score) ? `Sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : "";
const phaseText = Number.isFinite(msg.phase_rotation) ? `R${Number(msg.phase_rotation)}` : "";
const fecText = msg.fec_state || "";
const srcText = Number.isFinite(msg.source_id) ? `SRC ${Number(msg.source_id)}` : "";
const dstText = Number.isFinite(msg.destination_id) ? `DST ${Number(msg.destination_id)}` : "";
const sessionText = Number.isFinite(msg.session_id) ? `S${Number(msg.session_id)}` : "";
const asmText = Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : "";
const countText = Number.isFinite(msg.data_count) ? `${Number(msg.data_count)} data bits` : "";
const ackText = Number.isFinite(msg.ack_nack_mask) ? `ACK 0x${Number(msg.ack_nack_mask).toString(16).toUpperCase().padStart(4, "0")}` : "";
const cqiText = Number.isFinite(msg.channel_quality) ? `CQ ${Number(msg.channel_quality)}` : "";
const previewText = msg.payload_preview || "";
const rawHex = vdesHexPreview(msg.raw_bytes);
row.dataset.filterText = [
title,
label,
labelText,
info,
srcText,
dstText,
sessionText,
asmText,
countText,
ackText,
cqiText,
previewText,
linkText,
syncText,
phaseText,
fecText,
rawHex,
msg.message_type,
msg.bit_len
].filter(Boolean).join(" ").toUpperCase();
row.innerHTML = `<div class="vdes-row-head"><span class="vdes-time">${ts}</span><span class="vdes-call">${escapeMapHtml(title)}</span><span class="vdes-badge">${escapeMapHtml(label)}</span>` + (labelText ? `<span class="vdes-badge">${escapeMapHtml(labelText)}</span>` : "") + (linkText ? `<span class="vdes-badge">${escapeMapHtml(linkText)}</span>` : "") + (srcText ? `<span class="vdes-badge">${escapeMapHtml(srcText)}</span>` : "") + (dstText ? `<span class="vdes-badge">${escapeMapHtml(dstText)}</span>` : "") + (syncText ? `<span class="vdes-badge">${escapeMapHtml(syncText)}</span>` : "") + (phaseText ? `<span class="vdes-badge">${escapeMapHtml(phaseText)}</span>` : "") + `<span class="vdes-badge">T${escapeMapHtml(String(msg.message_type ?? "--"))}</span></div><div class="vdes-row-meta"><span>${escapeMapHtml(currentVdesCenterText())}</span><span>${escapeMapHtml(`${msg.bit_len || 0} bits`)}</span>` + (sessionText ? `<span>${escapeMapHtml(sessionText)}</span>` : "") + (asmText ? `<span>${escapeMapHtml(asmText)}</span>` : "") + (countText ? `<span>${escapeMapHtml(countText)}</span>` : "") + (ackText ? `<span>${escapeMapHtml(ackText)}</span>` : "") + (cqiText ? `<span>${escapeMapHtml(cqiText)}</span>` : "") + (info ? `<span>${escapeMapHtml(info)}</span>` : "") + (fecText ? `<span>${escapeMapHtml(fecText)}</span>` : "") + `<span>${escapeMapHtml(vdesAgeText(msg._tsMs))}</span></div><div class="vdes-row-detail">` + (previewText ? `<span>${escapeMapHtml(previewText)}</span>` : "") + (previewText ? `<span>·</span>` : "") + `<span class="vdes-raw">${escapeMapHtml(rawHex)}</span></div>`;
applyVdesFilterToRow(row);
return row;
}
function updateVdesBar() {
if (!vdesBarOverlay) return;
updateVdesSummary();
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
const messages = vdesMessageHistory.filter((msg) => msg._tsMs >= cutoffMs).slice(0, 6);
if (!isVdes || messages.length === 0) {
vdesBarOverlay.style.display = "none";
vdesBarOverlay.innerHTML = "";
return;
} }
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">VDES</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearVdesBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearVdesBar();}" aria-label="Clear VDES overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>`; function vdesAgeText(tsMs) {
for (const msg of messages) { if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : ""; const deltaMs = Math.max(0, Date.now() - tsMs);
const label = escapeMapHtml(msg.callsign || "VDES"); const seconds = Math.round(deltaMs / 1e3);
const title = escapeMapHtml(msg.vessel_name || "Burst"); if (seconds < 5) return "just now";
const detail = [ if (seconds < 60) return `${seconds}s ago`;
`${msg.bit_len || 0} bits`, const minutes = Math.round(seconds / 60);
msg.message_label ? escapeMapHtml(msg.message_label) : null, if (minutes < 60) return `${minutes}m ago`;
Number.isFinite(msg.source_id) ? `src ${Number(msg.source_id)}` : null, const hours = Math.round(minutes / 60);
Number.isFinite(msg.destination_id) ? `dst ${Number(msg.destination_id)}` : null, return `${hours}h ago`;
Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_id)}` : null,
Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : null,
Number.isFinite(msg.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null,
Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null,
msg.destination ? escapeMapHtml(msg.destination) : null,
escapeMapHtml(vdesAgeText(msg._tsMs))
].filter(Boolean).join(" · ");
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="vdes-call">${title}</span> <span class="vdes-badge">${label}</span>: ${detail}</div></div>`;
} }
vdesBarOverlay.innerHTML = html; function vdesHexPreview(rawBytes) {
vdesBarOverlay.style.display = "flex"; if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
} return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
window.updateVdesBar = updateVdesBar;
window.clearVdesBar = function() {
window.resetVdesHistoryView();
};
window.resetVdesHistoryView = function() {
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
vdesMessageHistory = [];
updateVdesBar();
renderVdesHistory();
};
function renderVdesHistory() {
pruneVdesMessageHistory();
if (!vdesMessagesEl) {
updateVdesSummary();
return;
} }
const fragment = document.createDocumentFragment(); function updateVdesSummary() {
for (let i = 0; i < vdesMessageHistory.length; i += 1) { pruneVdesMessageHistory();
fragment.appendChild(renderVdesRow(vdesMessageHistory[i])); if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText();
}
if (vdesFrameCountEl) {
const count = vdesMessageHistory.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
}
if (vdesLatestSeenEl) {
const latest = vdesMessageHistory[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
}
} }
vdesMessagesEl.replaceChildren(fragment); function applyVdesFilterToRow(row) {
updateVdesSummary(); if (!vdesFilterText) {
} row.style.display = "";
function addVdesMessage(msg) { return;
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now(); }
msg._tsMs = tsMs; const text = row.dataset.filterText || "";
msg._ts = new Date(tsMs).toLocaleTimeString([], { row.style.display = text.includes(vdesFilterText) ? "" : "none";
hour: "2-digit", }
minute: "2-digit", function renderVdesRow(msg) {
second: "2-digit" const row = document.createElement("div");
}); row.className = "vdes-message";
vdesMessageHistory.unshift(msg); const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
pruneVdesMessageHistory();
scheduleVdesBarUpdate();
scheduleVdesHistoryRender();
}
function normalizeServerVdesMessage(msg) {
return {
rig_id: msg.rig_id || null,
message_type: msg.message_type,
bit_len: msg.bit_len,
raw_bytes: msg.raw_bytes,
lat: msg.lat,
lon: msg.lon,
vessel_name: msg.vessel_name,
callsign: msg.callsign,
destination: msg.destination,
message_label: msg.message_label,
session_id: msg.session_id,
source_id: msg.source_id,
destination_id: msg.destination_id,
data_count: msg.data_count,
asm_identifier: msg.asm_identifier,
ack_nack_mask: msg.ack_nack_mask,
channel_quality: msg.channel_quality,
payload_preview: msg.payload_preview,
link_id: msg.link_id,
sync_score: msg.sync_score,
sync_errors: msg.sync_errors,
phase_rotation: msg.phase_rotation,
fec_state: msg.fec_state,
ts_ms: msg.ts_ms
};
}
window.onServerVdesBatch = function(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerVdesMessage(msg);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
second: "2-digit" second: "2-digit"
}); });
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) { const title = msg.vessel_name || "VDES Burst";
window.vdesMapAddPoint(next); const label = msg.callsign || "VDES";
const info = msg.destination || "";
const labelText = msg.message_label || "";
const linkText = Number.isFinite(msg.link_id) ? `LID ${msg.link_id}` : "";
const syncText = Number.isFinite(msg.sync_score) ? `Sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : "";
const phaseText = Number.isFinite(msg.phase_rotation) ? `R${Number(msg.phase_rotation)}` : "";
const fecText = msg.fec_state || "";
const srcText = Number.isFinite(msg.source_id) ? `SRC ${Number(msg.source_id)}` : "";
const dstText = Number.isFinite(msg.destination_id) ? `DST ${Number(msg.destination_id)}` : "";
const sessionText = Number.isFinite(msg.session_id) ? `S${Number(msg.session_id)}` : "";
const asmText = Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : "";
const countText = Number.isFinite(msg.data_count) ? `${Number(msg.data_count)} data bits` : "";
const ackText = Number.isFinite(msg.ack_nack_mask) ? `ACK 0x${Number(msg.ack_nack_mask).toString(16).toUpperCase().padStart(4, "0")}` : "";
const cqiText = Number.isFinite(msg.channel_quality) ? `CQ ${Number(msg.channel_quality)}` : "";
const previewText = msg.payload_preview || "";
const rawHex = vdesHexPreview(msg.raw_bytes);
row.dataset.filterText = [
title,
label,
labelText,
info,
srcText,
dstText,
sessionText,
asmText,
countText,
ackText,
cqiText,
previewText,
linkText,
syncText,
phaseText,
fecText,
rawHex,
msg.message_type,
msg.bit_len
].filter(Boolean).join(" ").toUpperCase();
row.innerHTML = `<div class="vdes-row-head"><span class="vdes-time">${ts}</span><span class="vdes-call">${escapeVdesHtml(title)}</span><span class="vdes-badge">${escapeVdesHtml(label)}</span>` + (labelText ? `<span class="vdes-badge">${escapeVdesHtml(labelText)}</span>` : "") + (linkText ? `<span class="vdes-badge">${escapeVdesHtml(linkText)}</span>` : "") + (srcText ? `<span class="vdes-badge">${escapeVdesHtml(srcText)}</span>` : "") + (dstText ? `<span class="vdes-badge">${escapeVdesHtml(dstText)}</span>` : "") + (syncText ? `<span class="vdes-badge">${escapeVdesHtml(syncText)}</span>` : "") + (phaseText ? `<span class="vdes-badge">${escapeVdesHtml(phaseText)}</span>` : "") + `<span class="vdes-badge">T${escapeVdesHtml(String(msg.message_type ?? "--"))}</span></div><div class="vdes-row-meta"><span>${escapeVdesHtml(currentVdesCenterText())}</span><span>${escapeVdesHtml(`${msg.bit_len || 0} bits`)}</span>` + (sessionText ? `<span>${escapeVdesHtml(sessionText)}</span>` : "") + (asmText ? `<span>${escapeVdesHtml(asmText)}</span>` : "") + (countText ? `<span>${escapeVdesHtml(countText)}</span>` : "") + (ackText ? `<span>${escapeVdesHtml(ackText)}</span>` : "") + (cqiText ? `<span>${escapeVdesHtml(cqiText)}</span>` : "") + (info ? `<span>${escapeVdesHtml(info)}</span>` : "") + (fecText ? `<span>${escapeVdesHtml(fecText)}</span>` : "") + `<span>${escapeVdesHtml(vdesAgeText(msg._tsMs))}</span></div><div class="vdes-row-detail">` + (previewText ? `<span>${escapeVdesHtml(previewText)}</span>` : "") + (previewText ? `<span>·</span>` : "") + `<span class="vdes-raw">${escapeVdesHtml(rawHex)}</span></div>`;
applyVdesFilterToRow(row);
return row;
}
function updateVdesBar() {
if (!vdesBarOverlay) return;
updateVdesSummary();
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
if (!isVdes || messages.length === 0) {
vdesBarOverlay.style.display = "none";
vdesBarOverlay.innerHTML = "";
return;
} }
normalized.push(next); let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">VDES</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearVdesBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearVdesBar();}" aria-label="Clear VDES overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>`;
for (const msg of messages) {
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
const label = escapeVdesHtml(msg.callsign || "VDES");
const title = escapeVdesHtml(msg.vessel_name || "Burst");
const detail = [
`${msg.bit_len || 0} bits`,
msg.message_label ? escapeVdesHtml(msg.message_label) : null,
Number.isFinite(msg.source_id) ? `src ${Number(msg.source_id)}` : null,
Number.isFinite(msg.destination_id) ? `dst ${Number(msg.destination_id)}` : null,
Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_id)}` : null,
Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : null,
Number.isFinite(msg.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null,
Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null,
msg.destination ? escapeVdesHtml(msg.destination) : null,
escapeVdesHtml(vdesAgeText(msg._tsMs))
].filter(Boolean).join(" · ");
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="vdes-call">${title}</span> <span class="vdes-badge">${label}</span>: ${detail}</div></div>`;
}
vdesBarOverlay.innerHTML = html;
vdesBarOverlay.style.display = "flex";
} }
normalized.reverse(); vdesWindow.updateVdesBar = updateVdesBar;
vdesMessageHistory = normalized.concat(vdesMessageHistory); vdesWindow.clearVdesBar = function() {
pruneVdesMessageHistory(); vdesWindow.resetVdesHistoryView?.();
scheduleVdesBarUpdate(); };
scheduleVdesHistoryRender(); vdesWindow.resetVdesHistoryView = function() {
}; if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
window.restoreVdesHistory = function(messages) { vdesMessageHistory = [];
window.onServerVdesBatch(messages); updateVdesBar();
};
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_vdes_decode");
window.resetVdesHistoryView();
} catch (e) {
console.error("VDES history clear failed", e);
}
});
if (vdesFilterInput) {
vdesFilterInput.addEventListener("input", () => {
vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
renderVdesHistory(); renderVdesHistory();
}); };
} function renderVdesHistory() {
window.onServerVdes = function(msg) { pruneVdesMessageHistory();
if (vdesStatus) vdesStatus.textContent = "Receiving"; if (!vdesMessagesEl) {
const next = normalizeServerVdesMessage(msg); updateVdesSummary();
addVdesMessage(next); return;
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) { }
window.vdesMapAddPoint(next); const fragment = document.createDocumentFragment();
for (const message of vdesMessageHistory) {
fragment.appendChild(renderVdesRow(message));
}
vdesMessagesEl.replaceChildren(fragment);
updateVdesSummary();
} }
}; function addVdesMessage(msg) {
window.pruneVdesHistoryView = function() { const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
pruneVdesMessageHistory(); msg._tsMs = tsMs;
updateVdesBar(); msg._ts = new Date(tsMs).toLocaleTimeString([], {
renderVdesHistory(); hour: "2-digit",
}; minute: "2-digit",
updateVdesSummary(); second: "2-digit"
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("vdes"); });
vdesMessageHistory.unshift(msg);
pruneVdesMessageHistory();
scheduleVdesBarUpdate();
scheduleVdesHistoryRender();
}
function normalizeServerVdesMessage(msg) {
return {
...msg,
rig_id: msg.rig_id || null
};
}
vdesWindow.onServerVdesBatch = function(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerVdesMessage(msg);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next);
}
normalized.push(next);
}
normalized.reverse();
vdesMessageHistory = normalized.concat(vdesMessageHistory);
pruneVdesMessageHistory();
scheduleVdesBarUpdate();
scheduleVdesHistoryRender();
};
vdesWindow.restoreVdesHistory = function(messages) {
vdesWindow.onServerVdesBatch?.(messages);
};
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => {
void (async () => {
if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await vdesWindow.postPath?.("/clear_vdes_decode");
vdesWindow.resetVdesHistoryView?.();
} catch (e) {
console.error("VDES history clear failed", e);
}
})();
});
if (vdesFilterInput) {
vdesFilterInput.addEventListener("input", () => {
vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
renderVdesHistory();
});
}
vdesWindow.onServerVdes = function(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
addVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next);
}
};
vdesWindow.pruneVdesHistoryView = function() {
pruneVdesMessageHistory();
updateVdesBar();
renderVdesHistory();
};
updateVdesSummary();
vdesWindow._trxDrainPendingDecode?.("vdes");
})();
@@ -31,7 +31,6 @@ await build({
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.js"), "sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.js"),
scheduler: path.join(sourceDir, "plugins", "scheduler.js"), scheduler: path.join(sourceDir, "plugins", "scheduler.js"),
vchan: path.join(sourceDir, "plugins", "vchan.js"), vchan: path.join(sourceDir, "plugins", "vchan.js"),
vdes: path.join(sourceDir, "plugins", "vdes.js"),
wefax: path.join(sourceDir, "plugins", "wefax.js"), wefax: path.join(sourceDir, "plugins", "wefax.js"),
}, },
outdir: outputDir, outdir: outputDir,
@@ -51,6 +50,7 @@ await build({
cw: path.join(sourceDir, "plugins", "cw.ts"), cw: path.join(sourceDir, "plugins", "cw.ts"),
ft8: path.join(sourceDir, "plugins", "ft8.ts"), ft8: path.join(sourceDir, "plugins", "ft8.ts"),
"leaflet-ais-tracksymbol": path.join(sourceDir, "leaflet-ais-tracksymbol.ts"), "leaflet-ais-tracksymbol": path.join(sourceDir, "leaflet-ais-tracksymbol.ts"),
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
}, },
outdir: outputDir, outdir: outputDir,
bundle: true, bundle: true,
@@ -16,7 +16,7 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
const loaded = new Set<string>(); const loaded = new Set<string>();
const loading = new Map<string, Promise<void>>(); const loading = new Map<string, Promise<void>>();
const modulePlugins = new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js"]); const modulePlugins = new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js"]);
function loadLegacyScript(path: string): Promise<void> { function loadLegacyScript(path: string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -2,54 +2,107 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
export {};
interface VdesMessage {
rig_id?: string | null;
message_type?: number;
bit_len?: number;
raw_bytes?: number[];
lat?: number | null;
lon?: number | null;
vessel_name?: string | null;
callsign?: string | null;
destination?: string | null;
message_label?: string | null;
session_id?: number | null;
source_id?: number | null;
destination_id?: number | null;
data_count?: number | null;
asm_identifier?: number | null;
ack_nack_mask?: number | null;
channel_quality?: number | null;
payload_preview?: string | null;
link_id?: number | null;
sync_score?: number | null;
sync_errors?: number | null;
phase_rotation?: number | null;
fec_state?: string | null;
ts_ms?: number | null;
_tsMs?: number;
_ts?: string;
}
interface VdesBridge {
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
escapeMapHtml?: (input: string) => string;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
vdesMapAddPoint?: (message: VdesMessage) => void;
updateVdesBar?: () => void;
clearVdesBar?: () => void;
resetVdesHistoryView?: () => void;
onServerVdesBatch?: (messages: VdesMessage[]) => void;
restoreVdesHistory?: (messages: VdesMessage[]) => void;
onServerVdes?: (message: VdesMessage) => void;
pruneVdesHistoryView?: () => void;
_trxDrainPendingDecode?: (decoder: string) => void;
}
const vdesWindow = window as unknown as VdesBridge;
const escapeVdesHtml = (input: string): string => vdesWindow.escapeMapHtml?.(input) ?? input
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
// --- VDES Decoder Plugin (server-side decode) --- // --- VDES Decoder Plugin (server-side decode) ---
const vdesStatus = document.getElementById("vdes-status"); const vdesStatus = document.getElementById("vdes-status");
const vdesMessagesEl = document.getElementById("vdes-messages"); const vdesMessagesEl = document.getElementById("vdes-messages");
const vdesFilterInput = document.getElementById("vdes-filter"); const vdesFilterInput = document.getElementById("vdes-filter") as HTMLInputElement | null;
const vdesBarOverlay = document.getElementById("vdes-bar-overlay"); const vdesBarOverlay = document.getElementById("vdes-bar-overlay");
const vdesChannelSummaryEl = document.getElementById("vdes-channel-summary"); const vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
const vdesFrameCountEl = document.getElementById("vdes-frame-count"); const vdesFrameCountEl = document.getElementById("vdes-frame-count");
const vdesLatestSeenEl = document.getElementById("vdes-latest-seen"); const vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
const VDES_BAR_WINDOW_MS = 15 * 60 * 1000; const VDES_BAR_WINDOW_MS = 15 * 60 * 1000;
let vdesFilterText = ""; let vdesFilterText = "";
let vdesMessageHistory = []; let vdesMessageHistory: VdesMessage[] = [];
function currentVdesHistoryRetentionMs() { function currentVdesHistoryRetentionMs(): number {
return typeof window.getDecodeHistoryRetentionMs === "function" return typeof vdesWindow.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs() ? vdesWindow.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000; : 24 * 60 * 60 * 1000;
} }
function pruneVdesMessageHistory() { function pruneVdesMessageHistory() {
const cutoffMs = Date.now() - currentVdesHistoryRetentionMs(); const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
vdesMessageHistory = vdesMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs); vdesMessageHistory = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
} }
function scheduleVdesUi(key, job) { function scheduleVdesUi(key: string, job: () => void): void {
if (typeof window.trxScheduleUiFrameJob === "function") { if (typeof vdesWindow.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job); vdesWindow.trxScheduleUiFrameJob(key, job);
return; return;
} }
job(); job();
} }
function scheduleVdesHistoryRender() { function scheduleVdesHistoryRender() {
scheduleVdesUi("vdes-history", () => renderVdesHistory()); scheduleVdesUi("vdes-history", () => { renderVdesHistory(); });
} }
function scheduleVdesBarUpdate() { function scheduleVdesBarUpdate() {
scheduleVdesUi("vdes-bar", () => updateVdesBar()); scheduleVdesUi("vdes-bar", () => { updateVdesBar(); });
} }
function currentVdesCenterText() { function currentVdesCenterText() {
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, ""); const raw = ((document.getElementById("freq") as HTMLInputElement | null)?.value || "").replace(/[^\d]/g, "");
const hz = raw ? Number(raw) : 0; const hz = raw ? Number(raw) : 0;
if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency"; if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency";
return `100 kHz @ ${(hz / 1_000_000).toFixed(3)} MHz`; return `100 kHz @ ${(hz / 1_000_000).toFixed(3)} MHz`;
} }
function vdesAgeText(tsMs) { function vdesAgeText(tsMs: number | undefined): string {
if (!Number.isFinite(tsMs)) return "just now"; if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
const deltaMs = Math.max(0, Date.now() - tsMs); const deltaMs = Math.max(0, Date.now() - tsMs);
const seconds = Math.round(deltaMs / 1000); const seconds = Math.round(deltaMs / 1000);
if (seconds < 5) return "just now"; if (seconds < 5) return "just now";
@@ -60,11 +113,11 @@ function vdesAgeText(tsMs) {
return `${hours}h ago`; return `${hours}h ago`;
} }
function vdesHexPreview(rawBytes) { function vdesHexPreview(rawBytes: number[] | undefined): string {
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--"; if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
return rawBytes return rawBytes
.slice(0, 20) .slice(0, 20)
.map((value) => Number(value).toString(16).padStart(2, "0")) .map((value) => value.toString(16).padStart(2, "0"))
.join(" ") .join(" ")
.toUpperCase(); .toUpperCase();
} }
@@ -84,7 +137,7 @@ function updateVdesSummary() {
} }
} }
function applyVdesFilterToRow(row) { function applyVdesFilterToRow(row: HTMLElement): void {
if (!vdesFilterText) { if (!vdesFilterText) {
row.style.display = ""; row.style.display = "";
return; return;
@@ -93,12 +146,7 @@ function applyVdesFilterToRow(row) {
row.style.display = text.includes(vdesFilterText) ? "" : "none"; row.style.display = text.includes(vdesFilterText) ? "" : "none";
} }
function applyVdesFilterToAll() { function renderVdesRow(msg: VdesMessage): HTMLElement {
if (!vdesMessagesEl) return;
vdesMessagesEl.querySelectorAll(".vdes-message").forEach((row) => applyVdesFilterToRow(row));
}
function renderVdesRow(msg) {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "vdes-message"; row.className = "vdes-message";
const ts = msg._ts || new Date().toLocaleTimeString([], { const ts = msg._ts || new Date().toLocaleTimeString([], {
@@ -150,32 +198,32 @@ function renderVdesRow(msg) {
row.innerHTML = row.innerHTML =
`<div class="vdes-row-head">` + `<div class="vdes-row-head">` +
`<span class="vdes-time">${ts}</span>` + `<span class="vdes-time">${ts}</span>` +
`<span class="vdes-call">${escapeMapHtml(title)}</span>` + `<span class="vdes-call">${escapeVdesHtml(title)}</span>` +
`<span class="vdes-badge">${escapeMapHtml(label)}</span>` + `<span class="vdes-badge">${escapeVdesHtml(label)}</span>` +
(labelText ? `<span class="vdes-badge">${escapeMapHtml(labelText)}</span>` : "") + (labelText ? `<span class="vdes-badge">${escapeVdesHtml(labelText)}</span>` : "") +
(linkText ? `<span class="vdes-badge">${escapeMapHtml(linkText)}</span>` : "") + (linkText ? `<span class="vdes-badge">${escapeVdesHtml(linkText)}</span>` : "") +
(srcText ? `<span class="vdes-badge">${escapeMapHtml(srcText)}</span>` : "") + (srcText ? `<span class="vdes-badge">${escapeVdesHtml(srcText)}</span>` : "") +
(dstText ? `<span class="vdes-badge">${escapeMapHtml(dstText)}</span>` : "") + (dstText ? `<span class="vdes-badge">${escapeVdesHtml(dstText)}</span>` : "") +
(syncText ? `<span class="vdes-badge">${escapeMapHtml(syncText)}</span>` : "") + (syncText ? `<span class="vdes-badge">${escapeVdesHtml(syncText)}</span>` : "") +
(phaseText ? `<span class="vdes-badge">${escapeMapHtml(phaseText)}</span>` : "") + (phaseText ? `<span class="vdes-badge">${escapeVdesHtml(phaseText)}</span>` : "") +
`<span class="vdes-badge">T${escapeMapHtml(String(msg.message_type ?? "--"))}</span>` + `<span class="vdes-badge">T${escapeVdesHtml(String(msg.message_type ?? "--"))}</span>` +
`</div>` + `</div>` +
`<div class="vdes-row-meta">` + `<div class="vdes-row-meta">` +
`<span>${escapeMapHtml(currentVdesCenterText())}</span>` + `<span>${escapeVdesHtml(currentVdesCenterText())}</span>` +
`<span>${escapeMapHtml(`${msg.bit_len || 0} bits`)}</span>` + `<span>${escapeVdesHtml(`${msg.bit_len || 0} bits`)}</span>` +
(sessionText ? `<span>${escapeMapHtml(sessionText)}</span>` : "") + (sessionText ? `<span>${escapeVdesHtml(sessionText)}</span>` : "") +
(asmText ? `<span>${escapeMapHtml(asmText)}</span>` : "") + (asmText ? `<span>${escapeVdesHtml(asmText)}</span>` : "") +
(countText ? `<span>${escapeMapHtml(countText)}</span>` : "") + (countText ? `<span>${escapeVdesHtml(countText)}</span>` : "") +
(ackText ? `<span>${escapeMapHtml(ackText)}</span>` : "") + (ackText ? `<span>${escapeVdesHtml(ackText)}</span>` : "") +
(cqiText ? `<span>${escapeMapHtml(cqiText)}</span>` : "") + (cqiText ? `<span>${escapeVdesHtml(cqiText)}</span>` : "") +
(info ? `<span>${escapeMapHtml(info)}</span>` : "") + (info ? `<span>${escapeVdesHtml(info)}</span>` : "") +
(fecText ? `<span>${escapeMapHtml(fecText)}</span>` : "") + (fecText ? `<span>${escapeVdesHtml(fecText)}</span>` : "") +
`<span>${escapeMapHtml(vdesAgeText(msg._tsMs))}</span>` + `<span>${escapeVdesHtml(vdesAgeText(msg._tsMs))}</span>` +
`</div>` + `</div>` +
`<div class="vdes-row-detail">` + `<div class="vdes-row-detail">` +
(previewText ? `<span>${escapeMapHtml(previewText)}</span>` : "") + (previewText ? `<span>${escapeVdesHtml(previewText)}</span>` : "") +
(previewText ? `<span>·</span>` : "") + (previewText ? `<span>·</span>` : "") +
`<span class="vdes-raw">${escapeMapHtml(rawHex)}</span>` + `<span class="vdes-raw">${escapeVdesHtml(rawHex)}</span>` +
`</div>`; `</div>`;
applyVdesFilterToRow(row); applyVdesFilterToRow(row);
return row; return row;
@@ -184,9 +232,9 @@ function renderVdesRow(msg) {
function updateVdesBar() { function updateVdesBar() {
if (!vdesBarOverlay) return; if (!vdesBarOverlay) return;
updateVdesSummary(); updateVdesSummary();
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES"; const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES";
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS; const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
const messages = vdesMessageHistory.filter((msg) => msg._tsMs >= cutoffMs).slice(0, 6); const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
if (!isVdes || messages.length === 0) { if (!isVdes || messages.length === 0) {
vdesBarOverlay.style.display = "none"; vdesBarOverlay.style.display = "none";
vdesBarOverlay.innerHTML = ""; vdesBarOverlay.innerHTML = "";
@@ -196,19 +244,19 @@ function updateVdesBar() {
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">VDES</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearVdesBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearVdesBar();}" aria-label="Clear VDES overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>'; let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">VDES</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearVdesBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearVdesBar();}" aria-label="Clear VDES overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>';
for (const msg of messages) { for (const msg of messages) {
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : ""; const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
const label = escapeMapHtml(msg.callsign || "VDES"); const label = escapeVdesHtml(msg.callsign || "VDES");
const title = escapeMapHtml(msg.vessel_name || "Burst"); const title = escapeVdesHtml(msg.vessel_name || "Burst");
const detail = [ const detail = [
`${msg.bit_len || 0} bits`, `${msg.bit_len || 0} bits`,
msg.message_label ? escapeMapHtml(msg.message_label) : null, msg.message_label ? escapeVdesHtml(msg.message_label) : null,
Number.isFinite(msg.source_id) ? `src ${Number(msg.source_id)}` : null, Number.isFinite(msg.source_id) ? `src ${Number(msg.source_id)}` : null,
Number.isFinite(msg.destination_id) ? `dst ${Number(msg.destination_id)}` : null, Number.isFinite(msg.destination_id) ? `dst ${Number(msg.destination_id)}` : null,
Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_id)}` : null, Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_id)}` : null,
Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : null, Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : null,
Number.isFinite(msg.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null, Number.isFinite(msg.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null,
Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null, Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null,
msg.destination ? escapeMapHtml(msg.destination) : null, msg.destination ? escapeVdesHtml(msg.destination) : null,
escapeMapHtml(vdesAgeText(msg._tsMs)), escapeVdesHtml(vdesAgeText(msg._tsMs)),
] ]
.filter(Boolean) .filter(Boolean)
.join(" · "); .join(" · ");
@@ -217,12 +265,12 @@ function updateVdesBar() {
vdesBarOverlay.innerHTML = html; vdesBarOverlay.innerHTML = html;
vdesBarOverlay.style.display = "flex"; vdesBarOverlay.style.display = "flex";
} }
window.updateVdesBar = updateVdesBar; vdesWindow.updateVdesBar = updateVdesBar;
window.clearVdesBar = function() { vdesWindow.clearVdesBar = function() {
window.resetVdesHistoryView(); vdesWindow.resetVdesHistoryView?.();
}; };
window.resetVdesHistoryView = function() { vdesWindow.resetVdesHistoryView = function() {
if (vdesMessagesEl) vdesMessagesEl.innerHTML = ""; if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
vdesMessageHistory = []; vdesMessageHistory = [];
updateVdesBar(); updateVdesBar();
@@ -236,14 +284,14 @@ function renderVdesHistory() {
return; return;
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (let i = 0; i < vdesMessageHistory.length; i += 1) { for (const message of vdesMessageHistory) {
fragment.appendChild(renderVdesRow(vdesMessageHistory[i])); fragment.appendChild(renderVdesRow(message));
} }
vdesMessagesEl.replaceChildren(fragment); vdesMessagesEl.replaceChildren(fragment);
updateVdesSummary(); updateVdesSummary();
} }
function addVdesMessage(msg) { function addVdesMessage(msg: VdesMessage): void {
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now(); const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
msg._tsMs = tsMs; msg._tsMs = tsMs;
msg._ts = new Date(tsMs).toLocaleTimeString([], { msg._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -258,39 +306,17 @@ function addVdesMessage(msg) {
scheduleVdesHistoryRender(); scheduleVdesHistoryRender();
} }
function normalizeServerVdesMessage(msg) { function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage {
return { return {
...msg,
rig_id: msg.rig_id || null, rig_id: msg.rig_id || null,
message_type: msg.message_type,
bit_len: msg.bit_len,
raw_bytes: msg.raw_bytes,
lat: msg.lat,
lon: msg.lon,
vessel_name: msg.vessel_name,
callsign: msg.callsign,
destination: msg.destination,
message_label: msg.message_label,
session_id: msg.session_id,
source_id: msg.source_id,
destination_id: msg.destination_id,
data_count: msg.data_count,
asm_identifier: msg.asm_identifier,
ack_nack_mask: msg.ack_nack_mask,
channel_quality: msg.channel_quality,
payload_preview: msg.payload_preview,
link_id: msg.link_id,
sync_score: msg.sync_score,
sync_errors: msg.sync_errors,
phase_rotation: msg.phase_rotation,
fec_state: msg.fec_state,
ts_ms: msg.ts_ms,
}; };
} }
window.onServerVdesBatch = function(messages) { vdesWindow.onServerVdesBatch = function(messages: VdesMessage[]) {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving"; if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized = []; const normalized: VdesMessage[] = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
@@ -300,8 +326,8 @@ window.onServerVdesBatch = function(messages) {
minute: "2-digit", minute: "2-digit",
second: "2-digit", second: "2-digit",
}); });
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) { if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
window.vdesMapAddPoint(next); vdesWindow.vdesMapAddPoint(next);
} }
normalized.push(next); normalized.push(next);
} }
@@ -312,19 +338,19 @@ window.onServerVdesBatch = function(messages) {
scheduleVdesHistoryRender(); scheduleVdesHistoryRender();
}; };
window.restoreVdesHistory = function(messages) { vdesWindow.restoreVdesHistory = function(messages: VdesMessage[]) {
window.onServerVdesBatch(messages); vdesWindow.onServerVdesBatch?.(messages);
}; };
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => { void (async () => {
if (!await window.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await postPath("/clear_vdes_decode"); await vdesWindow.postPath?.("/clear_vdes_decode");
window.resetVdesHistoryView(); vdesWindow.resetVdesHistoryView?.();
} catch (e) { } catch (e) {
console.error("VDES history clear failed", e); console.error("VDES history clear failed", e);
} }
}); })(); });
if (vdesFilterInput) { if (vdesFilterInput) {
vdesFilterInput.addEventListener("input", () => { vdesFilterInput.addEventListener("input", () => {
@@ -333,20 +359,20 @@ if (vdesFilterInput) {
}); });
} }
window.onServerVdes = function(msg) { vdesWindow.onServerVdes = function(msg: VdesMessage) {
if (vdesStatus) vdesStatus.textContent = "Receiving"; if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
addVdesMessage(next); addVdesMessage(next);
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) { if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
window.vdesMapAddPoint(next); vdesWindow.vdesMapAddPoint(next);
} }
}; };
window.pruneVdesHistoryView = function() { vdesWindow.pruneVdesHistoryView = function() {
pruneVdesMessageHistory(); pruneVdesMessageHistory();
updateVdesBar(); updateVdesBar();
renderVdesHistory(); renderVdesHistory();
}; };
updateVdesSummary(); updateVdesSummary();
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("vdes"); vdesWindow._trxDrainPendingDecode?.("vdes");
@@ -0,0 +1,33 @@
// 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("VDES entry normalizes and forwards positioned server messages", async () => {
const forwarded = [];
const window = {
trxUi: { confirm: async () => true },
vdesMapAddPoint: (message) => { forwarded.push(message); },
};
const context = vm.createContext({
window,
document: { getElementById: () => null },
Date,
Number,
String,
Array,
console,
});
const source = await readFile(new URL("../../assets/web/generated/vdes.js", import.meta.url), "utf8");
new vm.Script(source).runInContext(context);
window.onServerVdes({ callsign: "SP0ABC", lat: 54.5, lon: 18.5, bit_len: 120, ts_ms: Date.now() });
assert.equal(forwarded.length, 1);
assert.equal(forwarded[0].callsign, "SP0ABC");
assert.equal(forwarded[0].rig_id, null);
assert.equal(typeof forwarded[0]._tsMs, "number");
});