refactor: convert VDES plugin to TypeScript
This commit is contained in:
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
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) => {
|
||||||
|
|||||||
+124
-98
@@ -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("&", "&").replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
|
||||||
// --- 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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user