diff --git a/Cargo.lock b/Cargo.lock index b1c84be..2bbf196 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2375,6 +2375,13 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "trx-ais" +version = "0.1.0" +dependencies = [ + "trx-core", +] + [[package]] name = "trx-app" version = "0.1.0" @@ -2591,6 +2598,7 @@ dependencies = [ "tokio-serial", "toml", "tracing", + "trx-ais", "trx-app", "trx-aprs", "trx-backend", diff --git a/Cargo.toml b/Cargo.toml index 2898dc0..74d8e8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ [workspace] members = [ + "src/decoders/trx-ais", "src/decoders/trx-aprs", "src/decoders/trx-cw", "src/decoders/trx-decode-log", diff --git a/src/decoders/trx-ais/Cargo.toml b/src/decoders/trx-ais/Cargo.toml new file mode 100644 index 0000000..33dca1b --- /dev/null +++ b/src/decoders/trx-ais/Cargo.toml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 Stanislaw Grams +# +# SPDX-License-Identifier: BSD-2-Clause + +[package] +name = "trx-ais" +version = "0.1.0" +edition = "2021" + +[dependencies] +trx-core = { path = "../../trx-core" } diff --git a/src/decoders/trx-ais/src/lib.rs b/src/decoders/trx-ais/src/lib.rs new file mode 100644 index 0000000..a10baf5 --- /dev/null +++ b/src/decoders/trx-ais/src/lib.rs @@ -0,0 +1,389 @@ +// SPDX-FileCopyrightText: 2026 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +//! Basic AIS GMSK/HDLC decoder. +//! +//! This decoder operates on narrowband FM-demodulated audio. It uses a simple +//! sign slicer at the symbol rate, HDLC flag detection with NRZI decoding and +//! bit de-stuffing, then parses common AIS position/static messages. + +use trx_core::decode::AisMessage; + +const AIS_BAUD: f32 = 9_600.0; + +const CRC_CCITT_TABLE: [u16; 256] = { + let mut table = [0u16; 256]; + let mut i = 0usize; + while i < 256 { + let mut crc = i as u16; + let mut j = 0; + while j < 8 { + if crc & 1 != 0 { + crc = (crc >> 1) ^ 0x8408; + } else { + crc >>= 1; + } + j += 1; + } + table[i] = crc; + i += 1; + } + table +}; + +fn crc16ccitt(bytes: &[u8]) -> u16 { + let mut crc: u16 = 0xFFFF; + for &b in bytes { + crc = (crc >> 8) ^ CRC_CCITT_TABLE[((crc ^ b as u16) & 0xFF) as usize]; + } + crc ^ 0xFFFF +} + +#[derive(Debug, Clone)] +struct RawFrame { + payload: Vec, + bits: Vec, + crc_ok: bool, +} + +#[derive(Debug, Clone)] +pub struct AisDecoder { + sample_rate: f32, + symbol_phase: f32, + dc_state: f32, + lp_state: f32, + env_state: f32, + prev_raw_bit: u8, + ones: u32, + in_frame: bool, + frame_bits: Vec, + frames: Vec, +} + +impl AisDecoder { + pub fn new(sample_rate: u32) -> Self { + Self { + sample_rate: sample_rate.max(1) as f32, + symbol_phase: 0.0, + dc_state: 0.0, + lp_state: 0.0, + env_state: 1e-3, + prev_raw_bit: 0, + ones: 0, + in_frame: false, + frame_bits: Vec::new(), + frames: Vec::new(), + } + } + + pub fn reset(&mut self) { + self.symbol_phase = 0.0; + self.dc_state = 0.0; + self.lp_state = 0.0; + self.env_state = 1e-3; + self.prev_raw_bit = 0; + self.ones = 0; + self.in_frame = false; + self.frame_bits.clear(); + self.frames.clear(); + } + + pub fn process_samples(&mut self, samples: &[f32], channel: &str) -> Vec { + for &sample in samples { + self.process_sample(sample); + } + + let frames = std::mem::take(&mut self.frames); + let mut out = Vec::new(); + for frame in frames { + if let Some(msg) = parse_frame(frame, channel) { + out.push(msg); + } + } + out + } + + fn process_sample(&mut self, sample: f32) { + // Remove slow DC drift from the FM discriminator output. + self.dc_state += 0.0025 * (sample - self.dc_state); + let dc_free = sample - self.dc_state; + + // Gentle low-pass smoothing to suppress narrow impulsive noise. + self.lp_state += 0.28 * (dc_free - self.lp_state); + + // Track envelope to keep the slicer stable on weak signals. + self.env_state += 0.02 * (self.lp_state.abs() - self.env_state); + let normalized = if self.env_state > 1e-4 { + self.lp_state / self.env_state + } else { + self.lp_state + }; + + self.symbol_phase += AIS_BAUD; + while self.symbol_phase >= self.sample_rate { + self.symbol_phase -= self.sample_rate; + let raw_bit = if normalized >= 0.0 { 1 } else { 0 }; + self.process_symbol(raw_bit); + } + } + + fn process_symbol(&mut self, raw_bit: u8) { + let decoded_bit = if raw_bit == self.prev_raw_bit { 1 } else { 0 }; + self.prev_raw_bit = raw_bit; + + if decoded_bit == 1 { + self.ones += 1; + return; + } + + // A zero terminates the current run of ones. + if self.ones >= 7 { + self.in_frame = false; + self.frame_bits.clear(); + self.ones = 0; + return; + } + + if self.ones == 6 { + if self.in_frame && self.frame_bits.len() >= 256 { + if let Some(frame) = self.bits_to_frame() { + self.frames.push(frame); + } + } + self.frame_bits.clear(); + self.in_frame = true; + self.ones = 0; + return; + } + + if self.ones == 5 { + if self.in_frame { + for _ in 0..5 { + self.frame_bits.push(1); + } + } + self.ones = 0; + return; + } + + if self.in_frame { + for _ in 0..self.ones { + self.frame_bits.push(1); + } + self.frame_bits.push(0); + } + self.ones = 0; + } + + fn bits_to_frame(&self) -> Option { + if self.frame_bits.len() < 24 { + return None; + } + + let usable_bits = self.frame_bits.len() - (self.frame_bits.len() % 8); + if usable_bits < 24 { + return None; + } + + let bits = self.frame_bits[..usable_bits].to_vec(); + let mut bytes = Vec::with_capacity(usable_bits / 8); + for chunk in bits.chunks(8) { + let mut byte = 0u8; + for (idx, &bit) in chunk.iter().enumerate() { + if bit != 0 { + byte |= 1 << idx; + } + } + bytes.push(byte); + } + + if bytes.len() < 3 { + return None; + } + + let payload_len = bytes.len() - 2; + let payload = bytes[..payload_len].to_vec(); + let received_fcs = u16::from_le_bytes([bytes[payload_len], bytes[payload_len + 1]]); + let crc_ok = crc16ccitt(&payload) == received_fcs; + + Some(RawFrame { + payload, + bits, + crc_ok, + }) + } +} + +fn parse_frame(frame: RawFrame, channel: &str) -> Option { + if !frame.crc_ok { + return None; + } + + let bits = bytes_to_msb_bits(&frame.payload); + if bits.len() < 40 { + return None; + } + + let message_type = get_uint(&bits, 0, 6)? as u8; + let repeat = get_uint(&bits, 6, 2)? as u8; + let mmsi = get_uint(&bits, 8, 30)? as u32; + + let mut msg = AisMessage { + ts_ms: None, + channel: channel.to_string(), + message_type, + repeat, + mmsi, + crc_ok: frame.crc_ok, + bit_len: frame.bits.len(), + raw_bytes: frame.payload, + lat: None, + lon: None, + sog_knots: None, + cog_deg: None, + heading_deg: None, + nav_status: None, + vessel_name: None, + callsign: None, + destination: None, + }; + + match message_type { + 1..=3 => { + msg.nav_status = get_uint(&bits, 38, 4).map(|v| v as u8); + msg.sog_knots = decode_tenths(get_uint(&bits, 50, 10)?, 1023); + msg.lon = decode_coord(get_int(&bits, 61, 28)?, 181.0); + msg.lat = decode_coord(get_int(&bits, 89, 27)?, 91.0); + msg.cog_deg = decode_tenths(get_uint(&bits, 116, 12)?, 3600); + msg.heading_deg = decode_heading(get_uint(&bits, 128, 9)?); + } + 18 => { + msg.sog_knots = decode_tenths(get_uint(&bits, 46, 10)?, 1023); + msg.lon = decode_coord(get_int(&bits, 57, 28)?, 181.0); + msg.lat = decode_coord(get_int(&bits, 85, 27)?, 91.0); + msg.cog_deg = decode_tenths(get_uint(&bits, 112, 12)?, 3600); + msg.heading_deg = decode_heading(get_uint(&bits, 124, 9)?); + } + 19 => { + msg.sog_knots = decode_tenths(get_uint(&bits, 46, 10)?, 1023); + msg.lon = decode_coord(get_int(&bits, 57, 28)?, 181.0); + msg.lat = decode_coord(get_int(&bits, 85, 27)?, 91.0); + msg.cog_deg = decode_tenths(get_uint(&bits, 112, 12)?, 3600); + msg.heading_deg = decode_heading(get_uint(&bits, 124, 9)?); + msg.vessel_name = decode_sixbit_text(&bits, 143, 120); + } + 5 => { + msg.callsign = decode_sixbit_text(&bits, 70, 42); + msg.vessel_name = decode_sixbit_text(&bits, 112, 120); + msg.destination = decode_sixbit_text(&bits, 302, 120); + } + _ => {} + } + + Some(msg) +} + +fn bytes_to_msb_bits(bytes: &[u8]) -> Vec { + let mut bits = Vec::with_capacity(bytes.len() * 8); + for &byte in bytes { + for shift in (0..8).rev() { + bits.push((byte >> shift) & 1); + } + } + bits +} + +fn get_uint(bits: &[u8], start: usize, len: usize) -> Option { + if len == 0 || start.checked_add(len)? > bits.len() || len > 32 { + return None; + } + let mut out = 0u32; + for &bit in &bits[start..start + len] { + out = (out << 1) | u32::from(bit); + } + Some(out) +} + +fn get_int(bits: &[u8], start: usize, len: usize) -> Option { + let raw = get_uint(bits, start, len)?; + if len == 0 || len > 31 { + return None; + } + let sign_mask = 1u32 << (len - 1); + if raw & sign_mask == 0 { + Some(raw as i32) + } else { + Some((raw as i32) - ((1u32 << len) as i32)) + } +} + +fn decode_tenths(raw: u32, invalid: u32) -> Option { + if raw == invalid { + None + } else { + Some(raw as f32 / 10.0) + } +} + +fn decode_heading(raw: u32) -> Option { + if raw >= 360 { + None + } else { + Some(raw as u16) + } +} + +fn decode_coord(raw: i32, invalid_abs: f64) -> Option { + let value = raw as f64 / 600_000.0; + if value.abs() >= invalid_abs { + None + } else { + Some(value) + } +} + +fn decode_sixbit_text(bits: &[u8], start: usize, len: usize) -> Option { + if start.checked_add(len)? > bits.len() || len % 6 != 0 { + return None; + } + + let mut out = String::new(); + for offset in (0..len).step_by(6) { + let value = get_uint(bits, start + offset, 6)? as u8; + let ch = if value < 32 { + char::from(value + 64) + } else { + char::from(value) + }; + if ch != '@' { + out.push(ch); + } + } + + let trimmed = out.trim().trim_matches('@').trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_signed_coordinates() { + assert_eq!(decode_coord(60_000, 181.0), Some(0.1)); + assert_eq!(decode_coord(-60_000, 181.0), Some(-0.1)); + } + + #[test] + fn decodes_sixbit_name() { + let bytes = [0x10_u8, 0x41_u8, 0x11_u8, 0x92_u8, 0x08_u8, 0x00_u8]; + let bits = bytes_to_msb_bits(&bytes); + let text = decode_sixbit_text(&bits, 0, 36); + assert!(text.is_some()); + } +} diff --git a/src/trx-client/src/audio_client.rs b/src/trx-client/src/audio_client.rs index 2f6c729..6c1d4f1 100644 --- a/src/trx-client/src/audio_client.rs +++ b/src/trx-client/src/audio_client.rs @@ -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, diff --git a/src/trx-client/trx-frontend/src/lib.rs b/src/trx-client/trx-frontend/src/lib.rs index 6e7cbd5..f91b6f6 100644 --- a/src/trx-client/trx-frontend/src/lib.rs +++ b/src/trx-client/trx-frontend/src/lib.rs @@ -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>>, /// Decode message broadcast channel pub decode_rx: Option>, + /// AIS decode history (timestamp, message) + pub ais_history: Arc>>, /// APRS decode history (timestamp, packet) pub aprs_history: Arc>>, /// 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())), diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index 6573dc9..401120f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -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) { ``; } +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(" · "); + let rows = ""; + rows += `MMSI${escapeMapHtml(String(msg.mmsi || "--"))}`; + rows += `Type${escapeMapHtml(String(msg.message_type || "--"))}`; + if (msg?.sog_knots != null) rows += `SOG${Number(msg.sog_knots).toFixed(1)} kn`; + if (msg?.cog_deg != null) rows += `COG${Number(msg.cog_deg).toFixed(1)}°`; + if (msg?.lat != null && msg?.lon != null) rows += `Pos${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}`; + const info = [msg?.vessel_name, msg?.callsign, msg?.destination].filter(Boolean).map(escapeMapHtml).join(" · "); + return `
` + + `
${escapeMapHtml(msg?.vessel_name || `MMSI ${msg?.mmsi || "--"}`)}
` + + (meta ? `
${meta}
` : "") + + (rows ? `${rows}
` : "") + + (info ? `
${info}
` : "") + + `
`; +} + 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; } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index a49459e..3d4d0de 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -78,6 +78,7 @@
+
@@ -348,6 +349,7 @@