[feat](trx-rs): add VDES decoder mode support
Add a new trx-vdes decoder path alongside AIS, wire VDES through the server/frontend decode pipeline, and fix the web map so AIS vessel symbols load correctly and the TRX receiver marker appears when location data arrives. Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -1208,7 +1208,8 @@ function coverageGuardBandwidthHz(mode = modeEl ? modeEl.value : "") {
|
||||
}
|
||||
|
||||
function isAisMode(mode = modeEl ? modeEl.value : "") {
|
||||
return String(mode || "").toUpperCase() === "AIS";
|
||||
const upper = String(mode || "").toUpperCase();
|
||||
return upper === "AIS" || upper === "VDES";
|
||||
}
|
||||
|
||||
function coverageSpanForMode(freqHz, bandwidthHz = coverageGuardBandwidthHz(), mode = modeEl ? modeEl.value : "") {
|
||||
@@ -1870,6 +1871,7 @@ function render(update) {
|
||||
}
|
||||
if (update.server_latitude != null) serverLat = update.server_latitude;
|
||||
if (update.server_longitude != null) serverLon = update.server_longitude;
|
||||
if (aprsMap) syncAprsReceiverMarker();
|
||||
if (typeof update.initial_map_zoom === "number" && Number.isFinite(update.initial_map_zoom)) {
|
||||
initialMapZoom = Math.max(1, Math.round(update.initial_map_zoom));
|
||||
}
|
||||
@@ -2036,8 +2038,8 @@ function render(update) {
|
||||
const wsprStatus = document.getElementById("wspr-status");
|
||||
setModeBoundDecodeStatus(
|
||||
aisStatus,
|
||||
["AIS"],
|
||||
"Select AIS mode to decode",
|
||||
["AIS", "VDES"],
|
||||
"Select AIS or VDES mode to decode",
|
||||
"Connected, listening for packets",
|
||||
);
|
||||
if (window.updateAisBar) window.updateAisBar();
|
||||
@@ -2732,6 +2734,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],
|
||||
WFM: [180_000, 50_000,300_000,5_000],
|
||||
DIG: [3_000, 300, 6_000, 100],
|
||||
PKT: [25_000, 300, 50_000, 500],
|
||||
@@ -3019,6 +3022,34 @@ const AIS_TRACK_MAX_POINTS = 64;
|
||||
const aisMarkers = new Map();
|
||||
let selectedAisTrackMmsi = null;
|
||||
|
||||
function syncAprsReceiverMarker() {
|
||||
if (!aprsMap) return;
|
||||
const hasLocation = serverLat != null && serverLon != null;
|
||||
if (!hasLocation) {
|
||||
if (aprsMapReceiverMarker && aprsMap.hasLayer(aprsMapReceiverMarker)) {
|
||||
aprsMapReceiverMarker.removeFrom(aprsMap);
|
||||
}
|
||||
aprsMapReceiverMarker = null;
|
||||
return;
|
||||
}
|
||||
const latLng = [serverLat, serverLon];
|
||||
if (!aprsMapReceiverMarker) {
|
||||
aprsMapReceiverMarker = L.circleMarker(latLng, {
|
||||
radius: 8,
|
||||
className: "trx-receiver-marker",
|
||||
fillOpacity: 0.8,
|
||||
}).addTo(aprsMap).bindPopup("");
|
||||
if (typeof aprsMap.setView === "function") {
|
||||
aprsMap.setView(latLng, Math.max(1, initialMapZoom));
|
||||
}
|
||||
return;
|
||||
}
|
||||
aprsMapReceiverMarker.setLatLng(latLng);
|
||||
if (!aprsMap.hasLayer(aprsMapReceiverMarker)) {
|
||||
aprsMapReceiverMarker.addTo(aprsMap);
|
||||
}
|
||||
}
|
||||
|
||||
window.clearMapMarkersByType = function(type) {
|
||||
if (type === "aprs") {
|
||||
stationMarkers.forEach((entry) => {
|
||||
@@ -3106,12 +3137,7 @@ function initAprsMap() {
|
||||
|
||||
aprsMap = L.map("aprs-map").setView(center, zoom);
|
||||
updateMapBaseLayerForTheme(currentTheme());
|
||||
|
||||
if (hasLocation) {
|
||||
aprsMapReceiverMarker = L.circleMarker([serverLat, serverLon], {
|
||||
radius: 8, className: "trx-receiver-marker", fillOpacity: 0.8
|
||||
}).addTo(aprsMap).bindPopup("");
|
||||
}
|
||||
syncAprsReceiverMarker();
|
||||
|
||||
// Rebuild popup content on open (keeps age/distance/rig list fresh)
|
||||
aprsMap.on("popupopen", function(e) {
|
||||
@@ -4305,7 +4331,7 @@ function updateDecodeStatus(text) {
|
||||
const aprs = document.getElementById("aprs-status");
|
||||
const cw = document.getElementById("cw-status");
|
||||
const ft8 = document.getElementById("ft8-status");
|
||||
setModeBoundDecodeStatus(ais, ["AIS"], "Select AIS mode to decode", text);
|
||||
setModeBoundDecodeStatus(ais, ["AIS", "VDES"], "Select AIS or VDES mode to decode", text);
|
||||
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;
|
||||
@@ -4326,6 +4352,7 @@ function connectDecode() {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data);
|
||||
if (msg.type === "ais" && window.onServerAis) window.onServerAis(msg);
|
||||
if (msg.type === "vdes" && window.onServerVdes) window.onServerVdes(msg);
|
||||
if (msg.type === "aprs" && window.onServerAprs) window.onServerAprs(msg);
|
||||
if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg);
|
||||
if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg);
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
<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</button>
|
||||
<button class="sub-tab" data-subtab="ais">AIS/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 +359,9 @@
|
||||
</div>
|
||||
<div id="subtab-overview" class="sub-tab-panel">
|
||||
<div class="plugin-item">
|
||||
<strong>AIS Decoder</strong>
|
||||
<strong>AIS / VDES Decoder</strong>
|
||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||
Decodes dual-channel AIS traffic from RX audio using 9.6 kbit/s GMSK and HDLC.
|
||||
Decodes dual-channel AIS and VDES traffic from RX audio using 9.6 kbit/s GMSK and HDLC.
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-item">
|
||||
@@ -528,7 +528,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</label>
|
||||
<label><input type="checkbox" id="map-filter-ais" checked /> AIS/VDES</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>
|
||||
|
||||
@@ -14,6 +14,15 @@ 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`;
|
||||
}
|
||||
@@ -30,16 +39,17 @@ function currentAisChannelPlan() {
|
||||
|
||||
function aisChannelInfo(channel) {
|
||||
const plan = currentAisChannelPlan();
|
||||
const modeLabel = currentAisLikeModeLabel();
|
||||
const ch = String(channel || "").trim().toUpperCase();
|
||||
if (ch === "B") {
|
||||
return {
|
||||
label: "AIS-B",
|
||||
label: `${modeLabel}-B`,
|
||||
badgeClass: "ais-badge ais-badge-channel-b",
|
||||
freqText: formatAisMhz(plan.bHz),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "AIS-A",
|
||||
label: `${modeLabel}-A`,
|
||||
badgeClass: "ais-badge ais-badge-channel-a",
|
||||
freqText: formatAisMhz(plan.aHz),
|
||||
};
|
||||
@@ -217,7 +227,8 @@ function updateAisBar() {
|
||||
if (!aisBarOverlay) return;
|
||||
updateAisSummary();
|
||||
|
||||
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||
const isAis = isAisLikeMode();
|
||||
const modeLabel = currentAisLikeModeLabel();
|
||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||
const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
|
||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||
@@ -227,7 +238,7 @@ function updateAisBar() {
|
||||
return;
|
||||
}
|
||||
|
||||
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>';
|
||||
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>`;
|
||||
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
|
||||
@@ -294,10 +305,10 @@ function addAisMessage(msg) {
|
||||
if (aisClearBtn) {
|
||||
aisClearBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await postPath("/clear_ais_decode");
|
||||
await postPath(currentAisLikeModeLabel() === "VDES" ? "/clear_vdes_decode" : "/clear_ais_decode");
|
||||
window.resetAisHistoryView();
|
||||
} catch (e) {
|
||||
console.error("AIS clear failed", e);
|
||||
console.error("AIS/VDES clear failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -327,4 +338,22 @@ 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();
|
||||
|
||||
Reference in New Issue
Block a user