[feat](trx-rs): add AIS decoder mode and frontend

Add dual-channel AIS decode support across the SoapySDR backend, server decode pipeline, and frontend plugins, including the new AIS tab, live bar, and map filtering.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-02 22:42:12 +01:00
parent b6692b759e
commit c778d4b9a8
28 changed files with 1200 additions and 86 deletions
+5 -4
View File
@@ -18,9 +18,9 @@ use tracing::{info, warn};
use trx_frontend::RemoteRigEntry;
use trx_core::audio::{
read_audio_msg, write_audio_msg, AudioStreamInfo, AUDIO_MSG_APRS_DECODE, AUDIO_MSG_CW_DECODE,
AUDIO_MSG_FT8_DECODE, AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME,
AUDIO_MSG_WSPR_DECODE,
read_audio_msg, write_audio_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE,
AUDIO_MSG_APRS_DECODE, AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT8_DECODE, AUDIO_MSG_RX_FRAME,
AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_WSPR_DECODE,
};
use trx_core::decode::DecodedMessage;
@@ -148,7 +148,8 @@ async fn handle_audio_connection(
let _ = rx_tx.send(Bytes::from(payload));
}
Ok((
AUDIO_MSG_APRS_DECODE
AUDIO_MSG_AIS_DECODE
| AUDIO_MSG_APRS_DECODE
| AUDIO_MSG_CW_DECODE
| AUDIO_MSG_FT8_DECODE
| AUDIO_MSG_WSPR_DECODE,
+4 -1
View File
@@ -13,7 +13,7 @@ use tokio::sync::{broadcast, mpsc, watch};
use tokio::task::JoinHandle;
use trx_core::audio::AudioStreamInfo;
use trx_core::decode::{AprsPacket, CwEvent, DecodedMessage, Ft8Message, WsprMessage};
use trx_core::decode::{AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, WsprMessage};
use trx_core::rig::state::{RigSnapshot, SpectrumData};
use trx_core::{DynResult, RigRequest, RigState};
@@ -136,6 +136,8 @@ pub struct FrontendRuntimeContext {
pub audio_info: Option<watch::Receiver<Option<AudioStreamInfo>>>,
/// Decode message broadcast channel
pub decode_rx: Option<broadcast::Sender<DecodedMessage>>,
/// AIS decode history (timestamp, message)
pub ais_history: Arc<Mutex<VecDeque<(Instant, AisMessage)>>>,
/// APRS decode history (timestamp, packet)
pub aprs_history: Arc<Mutex<VecDeque<(Instant, AprsPacket)>>>,
/// CW decode history (timestamp, event)
@@ -196,6 +198,7 @@ impl FrontendRuntimeContext {
audio_tx: None,
audio_info: None,
decode_rx: None,
ais_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())),
@@ -845,10 +845,11 @@ function drawSignalOverlay() {
if (lastFreqHz != null && currentBandwidthHz > 0) {
const halfBw = currentBandwidthHz / 2;
const xL = hzToX(lastFreqHz - halfBw);
const xR = hzToX(lastFreqHz + halfBw);
const stripW = xR - xL;
if (stripW > 1) {
for (const centerHz of visibleBandwidthCenters(lastFreqHz)) {
const xL = hzToX(centerHz - halfBw);
const xR = hzToX(centerHz + halfBw);
const stripW = xR - xL;
if (stripW <= 1) continue;
const grd = ctx.createLinearGradient(xL, 0, xR, 0);
grd.addColorStop(0, "rgba(240,173,78,0.05)");
grd.addColorStop(0.2, "rgba(240,173,78,0.14)");
@@ -1204,6 +1205,31 @@ function coverageGuardBandwidthHz(mode = modeEl ? modeEl.value : "") {
return Math.max(0, Number.isFinite(maxBw) ? maxBw : currentBandwidthHz);
}
function isAisMode(mode = modeEl ? modeEl.value : "") {
return String(mode || "").toUpperCase() === "AIS";
}
function coverageSpanForMode(freqHz, bandwidthHz = coverageGuardBandwidthHz(), mode = modeEl ? modeEl.value : "") {
if (!Number.isFinite(freqHz)) return null;
const safeBw = Math.max(0, Number.isFinite(bandwidthHz) ? bandwidthHz : 0);
let loHz = freqHz - safeBw / 2;
let hiHz = freqHz + safeBw / 2;
if (isAisMode(mode)) {
const aisBFreqHz = freqHz + 50_000;
loHz = Math.min(loHz, aisBFreqHz - safeBw / 2);
hiHz = Math.max(hiHz, aisBFreqHz + safeBw / 2);
}
return { loHz, hiHz };
}
function visibleBandwidthCenters(freqHz = lastFreqHz, mode = modeEl ? modeEl.value : "") {
if (!Number.isFinite(freqHz)) return [];
if (isAisMode(mode)) {
return [freqHz, freqHz + 50_000];
}
return [freqHz];
}
function effectiveSpectrumCoverageSpanHz(sampleRateHz) {
const sampleRate = Number(sampleRateHz);
if (!Number.isFinite(sampleRate) || sampleRate <= 0) return 0;
@@ -1220,17 +1246,17 @@ function requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz = covera
return null;
}
const safeBw = Math.max(0, Number.isFinite(bandwidthHz) ? bandwidthHz : 0);
const halfSpanHz = sampleRate / 2;
const requiredHalfSpanHz = safeBw / 2 + spectrumCoverageMarginHz;
if (requiredHalfSpanHz * 2 >= sampleRate) {
const span = coverageSpanForMode(freqHz, bandwidthHz);
if (!span) return null;
const requiredLoHz = span.loHz - spectrumCoverageMarginHz;
const requiredHiHz = span.hiHz + spectrumCoverageMarginHz;
if (requiredHiHz - requiredLoHz >= sampleRate) {
return alignFreqToRigStep(Math.round(freqHz));
}
const currentLoHz = currentCenterHz - halfSpanHz;
const currentHiHz = currentCenterHz + halfSpanHz;
const requiredLoHz = freqHz - requiredHalfSpanHz;
const requiredHiHz = freqHz + requiredHalfSpanHz;
if (requiredLoHz >= currentLoHz && requiredHiHz <= currentHiHz) {
return null;
}
@@ -1300,8 +1326,11 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) {
const halfUsableSpanHz = usableSpanHz / 2;
const fullHalfSpanHz = sampleRate / 2;
const guardHalfSpanHz = bandwidthHz / 2 + spectrumCoverageMarginHz;
if (guardHalfSpanHz * 2 >= usableSpanHz) {
const span = coverageSpanForMode(freqHz, bandwidthHz);
if (!span) return null;
const requiredLoHz = span.loHz - spectrumCoverageMarginHz;
const requiredHiHz = span.hiHz + spectrumCoverageMarginHz;
if (requiredHiHz - requiredLoHz >= usableSpanHz) {
const fallbackCenterHz = requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz);
if (!Number.isFinite(fallbackCenterHz)) return null;
return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY };
@@ -1310,8 +1339,8 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) {
const evalHalfSpanHz = Math.max(0, (sampleRate - usableSpanHz) / 2);
const evalMinCenterHz = currentCenterHz - evalHalfSpanHz;
const evalMaxCenterHz = currentCenterHz + evalHalfSpanHz;
const fitMinCenterHz = freqHz + guardHalfSpanHz - halfUsableSpanHz;
const fitMaxCenterHz = freqHz - guardHalfSpanHz + halfUsableSpanHz;
const fitMinCenterHz = requiredHiHz - halfUsableSpanHz;
const fitMaxCenterHz = requiredLoHz + halfUsableSpanHz;
const minCenterHz = Math.max(evalMinCenterHz, fitMinCenterHz);
const maxCenterHz = Math.min(evalMaxCenterHz, fitMaxCenterHz);
if (!Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) {
@@ -1334,8 +1363,8 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) {
let bestStartIdx = null;
let bestScore = Number.POSITIVE_INFINITY;
const signalLoHz = freqHz - bandwidthHz / 2;
const signalHiHz = freqHz + bandwidthHz / 2;
const signalLoHz = span.loHz;
const signalHiHz = span.hiHz;
for (let startIdx = startMinIdx; startIdx <= startMaxIdx; startIdx += 1) {
const endIdx = Math.min(maxIdx, startIdx + usableBins);
@@ -1351,7 +1380,8 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) {
}
// Keep a very small bias toward a reasonably centered passband when scores are close.
const centeredOffsetHz = Math.abs(candidateCenterHz - freqHz);
const spanMidHz = (span.loHz + span.hiHz) / 2;
const centeredOffsetHz = Math.abs(candidateCenterHz - spanMidHz);
score *= 1 + centeredOffsetHz / Math.max(usableSpanHz, 1) * 0.08;
if (score < bestScore) {
bestScore = score;
@@ -1388,13 +1418,16 @@ function sweetSpotProbeCenters(data, freqHz, bandwidthHz) {
if (!Number.isFinite(usableSpanHz) || usableSpanHz <= 0) return [];
const halfUsableSpanHz = usableSpanHz / 2;
const guardHalfSpanHz = bandwidthHz / 2 + spectrumCoverageMarginHz;
if (guardHalfSpanHz * 2 >= usableSpanHz) {
const span = coverageSpanForMode(freqHz, bandwidthHz);
if (!span) return [];
const requiredLoHz = span.loHz - spectrumCoverageMarginHz;
const requiredHiHz = span.hiHz + spectrumCoverageMarginHz;
if (requiredHiHz - requiredLoHz >= usableSpanHz) {
return [alignFreqToRigStep(Math.round(freqHz))];
}
const minCenterHz = freqHz + guardHalfSpanHz - halfUsableSpanHz;
const maxCenterHz = freqHz - guardHalfSpanHz + halfUsableSpanHz;
const minCenterHz = requiredHiHz - halfUsableSpanHz;
const maxCenterHz = requiredLoHz + halfUsableSpanHz;
if (!Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) {
return [];
}
@@ -1486,15 +1519,17 @@ function tunedFrequencyForCenterCoverage(centerHz, freqHz = lastFreqHz, bandwidt
const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate);
if (!Number.isFinite(sampleRate) || sampleRate <= 0) return null;
const safeBw = Math.max(0, Number.isFinite(bandwidthHz) ? bandwidthHz : 0);
const span = coverageSpanForMode(freqHz, bandwidthHz);
if (!span) return null;
const halfSpanHz = sampleRate / 2;
const requiredHalfSpanHz = safeBw / 2 + spectrumCoverageMarginHz;
if (requiredHalfSpanHz * 2 >= sampleRate) {
const requiredLoOffset = freqHz - (span.loHz - spectrumCoverageMarginHz);
const requiredHiOffset = (span.hiHz + spectrumCoverageMarginHz) - freqHz;
if (requiredLoOffset + requiredHiOffset >= sampleRate) {
return alignFreqToRigStep(Math.round(centerHz));
}
const minFreqHz = centerHz - halfSpanHz + requiredHalfSpanHz;
const maxFreqHz = centerHz + halfSpanHz - requiredHalfSpanHz;
const minFreqHz = centerHz - halfSpanHz + requiredLoOffset;
const maxFreqHz = centerHz + halfSpanHz - requiredHiOffset;
if (freqHz >= minFreqHz && freqHz <= maxFreqHz) {
return null;
}
@@ -1953,10 +1988,15 @@ function render(update) {
}
}
const modeUpper = update.status && update.status.mode ? normalizeMode(update.status.mode).toUpperCase() : "";
const aisStatus = document.getElementById("ais-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");
if (aisStatus && modeUpper !== "AIS" && aisStatus.textContent === "Receiving") {
aisStatus.textContent = "Connected, listening for packets";
}
if (window.updateAisBar) window.updateAisBar();
if (aprsStatus && modeUpper !== "PKT" && aprsStatus.textContent === "Receiving") {
aprsStatus.textContent = "Connected, listening for packets";
}
@@ -2644,6 +2684,7 @@ const MODE_BW_DEFAULTS = {
USB: [2_700, 300, 6_000, 100],
AM: [9_000, 500, 20_000, 500],
FM: [12_500, 2_500, 25_000, 500],
AIS: [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],
@@ -2925,8 +2966,9 @@ let aprsRadioPath = null;
const stationMarkers = new Map();
const locatorMarkers = new Map();
const mapMarkers = new Set();
const mapFilter = { aprs: true, ft8: true, wspr: true };
const mapFilter = { ais: true, aprs: true, ft8: true, wspr: true };
const APRS_TRACK_MAX_POINTS = 64;
const aisMarkers = new Map();
window.clearMapMarkersByType = function(type) {
if (type === "aprs") {
@@ -2944,6 +2986,17 @@ window.clearMapMarkersByType = function(type) {
return;
}
if (type === "ais") {
aisMarkers.forEach((entry) => {
if (entry && entry.marker) {
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
mapMarkers.delete(entry.marker);
}
});
aisMarkers.clear();
return;
}
if (type === "ft8" || type === "wspr") {
const prefix = `${type}:`;
for (const [key, entry] of locatorMarkers.entries()) {
@@ -3041,9 +3094,16 @@ function initAprsMap() {
}
applyMapFilter();
const aisFilter = document.getElementById("map-filter-ais");
const aprsFilter = document.getElementById("map-filter-aprs");
const ft8Filter = document.getElementById("map-filter-ft8");
const wsprFilter = document.getElementById("map-filter-wspr");
if (aisFilter) {
aisFilter.addEventListener("change", () => {
mapFilter.ais = aisFilter.checked;
applyMapFilter();
});
}
if (aprsFilter) {
aprsFilter.addEventListener("change", () => {
mapFilter.aprs = aprsFilter.checked;
@@ -3188,6 +3248,30 @@ function buildAprsPopupHtml(call, lat, lon, info, pkt) {
`</div>`;
}
function buildAisPopupHtml(msg) {
const age = msg?._tsMs ? formatTimeAgo(msg._tsMs) : null;
const distKm = (serverLat != null && serverLon != null && msg?.lat != null && msg?.lon != null)
? haversineKm(serverLat, serverLon, msg.lat, msg.lon)
: null;
const distStr = distKm != null
? (distKm < 1 ? `${Math.round(distKm * 1000)} m` : `${distKm.toFixed(1)} km`)
: null;
const meta = [age, distStr, msg?.channel ? `AIS ${escapeMapHtml(msg.channel)}` : null].filter(Boolean).join(" &middot; ");
let rows = "";
rows += `<tr><td class="aprs-popup-label">MMSI</td><td>${escapeMapHtml(String(msg.mmsi || "--"))}</td></tr>`;
rows += `<tr><td class="aprs-popup-label">Type</td><td>${escapeMapHtml(String(msg.message_type || "--"))}</td></tr>`;
if (msg?.sog_knots != null) rows += `<tr><td class="aprs-popup-label">SOG</td><td>${Number(msg.sog_knots).toFixed(1)} kn</td></tr>`;
if (msg?.cog_deg != null) rows += `<tr><td class="aprs-popup-label">COG</td><td>${Number(msg.cog_deg).toFixed(1)}&deg;</td></tr>`;
if (msg?.lat != null && msg?.lon != null) rows += `<tr><td class="aprs-popup-label">Pos</td><td>${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</td></tr>`;
const info = [msg?.vessel_name, msg?.callsign, msg?.destination].filter(Boolean).map(escapeMapHtml).join(" · ");
return `<div class="aprs-popup">` +
`<div class="aprs-popup-call">${escapeMapHtml(msg?.vessel_name || `MMSI ${msg?.mmsi || "--"}`)}</div>` +
(meta ? `<div class="aprs-popup-meta">${meta}</div>` : "") +
(rows ? `<table class="aprs-popup-table">${rows}</table>` : "") +
(info ? `<div class="aprs-popup-info">${info}</div>` : "") +
`</div>`;
}
function aprsPositionsEqual(a, b) {
if (!a || !b) return false;
return Math.abs(a[0] - b[0]) < 0.000001 && Math.abs(a[1] - b[1]) < 0.000001;
@@ -3276,6 +3360,31 @@ window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCod
}
};
window.aisMapAddVessel = function(msg) {
if (msg == null || msg.lat == null || msg.lon == null || !Number.isFinite(msg.mmsi)) return;
if (!aprsMap) initAprsMap();
const key = String(msg.mmsi);
const popupHtml = buildAisPopupHtml(msg);
const existing = aisMarkers.get(key);
if (existing && existing.marker) {
existing.msg = msg;
existing.marker.setLatLng([msg.lat, msg.lon]);
existing.marker.setPopupContent(popupHtml);
return;
}
if (!aprsMap) return;
const marker = L.circleMarker([msg.lat, msg.lon], {
radius: 6,
color: "#e2553d",
fillColor: "#ff7559",
fillOpacity: 0.82,
}).addTo(aprsMap).bindPopup(popupHtml);
marker.__trxType = "ais";
mapMarkers.add(marker);
aisMarkers.set(key, { marker, msg });
applyMapFilter();
};
function maidenheadToBounds(grid) {
if (!grid || grid.length < 4) return null;
const g = grid.toUpperCase();
@@ -3310,6 +3419,7 @@ function applyMapFilter() {
mapMarkers.forEach((marker) => {
const type = marker.__trxType;
const visible =
(type === "ais" && mapFilter.ais) ||
(type === "aprs" && mapFilter.aprs) ||
(type === "ft8" && mapFilter.ft8) ||
(type === "wspr" && mapFilter.wspr);
@@ -3999,15 +4109,18 @@ document.getElementById("copyright-year").textContent = new Date().getFullYear()
let decodeSource = null;
let decodeConnected = false;
function updateDecodeStatus(text) {
const ais = document.getElementById("ais-status");
const aprs = document.getElementById("aprs-status");
const cw = document.getElementById("cw-status");
const ft8 = document.getElementById("ft8-status");
if (ais && ais.textContent !== "Receiving") ais.textContent = text;
if (aprs && aprs.textContent !== "Receiving") aprs.textContent = text;
if (cw && cw.textContent !== "Receiving") cw.textContent = text;
if (ft8 && ft8.textContent !== "Receiving") ft8.textContent = text;
}
function connectDecode() {
if (decodeSource) { decodeSource.close(); }
if (window.resetAisHistoryView) window.resetAisHistoryView();
if (window.resetAprsHistoryView) window.resetAprsHistoryView();
if (window.resetCwHistoryView) window.resetCwHistoryView();
if (window.resetFt8HistoryView) window.resetFt8HistoryView();
@@ -4020,6 +4133,7 @@ function connectDecode() {
decodeSource.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
if (msg.type === "ais" && window.onServerAis) window.onServerAis(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);
@@ -4796,36 +4910,38 @@ function drawSpectrum(data) {
// ── BW strip (drawn before spectrum so traces appear on top) ──────────────
if (lastFreqHz != null && currentBandwidthHz > 0) {
if (_bwDragEdge) {
const xMid = hzToX(lastFreqHz);
// Bottom bookmark tab centered on the dial frequency, shown only while resizing BW
// Bottom bookmark tab centered on each visible channel, shown while resizing BW
const bwText = formatBwLabel(currentBandwidthHz);
ctx.save();
ctx.font = `bold ${Math.round(10 * dpr)}px sans-serif`;
const tw = ctx.measureText(bwText).width;
const PAD = 6 * dpr;
const TAB_H = 16 * dpr;
const TAB_OFFSET = 4 * dpr;
const tabX = Math.max(0, Math.min(W - tw - PAD * 2, xMid - (tw + PAD * 2) / 2));
const tabBottom = H - TAB_OFFSET;
const tabY = tabBottom - TAB_H;
const r = 3 * dpr;
// Rounded-bottom tab shape (flat top)
ctx.fillStyle = "rgba(240,173,78,0.85)";
ctx.beginPath();
ctx.moveTo(tabX, tabY);
ctx.lineTo(tabX + tw + PAD * 2, tabY);
ctx.lineTo(tabX + tw + PAD * 2, tabBottom - r);
ctx.arcTo(tabX + tw + PAD * 2, tabBottom, tabX + tw + PAD * 2 - r, tabBottom, r);
ctx.lineTo(tabX + r, tabBottom);
ctx.arcTo(tabX, tabBottom, tabX, tabBottom - r, r);
ctx.lineTo(tabX, tabY);
ctx.closePath();
ctx.fill();
// Tab text
ctx.fillStyle = spectrumBgColor();
ctx.textAlign = "left";
ctx.fillText(bwText, tabX + PAD, tabBottom - 4 * dpr);
ctx.restore();
for (const centerHz of visibleBandwidthCenters(lastFreqHz)) {
const xMid = hzToX(centerHz);
ctx.save();
ctx.font = `bold ${Math.round(10 * dpr)}px sans-serif`;
const tw = ctx.measureText(bwText).width;
const PAD = 6 * dpr;
const TAB_H = 16 * dpr;
const TAB_OFFSET = 4 * dpr;
const tabX = Math.max(0, Math.min(W - tw - PAD * 2, xMid - (tw + PAD * 2) / 2));
const tabBottom = H - TAB_OFFSET;
const tabY = tabBottom - TAB_H;
const r = 3 * dpr;
// Rounded-bottom tab shape (flat top)
ctx.fillStyle = "rgba(240,173,78,0.85)";
ctx.beginPath();
ctx.moveTo(tabX, tabY);
ctx.lineTo(tabX + tw + PAD * 2, tabY);
ctx.lineTo(tabX + tw + PAD * 2, tabBottom - r);
ctx.arcTo(tabX + tw + PAD * 2, tabBottom, tabX + tw + PAD * 2 - r, tabBottom, r);
ctx.lineTo(tabX + r, tabBottom);
ctx.arcTo(tabX, tabBottom, tabX, tabBottom - r, r);
ctx.lineTo(tabX, tabY);
ctx.closePath();
ctx.fill();
// Tab text
ctx.fillStyle = spectrumBgColor();
ctx.textAlign = "left";
ctx.fillText(bwText, tabX + PAD, tabBottom - 4 * dpr);
ctx.restore();
}
}
}
@@ -5188,11 +5304,24 @@ if (overviewCanvas) {
function getBwEdgeHit(cssX, cssW, range) {
if (!lastFreqHz || !currentBandwidthHz || !lastSpectrumData) return null;
const halfBw = currentBandwidthHz / 2;
const xL = ((lastFreqHz - halfBw - range.visLoHz) / range.visSpanHz) * cssW;
const xR = ((lastFreqHz + halfBw - range.visLoHz) / range.visSpanHz) * cssW;
const HIT = 8;
if (Math.abs(cssX - xL) < HIT) return "left";
if (Math.abs(cssX - xR) < HIT) return "right";
let bestEdge = null;
let bestDist = Number.POSITIVE_INFINITY;
for (const centerHz of visibleBandwidthCenters(lastFreqHz)) {
const xL = ((centerHz - halfBw - range.visLoHz) / range.visSpanHz) * cssW;
const xR = ((centerHz + halfBw - range.visLoHz) / range.visSpanHz) * cssW;
const distL = Math.abs(cssX - xL);
const distR = Math.abs(cssX - xR);
if (distL < HIT && distL < bestDist) {
bestEdge = "left";
bestDist = distL;
}
if (distR < HIT && distR < bestDist) {
bestEdge = "right";
bestDist = distR;
}
}
if (bestEdge) return bestEdge;
return null;
}
@@ -78,6 +78,7 @@
<div class="overview-strip">
<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="aprs-bar-overlay" aria-live="polite" aria-label="Recent APRS frames"></div>
</div>
<div id="spectrum-bookmark-axis"></div>
@@ -348,6 +349,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="aprs">APRS</button>
<button class="sub-tab" data-subtab="cw">CW</button>
<button class="sub-tab" data-subtab="ft8">FT8</button>
@@ -355,6 +357,12 @@
<button class="sub-tab" data-subtab="rds">RDS</button>
</div>
<div id="subtab-overview" class="sub-tab-panel">
<div class="plugin-item">
<strong>AIS 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.
</div>
</div>
<div class="plugin-item">
<strong>APRS Decoder</strong>
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
@@ -411,6 +419,14 @@
</div>
<pre id="rds-raw" class="rds-raw">--</pre>
</div>
<div id="subtab-ais" class="sub-tab-panel" style="display:none;">
<div class="aprs-controls">
<button id="ais-clear-btn" type="button">Clear</button>
<input id="ais-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. MMSI, vessel, A)" />
<small id="ais-status" style="color:var(--text-muted);">Waiting for server decode</small>
</div>
<div id="ais-messages"></div>
</div>
<div id="subtab-aprs" class="sub-tab-panel" style="display:none;">
<div class="aprs-controls">
<button id="aprs-clear-btn" type="button">Clear</button>
@@ -469,6 +485,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-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>
@@ -504,6 +521,7 @@
</div>
</div>
<script src="/app.js"></script>
<script src="/ais.js"></script>
<script src="/aprs.js"></script>
<script src="/ft8.js"></script>
<script src="/wspr.js"></script>
@@ -0,0 +1,165 @@
// --- AIS Decoder Plugin (server-side decode) ---
const aisStatus = document.getElementById("ais-status");
const aisMessagesEl = document.getElementById("ais-messages");
const aisFilterInput = document.getElementById("ais-filter");
const aisClearBtn = document.getElementById("ais-clear-btn");
const aisBarOverlay = document.getElementById("ais-bar-overlay");
const AIS_MAX_MESSAGES = 200;
const AIS_BAR_WINDOW_MS = 15 * 60 * 1000;
let aisFilterText = "";
let aisMessageHistory = [];
function aisDisplayName(msg) {
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
}
function renderAisRow(msg) {
const row = document.createElement("div");
row.className = "ais-message";
const ts = msg._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const name = aisDisplayName(msg);
const channel = msg.channel ? `AIS-${msg.channel}` : "AIS";
const pos = msg.lat != null && msg.lon != null
? ` <a class="aprs-pos" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>`
: "";
const motion = [
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
].filter(Boolean).join(" · ");
row.dataset.filterText = [
name,
msg.mmsi,
msg.channel,
msg.vessel_name,
msg.callsign,
msg.destination,
]
.filter(Boolean)
.join(" ")
.toUpperCase();
row.innerHTML =
`<span class="ais-time">${ts}</span>` +
`<span class="ais-call">${escapeMapHtml(name)}</span> ` +
`<span class="aprs-time">[${escapeMapHtml(channel)}]</span> ` +
`<span>MMSI ${escapeMapHtml(String(msg.mmsi))}</span>` +
(motion ? ` <span>${escapeMapHtml(motion)}</span>` : "") +
pos;
applyAisFilterToRow(row);
return row;
}
function applyAisFilterToRow(row) {
if (!aisFilterText) {
row.style.display = "";
return;
}
const message = row.dataset.filterText || "";
row.style.display = message.includes(aisFilterText) ? "" : "none";
}
function applyAisFilterToAll() {
if (!aisMessagesEl) return;
const rows = aisMessagesEl.querySelectorAll(".ais-message");
rows.forEach((row) => applyAisFilterToRow(row));
}
function updateAisBar() {
if (!aisBarOverlay) return;
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
const messages = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
if (!isAis || messages.length === 0) {
aisBarOverlay.style.display = "none";
aisBarOverlay.innerHTML = "";
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>';
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
? `<button class="aprs-bar-pin" title="${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">📍</button>`
: "";
const name = `<span class="ais-call">${escapeMapHtml(aisDisplayName(msg))}</span>`;
const channel = msg.channel ? ` AIS-${escapeMapHtml(msg.channel)}` : "";
const details = [
`MMSI ${escapeMapHtml(String(msg.mmsi))}`,
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
].filter(Boolean).join(" · ");
html += `<div class="aprs-bar-frame">` +
`<div class="aprs-bar-frame-main">${ts}${pin}${name}${channel}: ${details}</div>` +
`</div>`;
}
aisBarOverlay.innerHTML = html;
aisBarOverlay.style.display = "flex";
}
window.updateAisBar = updateAisBar;
window.clearAisBar = function() {
window.resetAisHistoryView();
};
window.resetAisHistoryView = function() {
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
aisMessageHistory = [];
updateAisBar();
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais");
};
function addAisMessage(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" });
aisMessageHistory.unshift(msg);
if (aisMessageHistory.length > AIS_MAX_MESSAGES) aisMessageHistory.length = AIS_MAX_MESSAGES;
updateAisBar();
if (aisMessagesEl) {
const row = renderAisRow(msg);
aisMessagesEl.prepend(row);
while (aisMessagesEl.children.length > AIS_MAX_MESSAGES) {
aisMessagesEl.removeChild(aisMessagesEl.lastChild);
}
}
if (msg.lat != null && msg.lon != null && window.aisMapAddVessel) {
window.aisMapAddVessel(msg);
}
}
if (aisClearBtn) {
aisClearBtn.addEventListener("click", async () => {
try {
await postPath("/clear_ais_decode");
window.resetAisHistoryView();
} catch (e) {
console.error("AIS clear failed", e);
}
});
}
if (aisFilterInput) {
aisFilterInput.addEventListener("input", () => {
aisFilterText = aisFilterInput.value.trim().toUpperCase();
applyAisFilterToAll();
});
}
window.onServerAis = 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,
});
};
@@ -629,7 +629,8 @@ small { color: var(--text-muted); }
color: var(--text-muted);
background: color-mix(in srgb, var(--card-bg) 62%, transparent);
}
#aprs-bar-overlay {
#aprs-bar-overlay,
#ais-bar-overlay {
display: none;
position: absolute;
top: 50%;
@@ -1087,11 +1088,16 @@ small { color: var(--text-muted); }
.sub-tab:hover:not(.active) { color: var(--text); }
#aprs-map { min-height: 150px; border-radius: 6px; }
.aprs-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
#aprs-packets { 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,
#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; }
.aprs-packet { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--border); line-height: 1.4; }
.aprs-packet:last-child { border-bottom: none; }
.ais-message { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--border); line-height: 1.4; }
.ais-message:last-child { border-bottom: none; }
.aprs-call { color: var(--accent-green); font-weight: 600; }
.ais-call { color: var(--accent-red); font-weight: 600; }
.aprs-time { color: var(--text-muted); margin-right: 0.5rem; }
.ais-time { color: var(--text-muted); margin-right: 0.5rem; }
.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; }
@@ -258,6 +258,11 @@ pub async fn decode_events(
let history = {
let mut out = Vec::new();
out.extend(
crate::server::audio::snapshot_ais_history(context.get_ref())
.into_iter()
.map(trx_core::decode::DecodedMessage::Ais),
);
out.extend(
crate::server::audio::snapshot_aprs_history(context.get_ref())
.into_iter()
@@ -685,6 +690,14 @@ pub async fn clear_aprs_decode(
send_command(&rig_tx, RigCommand::ResetAprsDecoder).await
}
#[post("/clear_ais_decode")]
pub async fn clear_ais_decode(
context: web::Data<Arc<FrontendRuntimeContext>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_ais_history(context.get_ref());
Ok(HttpResponse::Ok().finish())
}
#[post("/clear_cw_decode")]
pub async fn clear_cw_decode(
context: web::Data<Arc<FrontendRuntimeContext>>,
@@ -938,6 +951,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(set_cw_tone)
.service(toggle_ft8_decode)
.service(toggle_wspr_decode)
.service(clear_ais_decode)
.service(clear_aprs_decode)
.service(clear_cw_decode)
.service(clear_ft8_decode)
@@ -954,6 +968,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(logo)
.service(style_css)
.service(app_js)
.service(ais_js)
.service(aprs_js)
.service(ft8_js)
.service(wspr_js)
@@ -1020,6 +1035,16 @@ async fn aprs_js() -> impl Responder {
.body(status::APRS_JS)
}
#[get("/ais.js")]
async fn ais_js() -> impl Responder {
HttpResponse::Ok()
.insert_header((
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
))
.body(status::AIS_JS)
}
#[get("/ft8.js")]
async fn ft8_js() -> impl Responder {
HttpResponse::Ok()
@@ -20,7 +20,7 @@ use bytes::Bytes;
use tokio::sync::broadcast;
use tracing::warn;
use trx_core::decode::{AprsPacket, CwEvent, DecodedMessage, Ft8Message, WsprMessage};
use trx_core::decode::{AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, WsprMessage};
use trx_frontend::FrontendRuntimeContext;
const HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
@@ -34,6 +34,24 @@ fn prune_aprs_history(history: &mut VecDeque<(Instant, AprsPacket)>) {
}
}
fn prune_ais_history(history: &mut VecDeque<(Instant, AisMessage)>) {
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
.lock()
.expect("ais history mutex poisoned");
history.push_back((Instant::now(), msg));
prune_ais_history(&mut history);
}
fn prune_cw_history(history: &mut VecDeque<(Instant, CwEvent)>) {
while let Some((ts, _)) = history.front() {
if ts.elapsed() <= HISTORY_RETENTION {
@@ -113,6 +131,22 @@ pub fn snapshot_aprs_history(context: &FrontendRuntimeContext) -> Vec<AprsPacket
.collect()
}
pub fn snapshot_ais_history(context: &FrontendRuntimeContext) -> Vec<AisMessage> {
let mut history = context
.ais_history
.lock()
.expect("ais history mutex poisoned");
prune_ais_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
@@ -148,6 +182,14 @@ pub fn clear_aprs_history(context: &FrontendRuntimeContext) {
history.clear();
}
pub fn clear_ais_history(context: &FrontendRuntimeContext) {
let mut history = context
.ais_history
.lock()
.expect("ais history mutex poisoned");
history.clear();
}
fn timestamp_ms_for_elapsed(elapsed: Duration) -> i64 {
let wall_clock = SystemTime::now()
.checked_sub(elapsed)
@@ -206,6 +248,7 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
loop {
match rx.recv().await {
Ok(msg) => match msg {
DecodedMessage::Ais(msg) => record_ais(&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,7 @@ 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 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");
pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js");