The map module is lazy: it arrives when the Map tab is first opened, which is normally long after startup restored the decode history. Until then aprsMapAddStation, aisMapAddVessel and vdesMapAddPoint are undefined, and the decoders' `if (lat != null && ... && fn)` guards quietly dropped every restored position. Nothing replayed them once the module did arrive, so the map came up empty and filled in only from decodes heard afterwards -- a station heard once was never plotted at all. A second reload appeared to fix it because the cached module then loaded early enough to win the race against the history fetch. Give DecoderPlugin an optional syncMap(), implement it for APRS, AIS and VDES over the history each already retains, and have map-core call trxPluginRuntime.syncMapAll() as it attaches. The add functions are keyed by callsign, MMSI and point, so replaying updates in place and cannot duplicate a marker; the replay runs oldest-first so tracks are rebuilt in the order they happened. Signed-off-by: Stan Grams <sjg@haxx.space>
277 lines
12 KiB
JavaScript
277 lines
12 KiB
JavaScript
import {
|
|
hostCore
|
|
} from "./chunk-KL66PICH.js";
|
|
|
|
// src/plugins/vdes.ts
|
|
var vdesWindow = window;
|
|
var escapeVdesHtml = (input) => hostCore.escapeMapHtml(input);
|
|
var vdesStatus = document.getElementById("vdes-status");
|
|
var vdesMessagesEl = document.getElementById("vdes-messages");
|
|
var vdesFilterInput = document.getElementById("vdes-filter");
|
|
var vdesBarOverlay = document.getElementById("vdes-bar-overlay");
|
|
var vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
|
|
var vdesFrameCountEl = document.getElementById("vdes-frame-count");
|
|
var vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
|
|
var VDES_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
|
var vdesFilterText = "";
|
|
var vdesMessageHistory = [];
|
|
function currentVdesHistoryRetentionMs() {
|
|
return typeof vdesWindow.getDecodeHistoryRetentionMs === "function" ? vdesWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
|
}
|
|
function pruneVdesMessageHistory() {
|
|
const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
|
|
vdesMessageHistory = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
|
}
|
|
function scheduleVdesUi(key, job) {
|
|
if (typeof vdesWindow.trxScheduleUiFrameJob === "function") {
|
|
vdesWindow.trxScheduleUiFrameJob(key, job);
|
|
return;
|
|
}
|
|
job();
|
|
}
|
|
function scheduleVdesHistoryRender() {
|
|
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 (typeof tsMs !== "number" || !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) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
|
|
}
|
|
function updateVdesSummary() {
|
|
pruneVdesMessageHistory();
|
|
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";
|
|
}
|
|
}
|
|
function applyVdesFilterToRow(row) {
|
|
if (!vdesFilterText) {
|
|
row.style.display = "";
|
|
return;
|
|
}
|
|
const text = row.dataset.filterText || "";
|
|
row.style.display = text.includes(vdesFilterText) ? "" : "none";
|
|
}
|
|
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">${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;
|
|
}
|
|
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";
|
|
}
|
|
vdesWindow.updateVdesBar = updateVdesBar;
|
|
vdesWindow.clearVdesBar = function() {
|
|
resetVdesHistoryView();
|
|
};
|
|
function resetVdesHistoryView() {
|
|
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
|
|
vdesMessageHistory = [];
|
|
updateVdesBar();
|
|
renderVdesHistory();
|
|
}
|
|
function renderVdesHistory() {
|
|
pruneVdesMessageHistory();
|
|
if (!vdesMessagesEl) {
|
|
updateVdesSummary();
|
|
return;
|
|
}
|
|
const fragment = document.createDocumentFragment();
|
|
for (const message of vdesMessageHistory) {
|
|
fragment.appendChild(renderVdesRow(message));
|
|
}
|
|
vdesMessagesEl.replaceChildren(fragment);
|
|
updateVdesSummary();
|
|
}
|
|
function addVdesMessage(msg) {
|
|
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
|
msg._tsMs = tsMs;
|
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit"
|
|
});
|
|
vdesMessageHistory.unshift(msg);
|
|
pruneVdesMessageHistory();
|
|
scheduleVdesBarUpdate();
|
|
scheduleVdesHistoryRender();
|
|
}
|
|
function normalizeServerVdesMessage(msg) {
|
|
return {
|
|
...msg,
|
|
rig_id: msg.rig_id || null
|
|
};
|
|
}
|
|
function onServerVdesBatch(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"
|
|
});
|
|
plotVdesMessage(next);
|
|
normalized.push(next);
|
|
}
|
|
normalized.reverse();
|
|
vdesMessageHistory = normalized.concat(vdesMessageHistory);
|
|
pruneVdesMessageHistory();
|
|
scheduleVdesBarUpdate();
|
|
scheduleVdesHistoryRender();
|
|
}
|
|
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 hostCore.postPath("/clear_vdes_decode");
|
|
resetVdesHistoryView();
|
|
} catch (e) {
|
|
console.error("VDES history clear failed", e);
|
|
}
|
|
})();
|
|
});
|
|
if (vdesFilterInput) {
|
|
vdesFilterInput.addEventListener("input", () => {
|
|
vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
|
|
renderVdesHistory();
|
|
});
|
|
}
|
|
function plotVdesMessage(msg) {
|
|
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
|
|
vdesWindow.vdesMapAddPoint(msg);
|
|
}
|
|
function onServerVdes(msg) {
|
|
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
|
const next = normalizeServerVdesMessage(msg);
|
|
addVdesMessage(next);
|
|
plotVdesMessage(next);
|
|
}
|
|
function pruneVdesHistoryView() {
|
|
pruneVdesMessageHistory();
|
|
updateVdesBar();
|
|
renderVdesHistory();
|
|
}
|
|
updateVdesSummary();
|
|
window.trxPluginRuntime.registerDecoder({
|
|
id: "vdes",
|
|
onMessage: onServerVdes,
|
|
onBatch: onServerVdesBatch,
|
|
restore: onServerVdesBatch,
|
|
reset: resetVdesHistoryView,
|
|
prune: pruneVdesHistoryView,
|
|
// Oldest first, so tracks are rebuilt in the order they happened.
|
|
syncMap: () => {
|
|
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
|
|
}
|
|
});
|