[feat](trx-rs): split VDES frontend and decoder path
Add a dedicated VDES plugin tab and live bar, stop reusing the AIS vessel UI, and serve a separate VDES frontend script. Rework the SDR backend so VDES receives a single 100 kHz IQ tap, then replace the fake AIS-clone decoder path with an early M.2092-1 oriented complex-baseband scaffold using burst detection, coarse pi/4-QPSK slicing, and TER-MCS-1.100 frame heuristics. Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -219,6 +219,7 @@ function applyAuthRestrictions() {
|
||||
// Note: sig-clear-btn is allowed for RX (clears local measurements only)
|
||||
const pluginToggleBtns = [
|
||||
"ais-clear-btn",
|
||||
"vdes-clear-btn",
|
||||
"ft8-decode-toggle-btn",
|
||||
"wspr-decode-toggle-btn",
|
||||
"cw-auto",
|
||||
@@ -1208,8 +1209,11 @@ function coverageGuardBandwidthHz(mode = modeEl ? modeEl.value : "") {
|
||||
}
|
||||
|
||||
function isAisMode(mode = modeEl ? modeEl.value : "") {
|
||||
const upper = String(mode || "").toUpperCase();
|
||||
return upper === "AIS" || upper === "VDES";
|
||||
return String(mode || "").toUpperCase() === "AIS";
|
||||
}
|
||||
|
||||
function isVdesMode(mode = modeEl ? modeEl.value : "") {
|
||||
return String(mode || "").toUpperCase() === "VDES";
|
||||
}
|
||||
|
||||
function coverageSpanForMode(freqHz, bandwidthHz = coverageGuardBandwidthHz(), mode = modeEl ? modeEl.value : "") {
|
||||
@@ -2032,17 +2036,25 @@ function render(update) {
|
||||
}
|
||||
const modeUpper = update.status && update.status.mode ? normalizeMode(update.status.mode).toUpperCase() : "";
|
||||
const aisStatus = document.getElementById("ais-status");
|
||||
const vdesStatus = document.getElementById("vdes-status");
|
||||
const aprsStatus = document.getElementById("aprs-status");
|
||||
const cwStatus = document.getElementById("cw-status");
|
||||
const ft8Status = document.getElementById("ft8-status");
|
||||
const wsprStatus = document.getElementById("wspr-status");
|
||||
setModeBoundDecodeStatus(
|
||||
aisStatus,
|
||||
["AIS", "VDES"],
|
||||
"Select AIS or VDES mode to decode",
|
||||
["AIS"],
|
||||
"Select AIS mode to decode",
|
||||
"Connected, listening for packets",
|
||||
);
|
||||
if (window.updateAisBar) window.updateAisBar();
|
||||
setModeBoundDecodeStatus(
|
||||
vdesStatus,
|
||||
["VDES"],
|
||||
"Select VDES mode to decode",
|
||||
"Connected, listening for bursts",
|
||||
);
|
||||
if (window.updateVdesBar) window.updateVdesBar();
|
||||
setModeBoundDecodeStatus(
|
||||
aprsStatus,
|
||||
["PKT"],
|
||||
@@ -2734,7 +2746,7 @@ const MODE_BW_DEFAULTS = {
|
||||
AM: [9_000, 500, 20_000, 500],
|
||||
FM: [12_500, 2_500, 25_000, 500],
|
||||
AIS: [25_000, 12_500, 50_000, 500],
|
||||
VDES: [25_000, 12_500, 50_000, 500],
|
||||
VDES: [100_000, 25_000, 200_000, 1_000],
|
||||
WFM: [180_000, 50_000,300_000,5_000],
|
||||
DIG: [3_000, 300, 6_000, 100],
|
||||
PKT: [25_000, 300, 50_000, 500],
|
||||
@@ -4328,10 +4340,13 @@ function setModeBoundDecodeStatus(el, activeModes, inactiveText, connectedText)
|
||||
}
|
||||
function updateDecodeStatus(text) {
|
||||
const ais = document.getElementById("ais-status");
|
||||
const vdes = document.getElementById("vdes-status");
|
||||
const aprs = document.getElementById("aprs-status");
|
||||
const cw = document.getElementById("cw-status");
|
||||
const ft8 = document.getElementById("ft8-status");
|
||||
setModeBoundDecodeStatus(ais, ["AIS", "VDES"], "Select AIS or VDES mode to decode", text);
|
||||
setModeBoundDecodeStatus(ais, ["AIS"], "Select AIS mode to decode", text);
|
||||
const vdesText = text === "Connected, listening for packets" ? "Connected, listening for bursts" : text;
|
||||
setModeBoundDecodeStatus(vdes, ["VDES"], "Select VDES mode to decode", vdesText);
|
||||
setModeBoundDecodeStatus(aprs, ["PKT"], "Select PKT mode to decode", text);
|
||||
if (cw && cw.textContent !== "Receiving") cw.textContent = text;
|
||||
if (ft8 && ft8.textContent !== "Receiving") ft8.textContent = text;
|
||||
@@ -4339,6 +4354,7 @@ function updateDecodeStatus(text) {
|
||||
function connectDecode() {
|
||||
if (decodeSource) { decodeSource.close(); }
|
||||
if (window.resetAisHistoryView) window.resetAisHistoryView();
|
||||
if (window.resetVdesHistoryView) window.resetVdesHistoryView();
|
||||
if (window.resetAprsHistoryView) window.resetAprsHistoryView();
|
||||
if (window.resetCwHistoryView) window.resetCwHistoryView();
|
||||
if (window.resetFt8HistoryView) window.resetFt8HistoryView();
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
<canvas id="overview-canvas" aria-hidden="true"></canvas>
|
||||
<div id="rds-ps-overlay" aria-live="polite" aria-label="RDS station name"></div>
|
||||
<div id="ais-bar-overlay" aria-live="polite" aria-label="Recent AIS messages"></div>
|
||||
<div id="vdes-bar-overlay" aria-live="polite" aria-label="Recent VDES bursts"></div>
|
||||
<div id="aprs-bar-overlay" aria-live="polite" aria-label="Recent APRS frames"></div>
|
||||
</div>
|
||||
<div id="spectrum-bookmark-axis"></div>
|
||||
@@ -350,7 +351,8 @@
|
||||
<div id="tab-plugins" class="tab-panel" style="display:none;">
|
||||
<div class="sub-tab-bar">
|
||||
<button class="sub-tab active" data-subtab="overview">Overview</button>
|
||||
<button class="sub-tab" data-subtab="ais">AIS/VDES</button>
|
||||
<button class="sub-tab" data-subtab="ais">AIS</button>
|
||||
<button class="sub-tab" data-subtab="vdes">VDES</button>
|
||||
<button class="sub-tab" data-subtab="aprs">APRS</button>
|
||||
<button class="sub-tab" data-subtab="cw">CW</button>
|
||||
<button class="sub-tab" data-subtab="ft8">FT8</button>
|
||||
@@ -359,9 +361,15 @@
|
||||
</div>
|
||||
<div id="subtab-overview" class="sub-tab-panel">
|
||||
<div class="plugin-item">
|
||||
<strong>AIS / VDES Decoder</strong>
|
||||
<strong>AIS Decoder</strong>
|
||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||
Decodes dual-channel AIS and VDES traffic from RX audio using 9.6 kbit/s GMSK and HDLC.
|
||||
Decodes dual-channel AIS traffic from RX audio using 9.6 kbit/s GMSK and HDLC.
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-item">
|
||||
<strong>VDES Decoder</strong>
|
||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||
Decodes single-channel 100 kHz VDES bursts from SDR IQ using the dedicated VDES path.
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-item">
|
||||
@@ -442,6 +450,28 @@
|
||||
</div>
|
||||
<div id="ais-messages"></div>
|
||||
</div>
|
||||
<div id="subtab-vdes" class="sub-tab-panel" style="display:none;">
|
||||
<div class="aprs-controls">
|
||||
<button id="vdes-clear-btn" type="button">Clear</button>
|
||||
<input id="vdes-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. frame, RMS, payload)" />
|
||||
<small id="vdes-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||
</div>
|
||||
<div class="ais-summary">
|
||||
<div class="ais-summary-card">
|
||||
<span class="ais-summary-label">Channel</span>
|
||||
<span id="vdes-channel-summary" class="ais-summary-value">100 kHz centered on tuned frequency</span>
|
||||
</div>
|
||||
<div class="ais-summary-card">
|
||||
<span class="ais-summary-label">Frames</span>
|
||||
<span id="vdes-frame-count" class="ais-summary-value">0 bursts</span>
|
||||
</div>
|
||||
<div class="ais-summary-card">
|
||||
<span class="ais-summary-label">Latest</span>
|
||||
<span id="vdes-latest-seen" class="ais-summary-value">No traffic yet</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="vdes-messages"></div>
|
||||
</div>
|
||||
<div id="subtab-aprs" class="sub-tab-panel" style="display:none;">
|
||||
<div class="aprs-controls">
|
||||
<button id="aprs-pause-btn" type="button">Pause</button>
|
||||
@@ -528,7 +558,7 @@
|
||||
</div>
|
||||
<div id="tab-map" class="tab-panel" style="display:none;">
|
||||
<div class="map-controls">
|
||||
<label><input type="checkbox" id="map-filter-ais" checked /> AIS/VDES</label>
|
||||
<label><input type="checkbox" id="map-filter-ais" checked /> AIS</label>
|
||||
<label><input type="checkbox" id="map-filter-aprs" checked /> APRS</label>
|
||||
<label><input type="checkbox" id="map-filter-ft8" checked /> FT8</label>
|
||||
<label><input type="checkbox" id="map-filter-wspr" checked /> WSPR</label>
|
||||
@@ -565,6 +595,7 @@
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
<script src="/ais.js"></script>
|
||||
<script src="/vdes.js"></script>
|
||||
<script src="/aprs.js"></script>
|
||||
<script src="/ft8.js"></script>
|
||||
<script src="/wspr.js"></script>
|
||||
|
||||
@@ -14,15 +14,6 @@ const AIS_CHANNEL_SPACING_HZ = 50_000;
|
||||
let aisFilterText = "";
|
||||
let aisMessageHistory = [];
|
||||
|
||||
function isAisLikeMode() {
|
||||
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||
return mode === "AIS" || mode === "VDES";
|
||||
}
|
||||
|
||||
function currentAisLikeModeLabel() {
|
||||
return (document.getElementById("mode")?.value || "").toUpperCase() === "VDES" ? "VDES" : "AIS";
|
||||
}
|
||||
|
||||
function formatAisMhz(freqHz) {
|
||||
return `${(freqHz / 1_000_000).toFixed(3)} MHz`;
|
||||
}
|
||||
@@ -39,17 +30,16 @@ function currentAisChannelPlan() {
|
||||
|
||||
function aisChannelInfo(channel) {
|
||||
const plan = currentAisChannelPlan();
|
||||
const modeLabel = currentAisLikeModeLabel();
|
||||
const ch = String(channel || "").trim().toUpperCase();
|
||||
if (ch === "B") {
|
||||
return {
|
||||
label: `${modeLabel}-B`,
|
||||
label: "AIS-B",
|
||||
badgeClass: "ais-badge ais-badge-channel-b",
|
||||
freqText: formatAisMhz(plan.bHz),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: `${modeLabel}-A`,
|
||||
label: "AIS-A",
|
||||
badgeClass: "ais-badge ais-badge-channel-a",
|
||||
freqText: formatAisMhz(plan.aHz),
|
||||
};
|
||||
@@ -227,8 +217,7 @@ function updateAisBar() {
|
||||
if (!aisBarOverlay) return;
|
||||
updateAisSummary();
|
||||
|
||||
const isAis = isAisLikeMode();
|
||||
const modeLabel = currentAisLikeModeLabel();
|
||||
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||
const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
|
||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||
@@ -238,7 +227,7 @@ function updateAisBar() {
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${modeLabel}</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.clearAisBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearAisBar();}" aria-label="Clear ${modeLabel} 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">AIS</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.clearAisBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAisBar();}" aria-label="Clear AIS 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 pin = msg.lat != null && msg.lon != null
|
||||
@@ -305,10 +294,10 @@ function addAisMessage(msg) {
|
||||
if (aisClearBtn) {
|
||||
aisClearBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await postPath(currentAisLikeModeLabel() === "VDES" ? "/clear_vdes_decode" : "/clear_ais_decode");
|
||||
await postPath("/clear_ais_decode");
|
||||
window.resetAisHistoryView();
|
||||
} catch (e) {
|
||||
console.error("AIS/VDES clear failed", e);
|
||||
console.error("AIS clear failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -338,22 +327,4 @@ window.onServerAis = function(msg) {
|
||||
});
|
||||
};
|
||||
|
||||
window.onServerVdes = function(msg) {
|
||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||
addAisMessage({
|
||||
channel: msg.channel,
|
||||
message_type: msg.message_type,
|
||||
mmsi: msg.mmsi,
|
||||
lat: msg.lat,
|
||||
lon: msg.lon,
|
||||
sog_knots: msg.sog_knots,
|
||||
cog_deg: msg.cog_deg,
|
||||
heading_deg: msg.heading_deg,
|
||||
vessel_name: msg.vessel_name,
|
||||
callsign: msg.callsign,
|
||||
destination: msg.destination,
|
||||
ts_ms: msg.ts_ms,
|
||||
});
|
||||
};
|
||||
|
||||
updateAisSummary();
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// --- VDES Decoder Plugin (server-side decode) ---
|
||||
const vdesStatus = document.getElementById("vdes-status");
|
||||
const vdesMessagesEl = document.getElementById("vdes-messages");
|
||||
const vdesFilterInput = document.getElementById("vdes-filter");
|
||||
const vdesClearBtn = document.getElementById("vdes-clear-btn");
|
||||
const vdesBarOverlay = document.getElementById("vdes-bar-overlay");
|
||||
const vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
|
||||
const vdesFrameCountEl = document.getElementById("vdes-frame-count");
|
||||
const vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
|
||||
const VDES_MAX_MESSAGES = 200;
|
||||
const VDES_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||
let vdesFilterText = "";
|
||||
let vdesMessageHistory = [];
|
||||
|
||||
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 / 1_000_000).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 / 1000);
|
||||
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() {
|
||||
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 applyVdesFilterToAll() {
|
||||
if (!vdesMessagesEl) return;
|
||||
vdesMessagesEl.querySelectorAll(".vdes-message").forEach((row) => applyVdesFilterToRow(row));
|
||||
}
|
||||
|
||||
function renderVdesRow(msg) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "vdes-message";
|
||||
const ts = msg._ts || 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 rawHex = vdesHexPreview(msg.raw_bytes);
|
||||
row.dataset.filterText = [title, label, info, 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>` +
|
||||
`<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>` +
|
||||
(info ? `<span>${escapeMapHtml(info)}</span>` : "") +
|
||||
`<span>${escapeMapHtml(vdesAgeText(msg._tsMs))}</span>` +
|
||||
`</div>` +
|
||||
`<div class="vdes-row-detail">` +
|
||||
`<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>';
|
||||
for (const msg of messages) {
|
||||
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
||||
const label = escapeMapHtml(msg.callsign || "VDES");
|
||||
const title = escapeMapHtml(msg.vessel_name || "Burst");
|
||||
const detail = [
|
||||
`${msg.bit_len || 0} bits`,
|
||||
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;
|
||||
vdesBarOverlay.style.display = "flex";
|
||||
}
|
||||
window.updateVdesBar = updateVdesBar;
|
||||
window.clearVdesBar = function() {
|
||||
document.getElementById("vdes-clear-btn")?.click();
|
||||
};
|
||||
|
||||
window.resetVdesHistoryView = function() {
|
||||
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
|
||||
vdesMessageHistory = [];
|
||||
updateVdesBar();
|
||||
};
|
||||
|
||||
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);
|
||||
if (vdesMessageHistory.length > VDES_MAX_MESSAGES) vdesMessageHistory.length = VDES_MAX_MESSAGES;
|
||||
updateVdesBar();
|
||||
|
||||
if (vdesMessagesEl) {
|
||||
const row = renderVdesRow(msg);
|
||||
vdesMessagesEl.prepend(row);
|
||||
while (vdesMessagesEl.children.length > VDES_MAX_MESSAGES) {
|
||||
vdesMessagesEl.removeChild(vdesMessagesEl.lastChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (vdesClearBtn) {
|
||||
vdesClearBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await postPath("/clear_vdes_decode");
|
||||
window.resetVdesHistoryView();
|
||||
} catch (e) {
|
||||
console.error("VDES clear failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (vdesFilterInput) {
|
||||
vdesFilterInput.addEventListener("input", () => {
|
||||
vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
|
||||
applyVdesFilterToAll();
|
||||
});
|
||||
}
|
||||
|
||||
window.onServerVdes = function(msg) {
|
||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||
addVdesMessage({
|
||||
message_type: msg.message_type,
|
||||
bit_len: msg.bit_len,
|
||||
raw_bytes: msg.raw_bytes,
|
||||
vessel_name: msg.vessel_name,
|
||||
callsign: msg.callsign,
|
||||
destination: msg.destination,
|
||||
ts_ms: msg.ts_ms,
|
||||
});
|
||||
};
|
||||
|
||||
updateVdesSummary();
|
||||
@@ -630,7 +630,8 @@ small { color: var(--text-muted); }
|
||||
background: color-mix(in srgb, var(--card-bg) 62%, transparent);
|
||||
}
|
||||
#aprs-bar-overlay,
|
||||
#ais-bar-overlay {
|
||||
#ais-bar-overlay,
|
||||
#vdes-bar-overlay {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -1205,12 +1206,17 @@ small { color: var(--text-muted); }
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#subtab-vdes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#subtab-aprs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#aprs-packets,
|
||||
#ais-messages { max-height: 360px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
#ais-messages,
|
||||
#vdes-messages { max-height: 360px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
#aprs-packets {
|
||||
flex: 0 1 auto;
|
||||
height: calc(100vh - 28rem);
|
||||
@@ -1223,6 +1229,12 @@ small { color: var(--text-muted); }
|
||||
min-height: 16rem;
|
||||
max-height: calc(100vh - 24rem);
|
||||
}
|
||||
#vdes-messages {
|
||||
flex: 0 1 auto;
|
||||
height: calc(100vh - 24rem);
|
||||
min-height: 16rem;
|
||||
max-height: calc(100vh - 24rem);
|
||||
}
|
||||
.aprs-packet { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.45rem 0.55rem; border-bottom: 1px solid var(--border); line-height: 1.35; }
|
||||
.aprs-packet:last-child { border-bottom: none; }
|
||||
.aprs-packet-new {
|
||||
@@ -1347,10 +1359,14 @@ small { color: var(--text-muted); }
|
||||
}
|
||||
.ais-message { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.45rem 0.55rem; border-bottom: 1px solid var(--border); line-height: 1.35; }
|
||||
.ais-message:last-child { border-bottom: none; }
|
||||
.vdes-message { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.45rem 0.55rem; border-bottom: 1px solid var(--border); line-height: 1.35; }
|
||||
.vdes-message:last-child { border-bottom: none; }
|
||||
.aprs-call { color: var(--accent-green); font-weight: 600; }
|
||||
.ais-call { color: var(--accent-red); font-weight: 600; }
|
||||
.vdes-call { color: #8ec8ff; font-weight: 600; }
|
||||
.aprs-time { color: var(--text-muted); margin-right: 0.5rem; }
|
||||
.ais-time { color: var(--text-muted); margin-right: 0.5rem; }
|
||||
.vdes-time { color: var(--text-muted); margin-right: 0.5rem; }
|
||||
.ais-row-head,
|
||||
.ais-row-meta,
|
||||
.ais-row-detail {
|
||||
@@ -1406,6 +1422,42 @@ small { color: var(--text-muted); }
|
||||
.ais-pos-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.vdes-row-head,
|
||||
.vdes-row-meta,
|
||||
.vdes-row-detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.vdes-row-head + .vdes-row-meta,
|
||||
.vdes-row-meta + .vdes-row-detail {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.vdes-row-detail {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.vdes-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 1.2rem;
|
||||
padding: 0.02rem 0.38rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, #8ec8ff 42%, transparent);
|
||||
background: color-mix(in srgb, #8ec8ff 12%, transparent);
|
||||
color: #8ec8ff;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.vdes-raw {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; }
|
||||
.aprs-pos { color: var(--accent-green); text-decoration: none; margin-left: 0.3rem; font-size: 0.8rem; }
|
||||
.aprs-pos:hover { text-decoration: underline; }
|
||||
@@ -1769,6 +1821,9 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
#subtab-ais {
|
||||
min-height: calc(100vh - 14rem);
|
||||
}
|
||||
#subtab-vdes {
|
||||
min-height: calc(100vh - 14rem);
|
||||
}
|
||||
#subtab-aprs {
|
||||
min-height: calc(100vh - 14rem);
|
||||
}
|
||||
@@ -1778,6 +1833,9 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
#ais-messages {
|
||||
min-height: calc(100vh - 22rem);
|
||||
}
|
||||
#vdes-messages {
|
||||
min-height: calc(100vh - 22rem);
|
||||
}
|
||||
.aprs-details-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.14rem;
|
||||
|
||||
@@ -993,6 +993,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(app_js)
|
||||
.service(leaflet_ais_tracksymbol_js)
|
||||
.service(ais_js)
|
||||
.service(vdes_js)
|
||||
.service(aprs_js)
|
||||
.service(ft8_js)
|
||||
.service(wspr_js)
|
||||
@@ -1079,6 +1080,16 @@ async fn ais_js() -> impl Responder {
|
||||
.body(status::AIS_JS)
|
||||
}
|
||||
|
||||
#[get("/vdes.js")]
|
||||
async fn vdes_js() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.insert_header((
|
||||
header::CONTENT_TYPE,
|
||||
"application/javascript; charset=utf-8",
|
||||
))
|
||||
.body(status::VDES_JS)
|
||||
}
|
||||
|
||||
#[get("/ft8.js")]
|
||||
async fn ft8_js() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
|
||||
@@ -12,6 +12,7 @@ pub const APP_JS: &str = include_str!("../assets/web/app.js");
|
||||
pub const LEAFLET_AIS_TRACKSYMBOL_JS: &str =
|
||||
include_str!("../assets/web/leaflet-ais-tracksymbol.js");
|
||||
pub const AIS_JS: &str = include_str!("../assets/web/plugins/ais.js");
|
||||
pub const VDES_JS: &str = include_str!("../assets/web/plugins/vdes.js");
|
||||
pub const APRS_JS: &str = include_str!("../assets/web/plugins/aprs.js");
|
||||
pub const FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js");
|
||||
pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js");
|
||||
|
||||
Reference in New Issue
Block a user