[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:
2026-03-03 00:05:16 +01:00
parent 051d07eaab
commit 92423f1e02
25 changed files with 878 additions and 32 deletions
+6 -1
View File
@@ -13,7 +13,9 @@ use tokio::sync::{broadcast, mpsc, watch};
use tokio::task::JoinHandle;
use trx_core::audio::AudioStreamInfo;
use trx_core::decode::{AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, WsprMessage};
use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WsprMessage,
};
use trx_core::rig::state::{RigSnapshot, SpectrumData};
use trx_core::{DynResult, RigRequest, RigState};
@@ -138,6 +140,8 @@ pub struct FrontendRuntimeContext {
pub decode_rx: Option<broadcast::Sender<DecodedMessage>>,
/// AIS decode history (timestamp, message)
pub ais_history: Arc<Mutex<VecDeque<(Instant, AisMessage)>>>,
/// VDES decode history (timestamp, message)
pub vdes_history: Arc<Mutex<VecDeque<(Instant, VdesMessage)>>>,
/// APRS decode history (timestamp, packet)
pub aprs_history: Arc<Mutex<VecDeque<(Instant, AprsPacket)>>>,
/// CW decode history (timestamp, event)
@@ -201,6 +205,7 @@ impl FrontendRuntimeContext {
audio_info: None,
decode_rx: None,
ais_history: Arc::new(Mutex::new(VecDeque::new())),
vdes_history: Arc::new(Mutex::new(VecDeque::new())),
aprs_history: Arc::new(Mutex::new(VecDeque::new())),
cw_history: Arc::new(Mutex::new(VecDeque::new())),
ft8_history: Arc::new(Mutex::new(VecDeque::new())),
@@ -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();
@@ -272,6 +272,11 @@ pub async fn decode_events(
.into_iter()
.map(trx_core::decode::DecodedMessage::Ais),
);
out.extend(
crate::server::audio::snapshot_vdes_history(context.get_ref())
.into_iter()
.map(trx_core::decode::DecodedMessage::Vdes),
);
out.extend(
crate::server::audio::snapshot_aprs_history(context.get_ref())
.into_iter()
@@ -707,6 +712,14 @@ pub async fn clear_ais_decode(
Ok(HttpResponse::Ok().finish())
}
#[post("/clear_vdes_decode")]
pub async fn clear_vdes_decode(
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_vdes_history(context.get_ref());
Ok(HttpResponse::Ok().finish())
}
#[post("/clear_cw_decode")]
pub async fn clear_cw_decode(
context: web::Data<Arc<FrontendRuntimeContext>>,
@@ -961,6 +974,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(toggle_ft8_decode)
.service(toggle_wspr_decode)
.service(clear_ais_decode)
.service(clear_vdes_decode)
.service(clear_aprs_decode)
.service(clear_cw_decode)
.service(clear_ft8_decode)
@@ -977,6 +991,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(logo)
.service(style_css)
.service(app_js)
.service(leaflet_ais_tracksymbol_js)
.service(ais_js)
.service(aprs_js)
.service(ft8_js)
@@ -1034,6 +1049,16 @@ async fn app_js() -> impl Responder {
.body(status::APP_JS)
}
#[get("/leaflet-ais-tracksymbol.js")]
async fn leaflet_ais_tracksymbol_js() -> impl Responder {
HttpResponse::Ok()
.insert_header((
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
))
.body(status::LEAFLET_AIS_TRACKSYMBOL_JS)
}
#[get("/aprs.js")]
async fn aprs_js() -> impl Responder {
HttpResponse::Ok()
@@ -20,7 +20,9 @@ use bytes::Bytes;
use tokio::sync::broadcast;
use tracing::warn;
use trx_core::decode::{AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, WsprMessage};
use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WsprMessage,
};
use trx_frontend::FrontendRuntimeContext;
const HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
@@ -43,6 +45,15 @@ fn prune_ais_history(history: &mut VecDeque<(Instant, AisMessage)>) {
}
}
fn prune_vdes_history(history: &mut VecDeque<(Instant, VdesMessage)>) {
while let Some((ts, _)) = history.front() {
if ts.elapsed() <= HISTORY_RETENTION {
break;
}
history.pop_front();
}
}
fn record_ais(context: &FrontendRuntimeContext, msg: AisMessage) {
let mut history = context
.ais_history
@@ -52,6 +63,15 @@ fn record_ais(context: &FrontendRuntimeContext, msg: AisMessage) {
prune_ais_history(&mut history);
}
fn record_vdes(context: &FrontendRuntimeContext, msg: VdesMessage) {
let mut history = context
.vdes_history
.lock()
.expect("vdes history mutex poisoned");
history.push_back((Instant::now(), msg));
prune_vdes_history(&mut history);
}
fn prune_cw_history(history: &mut VecDeque<(Instant, CwEvent)>) {
while let Some((ts, _)) = history.front() {
if ts.elapsed() <= HISTORY_RETENTION {
@@ -147,6 +167,22 @@ pub fn snapshot_ais_history(context: &FrontendRuntimeContext) -> Vec<AisMessage>
.collect()
}
pub fn snapshot_vdes_history(context: &FrontendRuntimeContext) -> Vec<VdesMessage> {
let mut history = context
.vdes_history
.lock()
.expect("vdes history mutex poisoned");
prune_vdes_history(&mut history);
history
.iter()
.map(|(ts, msg)| {
let mut msg = msg.clone();
msg.ts_ms = Some(timestamp_ms_for_elapsed(ts.elapsed()));
msg
})
.collect()
}
pub fn snapshot_cw_history(context: &FrontendRuntimeContext) -> Vec<CwEvent> {
let mut history = context
.cw_history
@@ -190,6 +226,14 @@ pub fn clear_ais_history(context: &FrontendRuntimeContext) {
history.clear();
}
pub fn clear_vdes_history(context: &FrontendRuntimeContext) {
let mut history = context
.vdes_history
.lock()
.expect("vdes history mutex poisoned");
history.clear();
}
fn timestamp_ms_for_elapsed(elapsed: Duration) -> i64 {
let wall_clock = SystemTime::now()
.checked_sub(elapsed)
@@ -249,6 +293,7 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
match rx.recv().await {
Ok(msg) => match msg {
DecodedMessage::Ais(msg) => record_ais(&context, msg),
DecodedMessage::Vdes(msg) => record_vdes(&context, msg),
DecodedMessage::Aprs(pkt) => record_aprs(&context, pkt),
DecodedMessage::Cw(evt) => record_cw(&context, evt),
DecodedMessage::Ft8(msg) => record_ft8(&context, msg),
@@ -9,6 +9,8 @@ const CLIENT_BUILD_DATE: &str = env!("TRX_CLIENT_BUILD_DATE");
const INDEX_HTML: &str = include_str!("../assets/web/index.html");
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
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 APRS_JS: &str = include_str!("../assets/web/plugins/aprs.js");
pub const FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js");