CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 8m34s
CI / test (push) Successful in 7m47s
CI / frontend (pull_request) Successful in 4m28s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m21s
CI / frontend (push) Successful in 3m37s
CI / reuse (push) Successful in 6s
A client connected to several rigs decodes all of them at once, and the decode stream carries every rig's traffic to every browser. The decoder panels listed all of it: a station a background rig copied on another band appeared in the APRS list next to the selected rig's, the vessel counts and the "latest seen" lines counted both, the status lines said "Receiving" because some other rig was, and the CW pane interleaved two rigs into one stream of text that read as neither. The page is about the rig the operator selected — the one whose spectrum is on screen and whose audio is playing — so each panel now shows what that rig heard: rows, counts, latest-seen, status, the live picture a WEFAX or SSTV frame is painting, and the CW pane. Nothing is dropped on the way in. The map is the whole station's view, has its own rig filter, and would empty out if the plugins stopped feeding it, so the histories still hold every rig and the map still plots them. That also means a switch loses nothing: the runtime gained a rerender hook, which the rig switch calls, and switching back brings the other rig's traffic up again. The CW pane is the exception — a running stream of text cannot be unpicked after the fact — so it starts empty on the rig switched to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams <sjg@haxx.space>
289 lines
12 KiB
JavaScript
289 lines
12 KiB
JavaScript
import {
|
|
forActiveRig,
|
|
isActiveRigDecode
|
|
} from "./chunk-S57W63QN.js";
|
|
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 vdesRigMessages() {
|
|
return forActiveRig(vdesMessageHistory);
|
|
}
|
|
function updateVdesSummary() {
|
|
pruneVdesMessageHistory();
|
|
if (vdesChannelSummaryEl) {
|
|
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
|
}
|
|
const rigMessages = vdesRigMessages();
|
|
if (vdesFrameCountEl) {
|
|
const count = rigMessages.length;
|
|
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
|
}
|
|
if (vdesLatestSeenEl) {
|
|
const latest = rigMessages[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 && isActiveRigDecode(msg.rig_id)).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 vdesRigMessages()) {
|
|
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;
|
|
const normalized = [];
|
|
for (const msg of messages) {
|
|
const next = normalizeServerVdesMessage(msg);
|
|
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
|
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) {
|
|
const next = normalizeServerVdesMessage(msg);
|
|
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
|
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,
|
|
rerender: () => {
|
|
updateVdesBar();
|
|
renderVdesHistory();
|
|
},
|
|
// Oldest first, so tracks are rebuilt in the order they happened.
|
|
syncMap: () => {
|
|
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
|
|
}
|
|
});
|