From 92423f1e02f1c1a2852328e3fdb6d81136e271f9 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Tue, 3 Mar 2026 00:05:16 +0100 Subject: [PATCH] [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 Signed-off-by: Stan Grams --- Cargo.lock | 8 + Cargo.toml | 1 + src/decoders/trx-vdes/Cargo.toml | 11 + src/decoders/trx-vdes/src/lib.rs | 506 ++++++++++++++++++ src/trx-client/trx-frontend/src/lib.rs | 7 +- .../trx-frontend-http/assets/web/app.js | 47 +- .../trx-frontend-http/assets/web/index.html | 8 +- .../assets/web/plugins/ais.js | 41 +- .../trx-frontend/trx-frontend-http/src/api.rs | 25 + .../trx-frontend-http/src/audio.rs | 47 +- .../trx-frontend-http/src/status.rs | 2 + src/trx-core/src/audio.rs | 1 + src/trx-core/src/decode.rs | 33 ++ src/trx-core/src/rig/state.rs | 1 + src/trx-protocol/src/codec.rs | 7 +- src/trx-server/Cargo.toml | 1 + src/trx-server/src/audio.rs | 129 ++++- src/trx-server/src/main.rs | 17 + src/trx-server/src/rig_task.rs | 2 +- src/trx-server/trx-backend/src/dummy.rs | 1 + .../trx-backend/trx-backend-ft450d/src/lib.rs | 3 +- .../trx-backend/trx-backend-ft817/src/lib.rs | 3 +- .../trx-backend-soapysdr/src/demod.rs | 2 +- .../trx-backend-soapysdr/src/dsp/channel.rs | 2 +- .../trx-backend-soapysdr/src/lib.rs | 5 +- 25 files changed, 878 insertions(+), 32 deletions(-) create mode 100644 src/decoders/trx-vdes/Cargo.toml create mode 100644 src/decoders/trx-vdes/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 2bbf196..f67ce54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 74d8e8f..cc3143a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/src/decoders/trx-vdes/Cargo.toml b/src/decoders/trx-vdes/Cargo.toml new file mode 100644 index 0000000..a49d473 --- /dev/null +++ b/src/decoders/trx-vdes/Cargo.toml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 Stanislaw Grams +# +# SPDX-License-Identifier: BSD-2-Clause + +[package] +name = "trx-vdes" +version = "0.1.0" +edition = "2021" + +[dependencies] +trx-core = { path = "../../trx-core" } diff --git a/src/decoders/trx-vdes/src/lib.rs b/src/decoders/trx-vdes/src/lib.rs new file mode 100644 index 0000000..4a34b44 --- /dev/null +++ b/src/decoders/trx-vdes/src/lib.rs @@ -0,0 +1,506 @@ +// SPDX-FileCopyrightText: 2026 Stanislaw Grams +// +// 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, + bits: Vec, + 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, + frames: Vec, +} + +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 { + 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 { + 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 = 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 { + 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::*; + + fn payload_with_crc(payload: &[u8]) -> Vec { + let mut out = payload.to_vec(); + out.extend_from_slice(&crc16ccitt(payload).to_le_bytes()); + out + } + + fn bytes_to_lsb_bits(bytes: &[u8]) -> Vec { + 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 { + 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 { + 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); + } +} diff --git a/src/trx-client/trx-frontend/src/lib.rs b/src/trx-client/trx-frontend/src/lib.rs index 2b516b5..d5a8cde 100644 --- a/src/trx-client/trx-frontend/src/lib.rs +++ b/src/trx-client/trx-frontend/src/lib.rs @@ -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>, /// AIS decode history (timestamp, message) pub ais_history: Arc>>, + /// VDES decode history (timestamp, message) + pub vdes_history: Arc>>, /// APRS decode history (timestamp, packet) pub aprs_history: Arc>>, /// 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())), 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 6f3c495..5eae69f 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 @@ -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); 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 516ff27..eaf9eea 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 @@ -350,7 +350,7 @@