[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:
Generated
+8
@@ -2607,9 +2607,17 @@ dependencies = [
|
||||
"trx-decode-log",
|
||||
"trx-ft8",
|
||||
"trx-protocol",
|
||||
"trx-vdes",
|
||||
"trx-wspr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-vdes"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"trx-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-wspr"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -10,6 +10,7 @@ members = [
|
||||
"src/decoders/trx-decode-log",
|
||||
"src/decoders/trx-ft8",
|
||||
"src/decoders/trx-rds",
|
||||
"src/decoders/trx-vdes",
|
||||
"src/decoders/trx-wspr",
|
||||
"src/trx-core",
|
||||
"src/trx-protocol",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-vdes"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../trx-core" }
|
||||
@@ -0,0 +1,506 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
//! Basic VDES 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 the same position/static fields used by the
|
||||
//! current AIS decoder path.
|
||||
|
||||
use trx_core::decode::VdesMessage;
|
||||
|
||||
const VDES_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<u8>,
|
||||
bits: Vec<u8>,
|
||||
crc_ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VdesDecoder {
|
||||
sample_rate: f32,
|
||||
samples_per_symbol: f32,
|
||||
sample_clock: f32,
|
||||
dc_state: f32,
|
||||
lp_fast: f32,
|
||||
lp_slow: f32,
|
||||
env_state: f32,
|
||||
polarity: i8,
|
||||
samples_since_transition: u32,
|
||||
clock_locked: bool,
|
||||
prev_raw_bit: u8,
|
||||
ones: u32,
|
||||
in_frame: bool,
|
||||
frame_bits: Vec<u8>,
|
||||
frames: Vec<RawFrame>,
|
||||
}
|
||||
|
||||
impl VdesDecoder {
|
||||
pub fn new(sample_rate: u32) -> Self {
|
||||
let sample_rate = sample_rate.max(1) as f32;
|
||||
Self {
|
||||
sample_rate,
|
||||
samples_per_symbol: sample_rate / VDES_BAUD,
|
||||
sample_clock: 0.0,
|
||||
dc_state: 0.0,
|
||||
lp_fast: 0.0,
|
||||
lp_slow: 0.0,
|
||||
env_state: 1e-3,
|
||||
polarity: 1,
|
||||
samples_since_transition: 0,
|
||||
clock_locked: false,
|
||||
prev_raw_bit: 0,
|
||||
ones: 0,
|
||||
in_frame: false,
|
||||
frame_bits: Vec::new(),
|
||||
frames: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.samples_per_symbol = self.sample_rate / VDES_BAUD;
|
||||
self.sample_clock = 0.0;
|
||||
self.dc_state = 0.0;
|
||||
self.lp_fast = 0.0;
|
||||
self.lp_slow = 0.0;
|
||||
self.env_state = 1e-3;
|
||||
self.polarity = 1;
|
||||
self.samples_since_transition = 0;
|
||||
self.clock_locked = false;
|
||||
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<VdesMessage> {
|
||||
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;
|
||||
|
||||
// A simple band-pass-ish response makes GMSK symbol transitions stand out
|
||||
// without needing a full matched filter.
|
||||
self.lp_fast += 0.32 * (dc_free - self.lp_fast);
|
||||
self.lp_slow += 0.045 * (dc_free - self.lp_slow);
|
||||
let shaped = self.lp_fast - self.lp_slow;
|
||||
|
||||
// Track envelope to keep the slicer stable on weak signals.
|
||||
self.env_state += 0.015 * (shaped.abs() - self.env_state);
|
||||
let normalized = if self.env_state > 1e-4 {
|
||||
shaped / self.env_state
|
||||
} else {
|
||||
shaped
|
||||
};
|
||||
|
||||
let threshold = 0.12;
|
||||
let next_polarity = if normalized > threshold {
|
||||
1
|
||||
} else if normalized < -threshold {
|
||||
-1
|
||||
} else {
|
||||
self.polarity
|
||||
};
|
||||
|
||||
self.samples_since_transition = self.samples_since_transition.saturating_add(1);
|
||||
if next_polarity != self.polarity {
|
||||
self.observe_transition();
|
||||
self.polarity = next_polarity;
|
||||
}
|
||||
|
||||
if !self.clock_locked {
|
||||
return;
|
||||
}
|
||||
|
||||
self.sample_clock += 1.0;
|
||||
while self.sample_clock >= self.samples_per_symbol {
|
||||
self.sample_clock -= self.samples_per_symbol;
|
||||
let raw_bit = if self.polarity >= 0 { 1 } else { 0 };
|
||||
self.process_symbol(raw_bit);
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_transition(&mut self) {
|
||||
let interval = self.samples_since_transition.max(1) as f32;
|
||||
self.samples_since_transition = 0;
|
||||
|
||||
let nominal = (self.sample_rate / VDES_BAUD).max(1.0);
|
||||
let symbols = (interval / nominal).round().clamp(1.0, 8.0);
|
||||
let estimate = (interval / symbols).clamp(nominal * 0.75, nominal * 1.25);
|
||||
self.samples_per_symbol += 0.18 * (estimate - self.samples_per_symbol);
|
||||
self.sample_clock = self.samples_per_symbol * 0.5;
|
||||
self.clock_locked = true;
|
||||
}
|
||||
|
||||
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 {
|
||||
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<RawFrame> {
|
||||
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<VdesMessage> {
|
||||
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 = VdesMessage {
|
||||
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<u8> {
|
||||
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<u32> {
|
||||
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<i32> {
|
||||
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<f32> {
|
||||
if raw == invalid {
|
||||
None
|
||||
} else {
|
||||
Some(raw as f32 / 10.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_heading(raw: u32) -> Option<u16> {
|
||||
if raw >= 360 {
|
||||
None
|
||||
} else {
|
||||
Some(raw as u16)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_coord(raw: i32, invalid_abs: f64) -> Option<f64> {
|
||||
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<String> {
|
||||
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::*;
|
||||
|
||||
fn payload_with_crc(payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = payload.to_vec();
|
||||
out.extend_from_slice(&crc16ccitt(payload).to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn bytes_to_lsb_bits(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut bits = Vec::with_capacity(bytes.len() * 8);
|
||||
for &byte in bytes {
|
||||
for shift in 0..8 {
|
||||
bits.push((byte >> shift) & 1);
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
fn bitstuff(bits: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(bits.len() + bits.len() / 5);
|
||||
let mut ones = 0u32;
|
||||
for &bit in bits {
|
||||
out.push(bit);
|
||||
if bit == 1 {
|
||||
ones += 1;
|
||||
if ones == 5 {
|
||||
out.push(0);
|
||||
ones = 0;
|
||||
}
|
||||
} else {
|
||||
ones = 0;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn nrzi_encode(bits: &[u8]) -> Vec<u8> {
|
||||
let mut state = 0u8;
|
||||
let mut out = Vec::with_capacity(bits.len());
|
||||
for &bit in bits {
|
||||
if bit == 0 {
|
||||
state ^= 1;
|
||||
}
|
||||
out.push(state);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_hdlc_frame_from_raw_nrzi_bits() {
|
||||
let payload = [0x11_u8, 0x22_u8, 0x7E_u8, 0x00_u8, 0xF0_u8];
|
||||
let frame_bytes = payload_with_crc(&payload);
|
||||
let mut hdlc_bits = bytes_to_lsb_bits(&[0x7E]);
|
||||
hdlc_bits.extend(bitstuff(&bytes_to_lsb_bits(&frame_bytes)));
|
||||
hdlc_bits.extend(bytes_to_lsb_bits(&[0x7E]));
|
||||
let raw_bits = nrzi_encode(&hdlc_bits);
|
||||
|
||||
let mut decoder = VdesDecoder::new(48_000);
|
||||
for raw_bit in raw_bits {
|
||||
decoder.process_symbol(raw_bit);
|
||||
}
|
||||
|
||||
assert_eq!(decoder.frames.len(), 1);
|
||||
let frame = &decoder.frames[0];
|
||||
assert!(frame.crc_ok);
|
||||
assert_eq!(frame.payload, payload);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -16,6 +16,7 @@ pub const AUDIO_MSG_CW_DECODE: u8 = 0x04;
|
||||
pub const AUDIO_MSG_FT8_DECODE: u8 = 0x05;
|
||||
pub const AUDIO_MSG_WSPR_DECODE: u8 = 0x06;
|
||||
pub const AUDIO_MSG_AIS_DECODE: u8 = 0x07;
|
||||
pub const AUDIO_MSG_VDES_DECODE: u8 = 0x08;
|
||||
|
||||
/// Maximum payload size (1 MB) to reject bogus frames early.
|
||||
const MAX_PAYLOAD_SIZE: u32 = 1_048_576;
|
||||
|
||||
@@ -12,6 +12,8 @@ use serde::{Deserialize, Serialize};
|
||||
pub enum DecodedMessage {
|
||||
#[serde(rename = "ais")]
|
||||
Ais(AisMessage),
|
||||
#[serde(rename = "vdes")]
|
||||
Vdes(VdesMessage),
|
||||
#[serde(rename = "aprs")]
|
||||
Aprs(AprsPacket),
|
||||
#[serde(rename = "cw")]
|
||||
@@ -53,6 +55,37 @@ pub struct AisMessage {
|
||||
pub destination: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VdesMessage {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ts_ms: Option<i64>,
|
||||
pub channel: String,
|
||||
pub message_type: u8,
|
||||
pub repeat: u8,
|
||||
pub mmsi: u32,
|
||||
pub crc_ok: bool,
|
||||
pub bit_len: usize,
|
||||
pub raw_bytes: Vec<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lat: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lon: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sog_knots: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cog_deg: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub heading_deg: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nav_status: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vessel_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub callsign: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub destination: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AprsPacket {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -71,6 +71,7 @@ pub enum RigMode {
|
||||
WFM,
|
||||
FM,
|
||||
AIS,
|
||||
VDES,
|
||||
DIG,
|
||||
PKT,
|
||||
Other(String),
|
||||
|
||||
@@ -11,7 +11,7 @@ use trx_core::rig::state::RigMode;
|
||||
|
||||
/// Parse a mode string into a RigMode.
|
||||
///
|
||||
/// Handles LSB, USB, CW, CWR, AM, FM, WFM, AIS, DIG, DIGI, PKT, PACKET.
|
||||
/// Handles LSB, USB, CW, CWR, AM, FM, WFM, AIS, VDES, DIG, DIGI, PKT, PACKET.
|
||||
/// Falls back to Other(string) for unknown modes.
|
||||
pub fn parse_mode(s: &str) -> RigMode {
|
||||
match s.to_uppercase().as_str() {
|
||||
@@ -23,6 +23,7 @@ pub fn parse_mode(s: &str) -> RigMode {
|
||||
"FM" => RigMode::FM,
|
||||
"WFM" => RigMode::WFM,
|
||||
"AIS" => RigMode::AIS,
|
||||
"VDES" => RigMode::VDES,
|
||||
"DIG" | "DIGI" => RigMode::DIG,
|
||||
"PKT" | "PACKET" => RigMode::PKT,
|
||||
other => RigMode::Other(other.to_string()),
|
||||
@@ -43,6 +44,7 @@ pub fn mode_to_string(mode: &RigMode) -> String {
|
||||
RigMode::FM => "FM".to_string(),
|
||||
RigMode::WFM => "WFM".to_string(),
|
||||
RigMode::AIS => "AIS".to_string(),
|
||||
RigMode::VDES => "VDES".to_string(),
|
||||
RigMode::DIG => "DIG".to_string(),
|
||||
RigMode::PKT => "PKT".to_string(),
|
||||
RigMode::Other(s) => s.clone(),
|
||||
@@ -81,6 +83,7 @@ mod tests {
|
||||
assert_eq!(parse_mode("FM"), RigMode::FM);
|
||||
assert_eq!(parse_mode("WFM"), RigMode::WFM);
|
||||
assert_eq!(parse_mode("AIS"), RigMode::AIS);
|
||||
assert_eq!(parse_mode("VDES"), RigMode::VDES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -126,6 +129,7 @@ mod tests {
|
||||
assert_eq!(mode_to_string(&RigMode::FM), "FM");
|
||||
assert_eq!(mode_to_string(&RigMode::WFM), "WFM");
|
||||
assert_eq!(mode_to_string(&RigMode::AIS), "AIS");
|
||||
assert_eq!(mode_to_string(&RigMode::VDES), "VDES");
|
||||
assert_eq!(mode_to_string(&RigMode::DIG), "DIG");
|
||||
assert_eq!(mode_to_string(&RigMode::PKT), "PKT");
|
||||
}
|
||||
@@ -146,6 +150,7 @@ mod tests {
|
||||
RigMode::FM,
|
||||
RigMode::WFM,
|
||||
RigMode::AIS,
|
||||
RigMode::VDES,
|
||||
RigMode::DIG,
|
||||
RigMode::PKT,
|
||||
];
|
||||
|
||||
@@ -28,6 +28,7 @@ opus = "0.3"
|
||||
trx-app = { path = "../trx-app" }
|
||||
trx-backend = { path = "trx-backend", features = ["soapysdr"] }
|
||||
trx-ais = { path = "../decoders/trx-ais" }
|
||||
trx-vdes = { path = "../decoders/trx-vdes" }
|
||||
trx-core = { path = "../trx-core" }
|
||||
trx-aprs = { path = "../decoders/trx-aprs" }
|
||||
trx-cw = { path = "../decoders/trx-cw" }
|
||||
|
||||
+127
-2
@@ -20,12 +20,15 @@ use trx_aprs::AprsDecoder;
|
||||
use trx_core::audio::{
|
||||
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,
|
||||
AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WSPR_DECODE,
|
||||
};
|
||||
use trx_core::decode::{
|
||||
AisMessage, AprsPacket, DecodedMessage, Ft8Message, VdesMessage, WsprMessage,
|
||||
};
|
||||
use trx_core::decode::{AisMessage, AprsPacket, DecodedMessage, Ft8Message, WsprMessage};
|
||||
use trx_core::rig::state::{RigMode, RigState};
|
||||
use trx_cw::CwDecoder;
|
||||
use trx_ft8::Ft8Decoder;
|
||||
use trx_vdes::VdesDecoder;
|
||||
use trx_wspr::WsprDecoder;
|
||||
|
||||
use crate::config::AudioConfig;
|
||||
@@ -33,6 +36,7 @@ use trx_decode_log::DecoderLoggers;
|
||||
|
||||
const APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const AIS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const VDES_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const FT8_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const WSPR_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const FT8_SAMPLE_RATE: u32 = 12_000;
|
||||
@@ -127,6 +131,7 @@ fn classify_stream_error(err: &str) -> &'static str {
|
||||
/// `Arc<DecoderHistories>` into every decoder task and into the audio listener.
|
||||
pub struct DecoderHistories {
|
||||
ais: Mutex<VecDeque<(Instant, AisMessage)>>,
|
||||
vdes: Mutex<VecDeque<(Instant, VdesMessage)>>,
|
||||
aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
|
||||
ft8: Mutex<VecDeque<(Instant, Ft8Message)>>,
|
||||
wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
|
||||
@@ -136,6 +141,7 @@ impl DecoderHistories {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
ais: Mutex::new(VecDeque::new()),
|
||||
vdes: Mutex::new(VecDeque::new()),
|
||||
aprs: Mutex::new(VecDeque::new()),
|
||||
ft8: Mutex::new(VecDeque::new()),
|
||||
wspr: Mutex::new(VecDeque::new()),
|
||||
@@ -167,6 +173,31 @@ impl DecoderHistories {
|
||||
h.iter().map(|(_, msg)| msg.clone()).collect()
|
||||
}
|
||||
|
||||
// --- VDES ---
|
||||
|
||||
fn prune_vdes(history: &mut VecDeque<(Instant, VdesMessage)>) {
|
||||
let cutoff = Instant::now() - VDES_HISTORY_RETENTION;
|
||||
while let Some((ts, _)) = history.front() {
|
||||
if *ts < cutoff {
|
||||
history.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_vdes_message(&self, msg: VdesMessage) {
|
||||
let mut h = self.vdes.lock().expect("vdes history mutex poisoned");
|
||||
h.push_back((Instant::now(), msg));
|
||||
Self::prune_vdes(&mut h);
|
||||
}
|
||||
|
||||
pub fn snapshot_vdes_history(&self) -> Vec<VdesMessage> {
|
||||
let mut h = self.vdes.lock().expect("vdes history mutex poisoned");
|
||||
Self::prune_vdes(&mut h);
|
||||
h.iter().map(|(_, msg)| msg.clone()).collect()
|
||||
}
|
||||
|
||||
// --- APRS ---
|
||||
|
||||
fn prune_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
|
||||
@@ -944,6 +975,91 @@ pub async fn run_ais_decoder(
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the VDES decoder task. Only processes PCM when rig mode is VDES.
|
||||
pub async fn run_vdes_decoder(
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
mut pcm_a_rx: broadcast::Receiver<Vec<f32>>,
|
||||
mut pcm_b_rx: broadcast::Receiver<Vec<f32>>,
|
||||
mut state_rx: watch::Receiver<RigState>,
|
||||
decode_tx: broadcast::Sender<DecodedMessage>,
|
||||
histories: Arc<DecoderHistories>,
|
||||
) {
|
||||
info!("VDES decoder started ({}Hz, {} ch)", sample_rate, channels);
|
||||
let mut decoder_a = VdesDecoder::new(sample_rate);
|
||||
let mut decoder_b = VdesDecoder::new(sample_rate);
|
||||
let mut was_active = false;
|
||||
let mut active = matches!(state_rx.borrow().status.mode, RigMode::VDES);
|
||||
|
||||
loop {
|
||||
if !active {
|
||||
match state_rx.changed().await {
|
||||
Ok(()) => {
|
||||
let state = state_rx.borrow();
|
||||
active = matches!(state.status.mode, RigMode::VDES);
|
||||
if active {
|
||||
pcm_a_rx = pcm_a_rx.resubscribe();
|
||||
pcm_b_rx = pcm_b_rx.resubscribe();
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
recv = pcm_a_rx.recv() => {
|
||||
match recv {
|
||||
Ok(frame) => {
|
||||
was_active = true;
|
||||
for msg in decoder_a.process_samples(&downmix_if_needed(frame, channels), "A") {
|
||||
histories.record_vdes_message(msg.clone());
|
||||
let _ = decode_tx.send(DecodedMessage::Vdes(msg));
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("VDES decoder A: dropped {} PCM frames", n);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
recv = pcm_b_rx.recv() => {
|
||||
match recv {
|
||||
Ok(frame) => {
|
||||
was_active = true;
|
||||
for msg in decoder_b.process_samples(&downmix_if_needed(frame, channels), "B") {
|
||||
histories.record_vdes_message(msg.clone());
|
||||
let _ = decode_tx.send(DecodedMessage::Vdes(msg));
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("VDES decoder B: dropped {} PCM frames", n);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
changed = state_rx.changed() => {
|
||||
match changed {
|
||||
Ok(()) => {
|
||||
let state = state_rx.borrow();
|
||||
active = matches!(state.status.mode, RigMode::VDES);
|
||||
if !active && was_active {
|
||||
decoder_a.reset();
|
||||
decoder_b.reset();
|
||||
was_active = false;
|
||||
}
|
||||
if active {
|
||||
pcm_a_rx = pcm_a_rx.resubscribe();
|
||||
pcm_b_rx = pcm_b_rx.resubscribe();
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the CW decoder task. Only processes PCM when rig mode is CW or CWR.
|
||||
pub async fn run_cw_decoder(
|
||||
sample_rate: u32,
|
||||
@@ -1468,6 +1584,14 @@ async fn handle_audio_client(
|
||||
write_audio_msg(&mut writer, msg_type, &json).await?;
|
||||
}
|
||||
}
|
||||
let history = histories.snapshot_vdes_history();
|
||||
for msg in history {
|
||||
let msg = DecodedMessage::Vdes(msg);
|
||||
let msg_type = AUDIO_MSG_VDES_DECODE;
|
||||
if let Ok(json) = serde_json::to_vec(&msg) {
|
||||
write_audio_msg(&mut writer, msg_type, &json).await?;
|
||||
}
|
||||
}
|
||||
// Send APRS history to newly connected client.
|
||||
let history = histories.snapshot_aprs_history();
|
||||
for pkt in history {
|
||||
@@ -1522,6 +1646,7 @@ async fn handle_audio_client(
|
||||
Ok(msg) => {
|
||||
let msg_type = match &msg {
|
||||
DecodedMessage::Ais(_) => AUDIO_MSG_AIS_DECODE,
|
||||
DecodedMessage::Vdes(_) => AUDIO_MSG_VDES_DECODE,
|
||||
DecodedMessage::Aprs(_) => AUDIO_MSG_APRS_DECODE,
|
||||
DecodedMessage::Cw(_) => AUDIO_MSG_CW_DECODE,
|
||||
DecodedMessage::Ft8(_) => AUDIO_MSG_FT8_DECODE,
|
||||
|
||||
@@ -245,6 +245,7 @@ fn default_audio_bandwidth_for_mode(mode: &trx_core::rig::state::RigMode) -> u32
|
||||
RigMode::FM => 12_500,
|
||||
RigMode::WFM => 180_000,
|
||||
RigMode::AIS => 25_000,
|
||||
RigMode::VDES => 25_000,
|
||||
RigMode::Other(_) => 3_000,
|
||||
}
|
||||
}
|
||||
@@ -266,6 +267,7 @@ fn parse_rig_mode(
|
||||
"WFM" => RigMode::WFM,
|
||||
"FM" => RigMode::FM,
|
||||
"AIS" => RigMode::AIS,
|
||||
"VDES" => RigMode::VDES,
|
||||
"DIG" => RigMode::DIG,
|
||||
"PKT" => RigMode::PKT,
|
||||
_ => initial_mode.clone(),
|
||||
@@ -613,6 +615,8 @@ fn spawn_rig_audio_stack(
|
||||
}));
|
||||
|
||||
if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) = sdr_ais_pcm_rx {
|
||||
let vdes_a_pcm_rx = ais_a_pcm_rx.resubscribe();
|
||||
let vdes_b_pcm_rx = ais_b_pcm_rx.resubscribe();
|
||||
let ais_state_rx = state_rx.clone();
|
||||
let ais_decode_tx = decode_tx.clone();
|
||||
let ais_shutdown_rx = shutdown_rx.clone();
|
||||
@@ -625,6 +629,19 @@ fn spawn_rig_audio_stack(
|
||||
_ = wait_for_shutdown(ais_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
|
||||
let vdes_state_rx = state_rx.clone();
|
||||
let vdes_decode_tx = decode_tx.clone();
|
||||
let vdes_shutdown_rx = shutdown_rx.clone();
|
||||
let vdes_histories = histories.clone();
|
||||
let vdes_sr = rig_cfg.audio.sample_rate;
|
||||
let vdes_ch = rig_cfg.audio.channels as u16;
|
||||
handles.push(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = audio::run_vdes_decoder(vdes_sr, vdes_ch, vdes_a_pcm_rx, vdes_b_pcm_rx, vdes_state_rx, vdes_decode_tx, vdes_histories) => {}
|
||||
_ = wait_for_shutdown(vdes_shutdown_rx) => {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Spawn CW decoder task (no histories needed — CW has no persistent history)
|
||||
|
||||
@@ -773,7 +773,7 @@ fn map_signal_strength(mode: &RigMode, raw: u8) -> i32 {
|
||||
// FT-817 returns 0-15 for signal strength
|
||||
// Map to approximate dBm / S-units
|
||||
match mode {
|
||||
RigMode::FM | RigMode::WFM | RigMode::AIS => -120 + (raw as i32 * 6),
|
||||
RigMode::FM | RigMode::WFM | RigMode::AIS | RigMode::VDES => -120 + (raw as i32 * 6),
|
||||
_ => -127 + (raw as i32 * 6),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ impl DummyRig {
|
||||
RigMode::FM,
|
||||
RigMode::WFM,
|
||||
RigMode::AIS,
|
||||
RigMode::VDES,
|
||||
RigMode::DIG,
|
||||
RigMode::PKT,
|
||||
],
|
||||
|
||||
@@ -165,6 +165,7 @@ impl Ft450d {
|
||||
RigMode::WFM,
|
||||
RigMode::FM,
|
||||
RigMode::AIS,
|
||||
RigMode::VDES,
|
||||
RigMode::DIG,
|
||||
RigMode::PKT,
|
||||
],
|
||||
@@ -512,7 +513,7 @@ fn encode_mode(mode: &RigMode) -> DynResult<char> {
|
||||
RigMode::USB => Ok('2'),
|
||||
RigMode::CW => Ok('3'),
|
||||
RigMode::FM => Ok('4'),
|
||||
RigMode::AIS => Ok('4'),
|
||||
RigMode::AIS | RigMode::VDES => Ok('4'),
|
||||
RigMode::AM => Ok('5'),
|
||||
RigMode::DIG => Ok('6'),
|
||||
RigMode::CWR => Ok('7'),
|
||||
|
||||
@@ -196,6 +196,7 @@ impl Ft817 {
|
||||
RigMode::WFM,
|
||||
RigMode::FM,
|
||||
RigMode::AIS,
|
||||
RigMode::VDES,
|
||||
RigMode::DIG,
|
||||
RigMode::PKT,
|
||||
],
|
||||
@@ -589,7 +590,7 @@ fn encode_mode(mode: &RigMode) -> u8 {
|
||||
RigMode::AM => 0x04,
|
||||
RigMode::WFM => 0x06,
|
||||
RigMode::FM => 0x08,
|
||||
RigMode::AIS => 0x08,
|
||||
RigMode::AIS | RigMode::VDES => 0x08,
|
||||
RigMode::DIG => 0x0A,
|
||||
RigMode::PKT => 0x0C,
|
||||
RigMode::Other(_) => 0x00,
|
||||
|
||||
@@ -156,7 +156,7 @@ impl Demodulator {
|
||||
RigMode::AM => Self::Am,
|
||||
RigMode::FM => Self::Fm,
|
||||
RigMode::WFM => Self::Wfm,
|
||||
RigMode::AIS => Self::Fm,
|
||||
RigMode::AIS | RigMode::VDES => Self::Fm,
|
||||
RigMode::CW | RigMode::CWR => Self::Cw,
|
||||
RigMode::DIG => Self::Passthrough,
|
||||
// VHF/UHF packet radio (APRS, AX.25) is FM-encoded AFSK.
|
||||
|
||||
@@ -44,7 +44,7 @@ fn default_bandwidth_for_mode(mode: &RigMode) -> u32 {
|
||||
RigMode::AM => 9_000,
|
||||
RigMode::FM => 12_500,
|
||||
RigMode::WFM => 180_000,
|
||||
RigMode::AIS => 25_000,
|
||||
RigMode::AIS | RigMode::VDES => 25_000,
|
||||
RigMode::Other(_) => 3_000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ impl SoapySdrRig {
|
||||
fn default_bandwidth_for_mode(mode: &RigMode) -> u32 {
|
||||
match mode {
|
||||
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
|
||||
RigMode::PKT | RigMode::AIS => 25_000,
|
||||
RigMode::PKT | RigMode::AIS | RigMode::VDES => 25_000,
|
||||
RigMode::CW | RigMode::CWR => 500,
|
||||
RigMode::AM => 9_000,
|
||||
RigMode::FM => 12_500,
|
||||
@@ -192,6 +192,7 @@ impl SoapySdrRig {
|
||||
RigMode::WFM,
|
||||
RigMode::FM,
|
||||
RigMode::AIS,
|
||||
RigMode::VDES,
|
||||
RigMode::DIG,
|
||||
RigMode::PKT,
|
||||
],
|
||||
@@ -352,7 +353,7 @@ impl RigCat for SoapySdrRig {
|
||||
let half_span_hz = i128::from(self.pipeline.sdr_sample_rate) / 2;
|
||||
let current_center_hz = i128::from(self.center_hz);
|
||||
let target_lo_hz = i128::from(freq.hz);
|
||||
let target_hi_hz = if self.mode == RigMode::AIS {
|
||||
let target_hi_hz = if matches!(self.mode, RigMode::AIS | RigMode::VDES) {
|
||||
i128::from(freq.hz) + i128::from(AIS_CHANNEL_SPACING_HZ)
|
||||
} else {
|
||||
i128::from(freq.hz)
|
||||
|
||||
Reference in New Issue
Block a user