From 083ac6175e1a672ca3349addb2369fc89ccbc777 Mon Sep 17 00:00:00 2001 From: jedisdead666 <308891347+jedisdead666@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:14:02 -0600 Subject: [PATCH 1/2] Add safe Xiegu G106 and DE-19 support --- desktop/README.md | 27 +- desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/src/engine.rs | 35 ++- desktop/src-tauri/src/rig.rs | 282 +++++++++++++++++- desktop/src-tauri/tauri.conf.json | 2 +- desktop/src/App.tsx | 54 +++- desktop/src/ipc.ts | 2 +- desktop/src/rig.test.ts | 28 +- desktop/src/rig.ts | 14 +- ft8af/app/src/main/assets/rigaddress.txt | 3 +- .../java/com/k1af/ft8af/MainViewModel.java | 6 +- .../com/k1af/ft8af/rigs/InstructionSet.java | 1 + .../com/k1af/ft8af/rigs/XieGuG106Rig.java | 37 +++ .../java/com/k1af/ft8af/rigs/XieGuRig.java | 15 +- .../k1af/ft8af/database/RigNameListTest.java | 11 + .../com/k1af/ft8af/rigs/XieGuG106RigTest.java | 26 ++ 18 files changed, 517 insertions(+), 32 deletions(-) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/rigs/XieGuG106Rig.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/rigs/XieGuG106RigTest.java diff --git a/desktop/README.md b/desktop/README.md index 1e4dab738..a387c5d9b 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -6,8 +6,8 @@ exact pure-C FT8 DSP core from the Android app (`ft8af/app/src/main/cpp`, kgoba `ft8_lib` @ `6f528128`) — compiled from source, no JNI. MVP scope: receive/decode FT8, transmit + auto-QSO sequencing, logbook with ADIF -export, rig control (**Hamlib** embedded library, **FLrig** XML-RPC, or direct -serial CAT — Yaesu / Kenwood / Icom CI-V), audio device selection, and a +export, rig control (**Hamlib** embedded library, **FLrig** XML-RPC, or a +safety-hardened direct Xiegu G106 CI-V profile), audio device selection, and a waterfall. Deferred: map, POTA, QRZ photos, Bluetooth, PSKReporter upload. ## Layout @@ -24,7 +24,7 @@ desktop/ audio/ cpal capture/playback, 12 kHz resample, slot buffer engine.rs the runtime: 15 s UTC cycle, decode, QSO, TX/PTT, events qso.rs FT8 QSO auto-sequence state machine (pure, unit-tested) - rig.rs rig control backends: Hamlib (dyn-loaded), FLrig XML-RPC, serial CAT + rig.rs rig control backends: Hamlib, FLrig XML-RPC, direct G106 CI-V db.rs SQLite (rusqlite) qso_log + config + ADIF export bands.rs FT8 band plan main.rs Tauri commands + event forwarding @@ -34,6 +34,16 @@ desktop/ - **Rust** (stable, MSVC toolchain on Windows): `winget install Rustlang.Rustup` - **Node.js 18+** and npm +- **Debian / Ubuntu / Linux Mint:** install Tauri's native WebKit/GTK build + dependencies plus the ALSA and udev headers used by audio and serial support: + + ```bash + sudo apt update + sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \ + libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \ + libasound2-dev libudev-dev + ``` + - **Windows only:** the C core uses C99 VLAs that MSVC's `cl.exe` rejects, so the build compiles the C with **clang-cl**. Install LLVM: `winget install LLVM.LLVM` (build.rs auto-detects `C:\Program Files\LLVM\bin\clang-cl.exe`, or set @@ -96,6 +106,17 @@ isn't found the rig list is empty and you get a clean message rather than a cras > If you later enable Tauri bundling (`bundle.active`), add `hamlib//*.dll` > to the bundle `resources` so they ship in the installer too. +### Xiegu G106 / G106C + +For a direct connection, choose **Xiegu G106 (direct CI-V)** in the Rig list. The +profile selects 19,200 baud and CI-V address `0x70`. CAT PTT is refreshed once +per second during transmit because the G106 otherwise releases CAT-keyed PTT +after roughly two seconds. On every serial connect and disconnect FT8AF clears +both DTR and RTS before issuing CAT commands; this prevents the DE-19 from +interpreting Linux's default asserted DTR state as PTT. RTS or DTR PTT does not +use the keepalive. Select the DE-19 (or other interface) capture and playback +devices separately for FT8 audio; the serial CAT cable does not carry audio. + ## Build / test ``` diff --git a/desktop/package.json b/desktop/package.json index 6590429ee..678701ce6 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "ft8af-desktop", "private": true, - "version": "0.1.0", + "version": "0.1.1", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 7d39f7801..ef0157fb4 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1191,7 +1191,7 @@ dependencies = [ [[package]] name = "ft8af" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "cc", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index e7791b7b9..08947cfda 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ft8af" -version = "0.1.0" +version = "0.1.1" edition = "2021" description = "FT8AF desktop — FT8 transceiver client" license = "GPL-3.0-or-later" diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index 8a720a529..a2a3ee42e 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -446,6 +446,10 @@ struct Engine { last_udp_heartbeat_ms: i64, } +fn should_refresh_ptt(ptt_on: bool, rig_requires_keepalive: bool) -> bool { + ptt_on && rig_requires_keepalive +} + impl Engine { fn new(db: Arc, evt: Sender, cmd_tx: Sender) -> Self { // Restore persisted station + band where present. @@ -633,6 +637,7 @@ impl Engine { silent: db <= SILENCE_DBFS, })); } + self.refresh_ptt_keepalive(); } // Decode runs on a clock shifted later by the capture-latency @@ -886,6 +891,22 @@ impl Engine { self.publish_rig_status(); } + fn refresh_ptt_keepalive(&mut self) { + let required = self + .rig + .as_ref() + .map(|rig| rig.requires_ptt_keepalive()) + .unwrap_or(false); + if !should_refresh_ptt(self.ptt, required) { + return; + } + + let result = self.rig.as_mut().map(|rig| rig.set_ptt(true)); + if let Some(Err(e)) = result { + self.emit(EngineEvent::Error(format!("PTT keepalive failed: {e}"))); + } + } + /// Compute one windowed-FFT spectrum row from the most recent audio and emit /// it for the scrolling waterfall. Color is auto-scaled relative to a smoothed /// noise-floor estimate so the floor stays blue regardless of input gain. @@ -1315,13 +1336,21 @@ impl Engine { #[cfg(test)] mod tests { use super::{ - clamp_rx_gain, clamp_tx_gain, reply_tx_audio_hz, rms_dbfs, wf_boundary_row, Engine, - CYCLE_MS, DEFAULT_RX_GAIN, DEFAULT_TX_GAIN, MAX_TX_AUDIO_HZ, MIN_TX_AUDIO_HZ, - SILENCE_DBFS, + clamp_rx_gain, clamp_tx_gain, reply_tx_audio_hz, rms_dbfs, should_refresh_ptt, + wf_boundary_row, Engine, CYCLE_MS, DEFAULT_RX_GAIN, DEFAULT_TX_GAIN, MAX_TX_AUDIO_HZ, + MIN_TX_AUDIO_HZ, SILENCE_DBFS, }; use crate::db::Db; use std::sync::Arc; + #[test] + fn ptt_keepalive_only_runs_for_keyed_radios_that_require_it() { + assert!(should_refresh_ptt(true, true)); + assert!(!should_refresh_ptt(false, true)); + assert!(!should_refresh_ptt(true, false)); + assert!(!should_refresh_ptt(false, false)); + } + #[test] fn reply_df_updates_session_offset_without_persisting() { // The operator's saved TX offset (base_freq) is what survives a restart. diff --git a/desktop/src-tauri/src/rig.rs b/desktop/src-tauri/src/rig.rs index 11f12eef2..ae4aae5c4 100644 --- a/desktop/src-tauri/src/rig.rs +++ b/desktop/src-tauri/src/rig.rs @@ -6,12 +6,16 @@ //! * **Hamlib** — embedded via the dynamically-loaded shared library, with a //! per-model backend for every supported radio. This is the superset that //! replaced the old built-in serial CAT drivers. +//! * **Xiegu G106** — a focused direct CI-V backend for the DE-19 interface, +//! including its serial-line and CAT-PTT safety requirements. //! //! Both expose the same `RigTransport` interface; the engine doesn't care which. //! Everything here is desktop-only and never touches the Android tree. +use anyhow::Context; use serde::{Deserialize, Serialize}; use std::ffi::CString; +use std::io::Write; use std::os::raw::{c_char, c_int, c_long, c_void}; use std::time::Duration; @@ -24,6 +28,8 @@ pub enum RigBackend { Flrig, /// Hamlib, embedded via the dynamically-loaded shared library. Hamlib, + /// Direct Xiegu G106 CI-V over a DE-19 serial interface. + G106, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -111,6 +117,11 @@ trait RigTransport: Send { /// backend name — resolved per-backend at connect time. fn name(&self) -> String; fn connected(&self) -> bool; + /// Some radios release CAT-keyed PTT unless the command is periodically + /// refreshed. Hardware RTS/DTR keying and most CAT implementations do not. + fn requires_ptt_keepalive(&self) -> bool { + false + } } /// The engine's handle to whatever backend is configured. @@ -124,11 +135,9 @@ impl RigConnection { RigBackend::None => Box::new(NullTransport), RigBackend::Flrig => Box::new(Flrig::connect(cfg)), RigBackend::Hamlib => Box::new(Hamlib::connect(cfg)?), + RigBackend::G106 => Box::new(G106::connect(cfg)?), }; - let mut conn = RigConnection { inner }; - let _ = conn.set_ptt(false); - let _ = conn.set_data_mode(); - Ok(conn) + initialize_connection(inner) } pub fn set_frequency(&mut self, hz: u64) -> anyhow::Result<()> { @@ -146,6 +155,21 @@ impl RigConnection { pub fn connected(&self) -> bool { self.inner.connected() } + pub fn requires_ptt_keepalive(&self) -> bool { + self.inner.requires_ptt_keepalive() + } +} + +/// Put a newly-opened rig in a known receive state before exposing the +/// connection to the engine. A failure to send PTT-off is safety-critical and +/// must fail the connection rather than leave a possibly keyed radio marked as +/// connected. Mode selection remains best-effort for older rigs. +fn initialize_connection(inner: Box) -> anyhow::Result { + let mut conn = RigConnection { inner }; + conn.set_ptt(false) + .context("failed to establish receive/PTT-off state")?; + let _ = conn.set_data_mode(); + Ok(conn) } // --- null backend ----------------------------------------------------------- @@ -169,6 +193,133 @@ impl RigTransport for NullTransport { } } +// =========================================================================== +// Xiegu G106 direct CI-V backend +// =========================================================================== + +const G106_CIV_ADDRESS: u8 = 0x70; +const G106_BAUD: u32 = 19_200; + +/// Minimal abstraction used to test the safety-critical modem-line sequence +/// without opening real radio hardware. +trait ModemControlLines { + fn set_dtr(&mut self, level: bool) -> anyhow::Result<()>; + fn set_rts(&mut self, level: bool) -> anyhow::Result<()>; +} + +impl ModemControlLines for T { + fn set_dtr(&mut self, level: bool) -> anyhow::Result<()> { + Ok(self.write_data_terminal_ready(level)?) + } + + fn set_rts(&mut self, level: bool) -> anyhow::Result<()> { + Ok(self.write_request_to_send(level)?) + } +} + +/// Release both hardware PTT-capable control lines. The DE-19 can interpret an +/// asserted DTR/RTS line as PTT even though FT8AF uses CAT PTT. Linux asserts +/// DTR while opening a tty, so this must run before any CI-V command is sent. +fn release_modem_ptt_lines(port: &mut T) -> anyhow::Result<()> { + // Attempt both releases even if the first ioctl fails; either line may be + // wired as PTT by an interface. + let dtr_result = port.set_dtr(false); + let rts_result = port.set_rts(false); + dtr_result.context("failed to clear serial DTR/PTT")?; + rts_result.context("failed to clear serial RTS/PTT")?; + Ok(()) +} + +struct G106 { + port: Box, +} + +impl G106 { + fn connect(cfg: &RigConfig) -> anyhow::Result { + // `dtr_on_open(false)` asks serialport to clear DTR before open() + // returns. Linux may still assert it very briefly in the kernel during + // open, so explicitly clear both DTR and RTS again immediately after. + let mut port = serialport::new(&cfg.port, G106_BAUD) + .timeout(Duration::from_millis(200)) + .dtr_on_open(false) + .open()?; + release_modem_ptt_lines(port.as_mut())?; + Ok(G106 { port }) + } + + fn frame(body: &[u8]) -> Vec { + let mut frame = vec![0xFE, 0xFE, G106_CIV_ADDRESS, 0xE0]; + frame.extend_from_slice(body); + frame.push(0xFD); + frame + } + + fn frequency_bcd(hz: u64) -> [u8; 5] { + let mut digits = [0u8; 10]; + let mut frequency = hz; + for digit in &mut digits { + *digit = (frequency % 10) as u8; + frequency /= 10; + } + let mut encoded = [0u8; 5]; + for i in 0..5 { + encoded[i] = digits[2 * i] | (digits[2 * i + 1] << 4); + } + encoded + } + + fn frequency_command(hz: u64) -> Vec { + let mut body = vec![0x05]; + body.extend_from_slice(&Self::frequency_bcd(hz)); + Self::frame(&body) + } + + fn ptt_command(on: bool) -> Vec { + Self::frame(&[0x1C, 0x00, if on { 0x01 } else { 0x00 }]) + } + + fn send(&mut self, bytes: &[u8]) -> anyhow::Result<()> { + self.port.write_all(bytes)?; + self.port.flush()?; + Ok(()) + } +} + +impl RigTransport for G106 { + fn set_frequency(&mut self, hz: u64) -> anyhow::Result<()> { + self.send(&Self::frequency_command(hz)) + } + + fn set_data_mode(&mut self) -> anyhow::Result<()> { + // The G106 uses USB voice mode for FT8: mode 1, filter 1. + self.send(&Self::frame(&[0x06, 0x01, 0x01])) + } + + fn set_ptt(&mut self, on: bool) -> anyhow::Result<()> { + self.send(&Self::ptt_command(on)) + } + + fn name(&self) -> String { + "Xiegu G106".into() + } + + fn connected(&self) -> bool { + true + } + + fn requires_ptt_keepalive(&self) -> bool { + true + } +} + +impl Drop for G106 { + fn drop(&mut self) { + // Best effort: leave hardware PTT lines released even if the caller is + // unwinding after another CAT error. + let _ = release_modem_ptt_lines(self.port.as_mut()); + } +} + // =========================================================================== // FLrig XML-RPC backend // =========================================================================== @@ -506,6 +657,64 @@ impl Drop for Hamlib { #[cfg(test)] mod tests { use super::*; + use std::sync::{Arc, Mutex}; + + #[derive(Default)] + struct RecordingLines { + changes: Vec<(&'static str, bool)>, + fail_dtr: bool, + } + + impl ModemControlLines for RecordingLines { + fn set_dtr(&mut self, level: bool) -> anyhow::Result<()> { + self.changes.push(("dtr", level)); + if self.fail_dtr { + anyhow::bail!("simulated DTR failure"); + } + Ok(()) + } + + fn set_rts(&mut self, level: bool) -> anyhow::Result<()> { + self.changes.push(("rts", level)); + Ok(()) + } + } + + struct RecordingTransport { + events: Arc>>, + fail_ptt_off: bool, + } + + impl RigTransport for RecordingTransport { + fn set_frequency(&mut self, _hz: u64) -> anyhow::Result<()> { + self.events.lock().unwrap().push("frequency"); + Ok(()) + } + + fn set_data_mode(&mut self) -> anyhow::Result<()> { + self.events.lock().unwrap().push("data-mode"); + Ok(()) + } + + fn set_ptt(&mut self, on: bool) -> anyhow::Result<()> { + self.events + .lock() + .unwrap() + .push(if on { "ptt-on" } else { "ptt-off" }); + if !on && self.fail_ptt_off { + anyhow::bail!("simulated PTT-off failure"); + } + Ok(()) + } + + fn name(&self) -> String { + "test rig".into() + } + + fn connected(&self) -> bool { + true + } + } /// A fresh install must default to manual tuning ("None"), not a backend /// that immediately tries to load a shared library that may be absent. @@ -517,13 +726,14 @@ mod tests { } /// The removed serial backend is no longer a valid selector value; only - /// none / flrig / hamlib deserialize. + /// none / flrig / hamlib / g106 deserialize. #[test] fn backend_serde_roundtrip() { for (json, backend) in [ ("\"none\"", RigBackend::None), ("\"flrig\"", RigBackend::Flrig), ("\"hamlib\"", RigBackend::Hamlib), + ("\"g106\"", RigBackend::G106), ] { let b: RigBackend = serde_json::from_str(json).unwrap(); assert_eq!(b, backend); @@ -532,6 +742,68 @@ mod tests { assert!(serde_json::from_str::("\"serial\"").is_err()); } + #[test] + fn g106_commands_use_address_70_and_correct_ptt_polarity() { + assert_eq!( + G106::frequency_command(14_074_000), + vec![0xFE, 0xFE, 0x70, 0xE0, 0x05, 0x00, 0x40, 0x07, 0x14, 0x00, 0xFD] + ); + assert_eq!( + G106::ptt_command(true), + vec![0xFE, 0xFE, 0x70, 0xE0, 0x1C, 0x00, 0x01, 0xFD] + ); + assert_eq!( + G106::ptt_command(false), + vec![0xFE, 0xFE, 0x70, 0xE0, 0x1C, 0x00, 0x00, 0xFD] + ); + } + + #[test] + fn g106_connect_releases_dtr_and_rts_before_cat_initialization() { + let mut lines = RecordingLines::default(); + release_modem_ptt_lines(&mut lines).unwrap(); + assert_eq!(lines.changes, vec![("dtr", false), ("rts", false)]); + } + + #[test] + fn g106_connect_attempts_both_releases_if_dtr_clear_fails() { + let mut lines = RecordingLines { + fail_dtr: true, + ..Default::default() + }; + assert!(release_modem_ptt_lines(&mut lines).is_err()); + assert_eq!(lines.changes, vec![("dtr", false), ("rts", false)]); + } + + #[test] + fn connection_establishes_ptt_off_before_setting_mode() { + let events = Arc::new(Mutex::new(Vec::new())); + let transport = RecordingTransport { + events: events.clone(), + fail_ptt_off: false, + }; + + let _connection = initialize_connection(Box::new(transport)).unwrap(); + assert_eq!(*events.lock().unwrap(), vec!["ptt-off", "data-mode"]); + } + + #[test] + fn connection_fails_closed_when_ptt_off_cannot_be_sent() { + let events = Arc::new(Mutex::new(Vec::new())); + let transport = RecordingTransport { + events: events.clone(), + fail_ptt_off: true, + }; + + let error = initialize_connection(Box::new(transport)) + .err() + .expect("PTT-off failure must reject the connection"); + assert!(error + .to_string() + .contains("failed to establish receive/PTT-off state")); + assert_eq!(*events.lock().unwrap(), vec!["ptt-off"]); + } + /// A config persisted by an older build (with the now-removed serial fields /// `model`/`ptt`/`civ_address`) must still deserialize — serde ignores the /// extra keys and applies defaults for anything missing. diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 68a8fd0ae..b1c187e09 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "FT8AF", - "version": "0.1.0", + "version": "0.1.1", "identifier": "radio.ks3ckc.ft8af.desktop", "build": { "frontendDist": "../dist", diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 3d6d3d866..3ecdcf66d 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -829,6 +829,18 @@ const WF_WINDOW_LABELS: Record = { const WF_FFT_SIZES = [512, 1024, 2048, 4096, 8192]; const WF_AVG_OPTIONS = [1, 2, 3, 4, 6, 8, 12, 16]; +function audioDeviceLabel(device: AudioDevice): string { + // ALSA often exposes the DE-19's C-Media codec as the generic card name + // "Device". Keep the real identifier visible while making it recognizable. + if (device.name.includes("CARD=Device")) { + const recommended = device.name.startsWith("plughw:") ? " — recommended" : ""; + return `USB Audio Device (DE-19) — ${device.name}${recommended}`; + } + if (device.name === "pipewire") return "PipeWire sound server"; + if (device.name === "default") return "Linux system default"; + return device.name; +} + // Add/edit/delete operator-defined dial frequencies (issue #470). Custom dials // persist in the config store and are merged into the band picker (shown with a // ★). Editing is re-adding at the same dial frequency; the backend upserts. @@ -1087,7 +1099,9 @@ function SettingsScreen(props: { // after launch — or a Hamlib radio picked before its serial port was chosen — // showed an empty/stale list. Selecting a serial-connected Hamlib radio now // re-scans, so /dev/ttyACM0 (a QMX/QDX) appears without a workaround. - const showsPortList = rigCfg.backend === "hamlib" && !rigCfg.hamlib_network; + const showsPortList = + rigCfg.backend === "g106" || + (rigCfg.backend === "hamlib" && !rigCfg.hamlib_network); useEffect(() => { if (showsPortList) refreshPorts(); }, [showsPortList]); @@ -1232,7 +1246,7 @@ function SettingsScreen(props: { {inputs.map((d) => ( ))} @@ -1250,7 +1264,7 @@ function SettingsScreen(props: { {outputs.map((d) => ( ))} @@ -1307,7 +1321,8 @@ function SettingsScreen(props: { {hamlibRigs.length === 0 && ( - Only None and FLrig are listed — the Hamlib library didn't load, so + The built-in choices remain available, but Hamlib radios are not listed — + the Hamlib library didn't load, so no radios could be enumerated. Install Hamlib and put{" "} hamlib-4.dll / libhamlib next to the app or on PATH, then reopen. @@ -1315,6 +1330,35 @@ function SettingsScreen(props: { )} + {rigCfg.backend === "g106" && ( +
+
+ +
+ + +
+
+
+ + +
+
+ )} + {rigCfg.backend === "hamlib" && ( <>
@@ -1410,6 +1454,8 @@ function SettingsScreen(props: { const detail = rigCfg.backend === "flrig" ? ` (${rigCfg.flrig_host}:${rigCfg.flrig_port})` + : rigCfg.backend === "g106" + ? ` (${rigCfg.port || "no port"} at 19200 baud)` : rigCfg.backend === "hamlib" ? ` (${rigCfg.hamlib_network ? rigCfg.hamlib_address : rigCfg.port || "no port"})` : ""; diff --git a/desktop/src/ipc.ts b/desktop/src/ipc.ts index f49d8dfd6..ebc5291fa 100644 --- a/desktop/src/ipc.ts +++ b/desktop/src/ipc.ts @@ -127,7 +127,7 @@ export interface WaterfallConfig { avg: number; } -export type RigBackend = "none" | "flrig" | "hamlib"; +export type RigBackend = "none" | "flrig" | "hamlib" | "g106"; export interface RigConfig { backend: RigBackend; diff --git a/desktop/src/rig.test.ts b/desktop/src/rig.test.ts index 210ee217f..09a773dbd 100644 --- a/desktop/src/rig.test.ts +++ b/desktop/src/rig.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import type { HamlibRig, RigConfig } from "./ipc"; import { RIG_FLRIG, + RIG_G106, RIG_NONE, applySelection, configToSelection, @@ -30,27 +31,29 @@ function cfg(overrides: Partial = {}): RigConfig { } describe("rigOptions", () => { - it("pins None (default) then FLrig, then every Hamlib radio", () => { + it("pins None, FLrig, and G106 before every Hamlib radio", () => { const opts = rigOptions(RIGS); expect(opts.map((o) => o.value)).toEqual([ RIG_NONE, RIG_FLRIG, + RIG_G106, "hamlib:3073", "hamlib:2", ]); expect(opts[0].label).toBe("None (manual tuning)"); expect(opts[1].label).toBe("FLrig"); // Radio labels are the plain radio names, no backend jargon or model #. - expect(opts[2].label).toBe("Icom IC-7300"); + expect(opts[2].label).toBe("Xiegu G106 (direct CI-V)"); + expect(opts[3].label).toBe("Icom IC-7300"); }); it("preserves the Hamlib list order (already name-sorted upstream)", () => { - const opts = rigOptions(RIGS).slice(2); + const opts = rigOptions(RIGS).slice(3); expect(opts.map((o) => o.label)).toEqual(["Icom IC-7300", "Yaesu FT-991"]); }); - it("is just None + FLrig when no Hamlib radios are available", () => { - expect(rigOptions([]).map((o) => o.value)).toEqual([RIG_NONE, RIG_FLRIG]); + it("keeps the direct G106 profile when Hamlib is unavailable", () => { + expect(rigOptions([]).map((o) => o.value)).toEqual([RIG_NONE, RIG_FLRIG, RIG_G106]); }); }); @@ -66,6 +69,17 @@ describe("applySelection (selector → backend)", () => { expect(next.flrig_port).toBe(9999); }); + it("G106 → direct backend with its fixed safe serial settings", () => { + const next = applySelection( + cfg({ port: "/dev/ttyUSB0", baud: 38400, hamlib_network: true }), + RIG_G106, + ); + expect(next.backend).toBe("g106"); + expect(next.port).toBe("/dev/ttyUSB0"); + expect(next.baud).toBe(19200); + expect(next.hamlib_network).toBe(false); + }); + it("a Hamlib radio → backend hamlib with that model id", () => { const next = applySelection(cfg(), "hamlib:3073"); expect(next.backend).toBe("hamlib"); @@ -85,9 +99,10 @@ describe("applySelection (selector → backend)", () => { }); describe("configToSelection (backend → selector)", () => { - it("round-trips none / flrig / hamlib", () => { + it("round-trips none / flrig / g106 / hamlib", () => { expect(configToSelection(cfg({ backend: "none" }))).toBe(RIG_NONE); expect(configToSelection(cfg({ backend: "flrig" }))).toBe(RIG_FLRIG); + expect(configToSelection(cfg({ backend: "g106" }))).toBe(RIG_G106); expect(configToSelection(cfg({ backend: "hamlib", hamlib_model: 3073 }))).toBe("hamlib:3073"); }); @@ -109,6 +124,7 @@ describe("rigName", () => { it("labels the non-Hamlib backends", () => { expect(rigName(cfg({ backend: "none" }), RIGS)).toBe("None (manual tuning)"); expect(rigName(cfg({ backend: "flrig" }), RIGS)).toBe("FLrig"); + expect(rigName(cfg({ backend: "g106" }), RIGS)).toBe("Xiegu G106"); }); it("never returns undefined for a legacy/unknown backend", () => { diff --git a/desktop/src/rig.ts b/desktop/src/rig.ts index 40f48d16c..a41553333 100644 --- a/desktop/src/rig.ts +++ b/desktop/src/rig.ts @@ -7,12 +7,14 @@ // // "none" → RigBackend "none" — manual tuning (the default) // "flrig" → RigBackend "flrig" — network CAT via FLrig XML-RPC +// "g106" → RigBackend "g106" — direct, safety-hardened CI-V // "hamlib:" → RigBackend "hamlib" with hamlib_model = // import type { HamlibRig, RigConfig } from "./ipc"; export const RIG_NONE = "none"; export const RIG_FLRIG = "flrig"; +export const RIG_G106 = "g106"; const HAMLIB_PREFIX = "hamlib:"; export interface RigOption { @@ -21,14 +23,15 @@ export interface RigOption { } /** - * Build the Rig dropdown options: **None** (default) and **FLrig** pinned at - * the top, followed by every Hamlib-supported radio by name. `hamlibRigs` is + * Build the Rig dropdown options: **None**, **FLrig**, and the direct G106 + * profile pinned at the top, followed by every Hamlib-supported radio by name. `hamlibRigs` is * already sorted case-insensitively by name by `list_hamlib_rigs()`. */ export function rigOptions(hamlibRigs: HamlibRig[]): RigOption[] { return [ { value: RIG_NONE, label: "None (manual tuning)" }, { value: RIG_FLRIG, label: "FLrig" }, + { value: RIG_G106, label: "Xiegu G106 (direct CI-V)" }, ...hamlibRigs.map((r) => ({ value: `${HAMLIB_PREFIX}${r.model}`, label: r.name })), ]; } @@ -42,6 +45,8 @@ export function configToSelection(cfg: RigConfig): string { switch (cfg.backend) { case "flrig": return RIG_FLRIG; + case "g106": + return RIG_G106; case "hamlib": return `${HAMLIB_PREFIX}${cfg.hamlib_model}`; default: @@ -58,6 +63,9 @@ export function applySelection(cfg: RigConfig, selection: string): RigConfig { if (selection === RIG_FLRIG) { return { ...cfg, backend: "flrig" }; } + if (selection === RIG_G106) { + return { ...cfg, backend: "g106", baud: 19200, hamlib_network: false }; + } if (selection.startsWith(HAMLIB_PREFIX)) { const model = parseInt(selection.slice(HAMLIB_PREFIX.length), 10); if (!Number.isNaN(model)) { @@ -76,6 +84,8 @@ export function rigName(cfg: RigConfig, hamlibRigs: HamlibRig[]): string { switch (cfg.backend) { case "flrig": return "FLrig"; + case "g106": + return "Xiegu G106"; case "hamlib": return ( hamlibRigs.find((r) => r.model === cfg.hamlib_model)?.name ?? diff --git a/ft8af/app/src/main/assets/rigaddress.txt b/ft8af/app/src/main/assets/rigaddress.txt index a1a09823f..5bece2165 100644 --- a/ft8af/app/src/main/assets/rigaddress.txt +++ b/ft8af/app/src/main/assets/rigaddress.txt @@ -37,7 +37,8 @@ XIEGU X6200(U-DIG),A4,19200,13 XIEGU G90S(U-DIG),70,19200,13 XIEGU G90S(USB),70,19200,9 XIEGU G106C(U-DIG),70,19200,13 -XIEGU G106C(USB),70,19200,9 +XIEGU G106C(USB),70,19200,26 +XIEGU G106(USB),70,19200,26 XIEGU X5105,70,19200,9 XIEGU X108,70,19200,9 GUOHE Q900,00,19200,8 diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java index 282ae7b0a..765713327 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -119,6 +119,7 @@ import com.k1af.ft8af.rigs.Wolf_sdr_450Rig; import com.k1af.ft8af.rigs.XieGu6100NetRig; import com.k1af.ft8af.rigs.XieGu6100Rig; +import com.k1af.ft8af.rigs.XieGuG106Rig; import com.k1af.ft8af.rigs.XieGuRig; import com.k1af.ft8af.rigs.Yaesu2Rig; import com.k1af.ft8af.rigs.Yaesu2_847Rig; @@ -2396,6 +2397,9 @@ private void connectRig() { case InstructionSet.XIEGUG90S://XieGu, USB mode baseRig = new XieGuRig(GeneralVariables.civAddress);//XieGu G90S break; + case InstructionSet.XIEGU_G106: + baseRig = new XieGuG106Rig(GeneralVariables.civAddress); + break; case InstructionSet.ELECRAFT: baseRig = new ElecraftRig();//ELECRAFT break; @@ -3109,4 +3113,4 @@ public void onReceive(Context ctx, Intent intent) { usbManager.requestPermission(device, permissionIntent); } -} \ No newline at end of file +} diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/InstructionSet.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/InstructionSet.java index 5404ea489..e48585ebd 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/InstructionSet.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/InstructionSet.java @@ -29,6 +29,7 @@ public class InstructionSet { public static final int YAESU_FT710=23;//FT-710, currently reuses DX10 instruction set; CAT read loop must stay off public static final int DISCOVERY_TX500=24;//Lab599 Discovery TX-500, TS-2000 compatible, DATA mode (MD6;) public static final int KENWOOD_TS440=25;//KENWOOD TS-440S, original Kenwood CAT, TS-570 compatible (TX;/RX; PTT) + public static final int XIEGU_G106=26;//Xiegu G106/G106C, CI-V with CAT PTT keepalive // Hamlib backend: CAT protocol supplied by libhamlib (see HamlibRig). The // rig table's address column carries the hamlib model number (in hex, since diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/XieGuG106Rig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/XieGuG106Rig.java new file mode 100644 index 000000000..661f7e10b --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/XieGuG106Rig.java @@ -0,0 +1,37 @@ +package com.k1af.ft8af.rigs; + +import com.k1af.ft8af.database.ControlMode; + +/** + * Xiegu G106/G106C CI-V driver. + * + *

The G106 releases CAT-controlled PTT after roughly two seconds unless the + * command is refreshed. FT8 transmission lasts 12.64 seconds, so repeat the + * PTT-on frame once per second while transmitting. RTS and DTR keying do not + * need this workaround.

+ */ +public class XieGuG106Rig extends XieGuRig { + static final long PTT_KEEPALIVE_MS = 1_000L; + + public XieGuG106Rig(int civAddress) { + super(civAddress, PTT_KEEPALIVE_MS); + } + + static boolean needsCatPttKeepAlive(boolean pttOn, int controlMode) { + return pttOn && controlMode == ControlMode.CAT; + } + + @Override + protected void onPollingWhileTransmitting() { + if (needsCatPttKeepAlive(isPttOn(), getControlMode())) { + // Runs on XieGuRig's existing polling timer, keeping all serial + // commands on the same ordered path as its meter/frequency polls. + super.setPTT(true); + } + } + + @Override + public String getName() { + return "XIEGU G106 series"; + } +} diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/XieGuRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/XieGuRig.java index 1b22a0cb4..727f15342 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/XieGuRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/XieGuRig.java @@ -45,6 +45,7 @@ public void run() { return; // skip this tick; timer stays alive for reconnect } if (isPttOn()) { + onPollingWhileTransmitting(); readSWRMeter(); } else { readFreqFromRig(); @@ -57,6 +58,11 @@ public void run() { }; } + /** Hook for Xiegu models that require a command while each TX poll runs. */ + protected void onPollingWhileTransmitting() { + // Default Xiegu radios need no additional action. + } + @Override public void setPTT(boolean on) { @@ -266,11 +272,16 @@ public String getFrequencyStr() { return BaseRigOperation.getFrequencyStr(getFreq()); } - public XieGuRig(int civAddress) { + protected XieGuRig(int civAddress, long pollingIntervalMs) { Log.d(TAG, "XieGuRig 6100: Create."); setCivAddress(civAddress); - readFreqTimer.schedule(readTask(), START_QUERY_FREQ_DELAY, QUERY_FREQ_TIMEOUT); + long initialDelayMs = Math.min(START_QUERY_FREQ_DELAY, pollingIntervalMs); + readFreqTimer.schedule(readTask(), initialDelayMs, pollingIntervalMs); //readFreqTimer.schedule(readTask(),START_QUERY_FREQ_DELAY,1000); } + + public XieGuRig(int civAddress) { + this(civAddress, QUERY_FREQ_TIMEOUT); + } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/RigNameListTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/RigNameListTest.java index ed27c8c98..d068f232a 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/database/RigNameListTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/RigNameListTest.java @@ -2,6 +2,8 @@ import static com.google.common.truth.Truth.assertThat; +import com.k1af.ft8af.rigs.InstructionSet; + import org.junit.Test; import java.io.ByteArrayInputStream; @@ -31,6 +33,15 @@ public void rigNameStringConstructor_parsesHexAddressAndDecimalFields() { assertThat(r.instructionSet).isEqualTo(0); } + @Test + public void rigNameStringConstructor_parsesG106Profile() { + RigNameList.RigName r = new RigNameList.RigName("XIEGU G106(USB),70,19200,26"); + assertThat(r.modelName).isEqualTo("XIEGU G106(USB)"); + assertThat(r.address).isEqualTo(0x70); + assertThat(r.bauRate).isEqualTo(19200); + assertThat(r.instructionSet).isEqualTo(InstructionSet.XIEGU_G106); + } + @Test public void rigNameStringConstructor_trimsWhitespaceAroundFields() { RigNameList.RigName r = new RigNameList.RigName(" Yaesu FT-991 , 1F , 38400 , 2 "); diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/XieGuG106RigTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/XieGuG106RigTest.java new file mode 100644 index 000000000..9a3f0e783 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/XieGuG106RigTest.java @@ -0,0 +1,26 @@ +package com.k1af.ft8af.rigs; + +import static com.google.common.truth.Truth.assertThat; + +import com.k1af.ft8af.database.ControlMode; + +import org.junit.Test; + +public class XieGuG106RigTest { + @Test + public void catPttOnNeedsKeepAlive() { + assertThat(XieGuG106Rig.needsCatPttKeepAlive(true, ControlMode.CAT)).isTrue(); + } + + @Test + public void receiveAndHardwarePttDoNotNeedKeepAlive() { + assertThat(XieGuG106Rig.needsCatPttKeepAlive(false, ControlMode.CAT)).isFalse(); + assertThat(XieGuG106Rig.needsCatPttKeepAlive(true, ControlMode.RTS)).isFalse(); + assertThat(XieGuG106Rig.needsCatPttKeepAlive(true, ControlMode.DTR)).isFalse(); + } + + @Test + public void keepAliveRunsSafelyBeforeTwoSecondRadioTimeout() { + assertThat(XieGuG106Rig.PTT_KEEPALIVE_MS).isLessThan(2_000L); + } +} From e899134f376c539c185e50b710a5450344848f19 Mon Sep 17 00:00:00 2001 From: jedisdead666 <308891347+jedisdead666@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:35:23 -0600 Subject: [PATCH 2/2] Disable unsafe receive DT auto-calibration --- desktop/README.md | 7 ++ desktop/package-lock.json | 4 +- desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/src/engine.rs | 128 ++++++++++++++---------------- desktop/src-tauri/tauri.conf.json | 2 +- desktop/src/App.tsx | 11 +-- desktop/src/ipc.ts | 2 +- 9 files changed, 78 insertions(+), 82 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index a387c5d9b..58fac99e2 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -117,6 +117,13 @@ interpreting Linux's default asserted DTR state as PTT. RTS or DTR PTT does not use the keepalive. Select the DE-19 (or other interface) capture and playback devices separately for FT8 audio; the serial CAT cable does not carry audio. +### Receive timing + +Desktop 0.1.2 keeps receive capture windows fixed to the NTP-synchronized UTC +15-second slots. It ignores and clears the legacy `rx_offset_ms` setting used by +0.1.1. Decoded DT remains visible as diagnostic information, but one decode can +no longer shift later windows and cut off the leading FT8 sync block. + ## Build / test ``` diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 31016e869..fda8decbf 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "ft8af-desktop", - "version": "0.1.0", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ft8af-desktop", - "version": "0.1.0", + "version": "0.1.2", "dependencies": { "@tauri-apps/api": "^2", "react": "^18.3.1", diff --git a/desktop/package.json b/desktop/package.json index 678701ce6..622748ee9 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "ft8af-desktop", "private": true, - "version": "0.1.1", + "version": "0.1.2", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ef0157fb4..580e4863d 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1191,7 +1191,7 @@ dependencies = [ [[package]] name = "ft8af" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "cc", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 08947cfda..6811a12a3 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ft8af" -version = "0.1.1" +version = "0.1.2" edition = "2021" description = "FT8AF desktop — FT8 transceiver client" license = "GPL-3.0-or-later" diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index a2a3ee42e..c925bd6ec 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -26,12 +26,12 @@ const PTT_DELAY_MS: u64 = 100; // waveform (plus the PTT lead) land before the next slot boundary. Start later // than this and the leading Costas array gets clipped — audible but undecodable. const TX_LATEST_MS: i64 = TX_SLACK_MS - PTT_DELAY_MS as i64; -// Point in the (rx-corrected) cycle at which we hand the slot to the decoder. +// Point in the UTC cycle at which we hand the slot to the decoder. // The FT8 waveform is complete at 12.64 s, so decoding ~0.6 s later returns // results around the 15 s boundary even on a slow CPU — early enough that the // operator's reply lands in the very next slot instead of skipping a cycle. -// The rx-offset calibration aligns this clock to the real signal timing, and -// any positive capture latency only pushes the real trigger later (safer), so +// Keep this anchored to UTC: shifting the trigger while copying only the +// shifted elapsed sample count can remove the leading Costas sync block. // 13.2 s keeps a comfortable margin above the 12.64 s waveform end. const DECODE_AT_MS: i64 = 13_200; const DEFAULT_TX_AUDIO_HZ: i32 = 1_500; @@ -179,13 +179,10 @@ fn rms_dbfs(samples: &[f32]) -> f32 { (20.0 * (rms.max(1e-12)).log10()).max(-120.0) as f32 } -/// True once per cycle, on the first waterfall row at/after a 15 s boundary on -/// the rx-corrected clock, advancing `last_slot`. The live waterfall draws audio -/// on real time while the decoder measures DT on the rx-corrected clock (shifted -/// by capture latency), so the spectrum reads ahead of the DT; marking the cycle -/// grid on the corrected clock gives both the same time reference. -fn wf_boundary_row(corrected_now_ms: i64, last_slot: &mut i64) -> bool { - let slot = corrected_now_ms.div_euclid(CYCLE_MS); +/// True once per cycle, on the first waterfall row at/after a UTC 15 s boundary, +/// advancing `last_slot`. The decoder and waterfall use this same fixed clock. +fn wf_boundary_row(now_ms: i64, last_slot: &mut i64) -> bool { + let slot = now_ms.div_euclid(CYCLE_MS); if slot != *last_slot { *last_slot = slot; true @@ -306,7 +303,7 @@ pub struct ClockSyncEvent { pub offset_ms: i64, /// true once an NTP sync has succeeded this session (or a saved offset was restored). pub synced: bool, - /// Auto-calibrated audio-capture latency compensation (ms) applied to the RX window. + /// Retained for IPC compatibility; always zero since unsafe RX auto-calibration was disabled. pub rx_offset_ms: i64, } @@ -325,8 +322,7 @@ pub struct WaterfallFrame { pub struct WaterfallRow { pub bins: Vec, pub hz_per_col: f32, - /// True on the first row at/after a 15 s cycle boundary (on the rx-corrected - /// clock the decoder measures DT against). The UI draws a grid line there so + /// True on the first row at/after a UTC 15 s cycle boundary. The UI draws a grid line there so /// the spectrum lines up with decode DT instead of reading ahead of it. pub boundary: bool, } @@ -409,7 +405,7 @@ struct Engine { tx_gain: f32, /// RX input gain (0.0–1.0), applied live inside the capture callback. rx_gain: f32, - /// Slot id (rx-corrected clock) most recently handed to the decode worker. + /// UTC slot id most recently handed to the decode worker. /// Guards the once-per-slot early decode trigger in the run loop. last_decoded_slot: i64, /// Number of decode batches handed to the worker but not yet returned. A @@ -420,10 +416,8 @@ struct Engine { /// so it never keys a stale message ahead of a slow decode (the fresh one is /// keyed by `handle_decoded` when it arrives). See [`boundary_tx_ready`]. pending_decodes: usize, - /// Audio-capture latency compensation (ms). The RX decode window is sliced - /// this much later than the UTC cycle boundary so that buffered/late-arriving - /// input audio lands aligned — drives decoded DT toward 0. Auto-calibrated - /// from the median DT of each slot's decodes; persisted across sessions. + /// Retained for event compatibility. This is fixed at zero: adjusting the + /// receive clock after a decode can remove the next slot's leading sync block. rx_offset_ms: i64, last_tick_ms: i64, tx_parity: Option, @@ -439,7 +433,7 @@ struct Engine { tx_playback: Option, // live waterfall FFT (plan + window table + floor state; rebuilt on config change) wf: WfProcessor, - last_wf_slot: i64, // last cycle slot marked on the live waterfall (rx-corrected) + last_wf_slot: i64, // last UTC cycle slot marked on the live waterfall /// WSJT-X UDP interface (outbound broadcast + inbound request listener). udp: crate::udp::UdpService, /// UTC ms of the last UDP Heartbeat sent (rate-limits it to ~15 s). @@ -478,10 +472,12 @@ impl Engine { // Restore the last NTP offset so DT is roughly right immediately, before // the first fresh sync of this session lands. Treated as already-synced. let saved_offset: Option = db.get_config("clock_offset_ms").and_then(|s| s.parse().ok()); - let rx_offset_ms: i64 = db - .get_config("rx_offset_ms") - .and_then(|s| s.parse().ok()) - .unwrap_or(0); + // Versions through 0.1.1 persisted an auto-calibrated RX offset. Applying + // that offset changes which samples are treated as the slot start and can + // remove the leading Costas sync block. Clear every legacy value and keep + // receive windows anchored to UTC. + let rx_offset_ms = 0; + let _ = db.set_config("rx_offset_ms", "0"); let mut qso = QsoEngine::new(my_call, my_grid); qso.band = bands::band_for_freq_hz(dial_hz).to_string(); qso.freq_mhz = bands::freq_mhz_string(dial_hz); @@ -640,15 +636,14 @@ impl Engine { self.refresh_ptt_keepalive(); } - // Decode runs on a clock shifted later by the capture-latency - // compensation, so the sliced slot aligns with the audio that has - // actually arrived in the buffer (UTC display + TX still use real `now`). + // Decode stays anchored to the UTC slot. Do not shift this from a + // decoded message's DT: doing so changes the sample chosen as the slot + // start and can cut off the next message's leading Costas sync block. // We fire once per slot as soon as the 12.64 s waveform is captured // (~13.2 s in) rather than at the 15 s boundary, so decodes land a slot // earlier and the operator can reply on the next cycle (not skip one). - let corrected = now - self.rx_offset_ms; - let rx_slot_id = corrected.div_euclid(CYCLE_MS); - let into_rx_cycle = corrected.rem_euclid(CYCLE_MS); + let rx_slot_id = now.div_euclid(CYCLE_MS); + let into_rx_cycle = now.rem_euclid(CYCLE_MS); if self.decoding && rx_slot_id != self.last_decoded_slot && into_rx_cycle >= DECODE_AT_MS { self.last_decoded_slot = rx_slot_id; @@ -718,41 +713,6 @@ impl Engine { } } - /// Nudge the capture-latency compensation toward zeroing the decoded DT. The - /// FT8 network is NTP-synced, so the *median* DT of a slot's decodes is a robust - /// estimate of our own systematic timing error (clock residual + audio latency). - /// A slow EMA keeps it from chasing noise; persisted so DT is right at next launch. - fn calibrate_dt(&mut self, decoded: &[DecodedMessage]) { - // Need a few independent decodes for the median to be meaningful. - const MIN_DECODES: usize = 4; - const ALPHA: f64 = 0.6; // correction fraction applied per slot - const DEADBAND_MS: i64 = 60; // ignore tiny residuals to avoid jitter - const MAX_OFFSET_MS: i64 = 4_000; - if decoded.len() < MIN_DECODES { - return; - } - - let mut dts: Vec = decoded.iter().map(|m| m.time_sec).collect(); - dts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let median_dt_ms = (dts[dts.len() / 2] * 1000.0) as i64; - if median_dt_ms.abs() <= DEADBAND_MS { - return; - } - - let adjusted = self.rx_offset_ms + (median_dt_ms as f64 * ALPHA).round() as i64; - let new_offset = adjusted.clamp(-MAX_OFFSET_MS, MAX_OFFSET_MS); - if new_offset == self.rx_offset_ms { - return; - } - self.rx_offset_ms = new_offset; - // No re-anchor needed: the decode trigger requires `into_rx_cycle >= - // DECODE_AT_MS`, and offset changes land early in the cycle (right after a - // decode is delivered), far below that threshold — so a shifted slot id - // can't spuriously re-fire the decode this cycle. - let _ = self.db.set_config("rx_offset_ms", &new_offset.to_string()); - self.publish_clock_sync(); - } - /// Handle one slot's decodes (from the worker): publish them, advance the QSO /// state machine, then key up if it's our turn this slot. Runs once per slot /// (the decoder returns a batch — possibly empty — every cycle), so this is @@ -760,7 +720,6 @@ impl Engine { fn handle_decoded(&mut self, decoded: Vec, slot_id: i64) { self.publish_decodes(&decoded); self.broadcast_decodes(&decoded); - self.calibrate_dt(&decoded); if let Some(QsoOutcome::Completed(record)) = self.qso.process_rx(&decoded) { match self.db.insert_qso(&record) { @@ -915,9 +874,9 @@ impl Engine { let Some((bins, hz_per_col)) = self.wf.process_row(&samples, SAMPLE_RATE as f32) else { return; // still warming up }; - // Mark this row if it's the first at/after a cycle boundary on the same - // rx-corrected clock the decoder uses, so the grid line aligns with DT. - let boundary = wf_boundary_row(self.now() - self.rx_offset_ms, &mut self.last_wf_slot); + // Mark the first row at/after each UTC cycle boundary, matching the + // fixed clock used for decoder windows. + let boundary = wf_boundary_row(self.now(), &mut self.last_wf_slot); self.emit(EngineEvent::WaterfallRow(WaterfallRow { bins, hz_per_col, boundary })); } @@ -1341,6 +1300,7 @@ mod tests { MIN_TX_AUDIO_HZ, SILENCE_DBFS, }; use crate::db::Db; + use crate::dsp::DecodedMessage; use std::sync::Arc; #[test] @@ -1351,6 +1311,38 @@ mod tests { assert!(!should_refresh_ptt(false, false)); } + #[test] + fn legacy_rx_offsets_are_cleared_on_restart() { + for saved in ["-4000", "528"] { + let db = Arc::new(Db::open_in_memory().expect("in-memory db")); + db.set_config("rx_offset_ms", saved).expect("seed legacy offset"); + let (evt_tx, _evt_rx) = std::sync::mpsc::channel(); + let (cmd_tx, _cmd_rx) = std::sync::mpsc::channel(); + + let engine = Engine::new(Arc::clone(&db), evt_tx, cmd_tx); + + assert_eq!(engine.rx_offset_ms, 0, "saved offset {saved} must be ignored"); + assert_eq!(db.get_config("rx_offset_ms").as_deref(), Some("0")); + } + } + + #[test] + fn decoded_dt_cannot_move_future_receive_windows() { + let db = Arc::new(Db::open_in_memory().expect("in-memory db")); + let (evt_tx, _evt_rx) = std::sync::mpsc::channel(); + let (cmd_tx, _cmd_rx) = std::sync::mpsc::channel(); + let mut engine = Engine::new(Arc::clone(&db), evt_tx, cmd_tx); + let decodes = [0.7, 0.8, 0.9, 1.0] + .into_iter() + .map(|time_sec| DecodedMessage { time_sec, ..Default::default() }) + .collect(); + + engine.handle_decoded(decodes, 1); + + assert_eq!(engine.rx_offset_ms, 0); + assert_eq!(db.get_config("rx_offset_ms").as_deref(), Some("0")); + } + #[test] fn reply_df_updates_session_offset_without_persisting() { // The operator's saved TX offset (base_freq) is what survives a restart. diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index b1c187e09..41217825f 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "FT8AF", - "version": "0.1.1", + "version": "0.1.2", "identifier": "radio.ks3ckc.ft8af.desktop", "build": { "frontendDist": "../dist", diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 3ecdcf66d..d4a196c80 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -72,9 +72,7 @@ function drawWaterfallRow(bins: number[], boundary = false) { img.data[i * 4 + 3] = 255; } ctx.putImageData(img, 0, 0); - // 15 s cycle grid line, on the same rx-corrected clock as decode DT: a faint - // overlay so the waterfall's timing reads against the cycle (and the DT) - // rather than appearing to run ahead of it. + // 15 s UTC cycle grid line on the same fixed clock as the decoder. if (boundary) { ctx.fillStyle = "rgba(255,255,255,0.35)"; ctx.fillRect(0, 0, cols, 1); @@ -1210,12 +1208,11 @@ function SettingsScreen(props: {
- +
- {clock ? `${fmtOffset(clock.rx_offset_ms)} (${clock.rx_offset_ms} ms)` : "—"} + {clock ? "Fixed to UTC slots (auto-calibration disabled)" : "—"} - Auto-tuned from decoded DT to keep the RX window aligned with your - soundcard/SDR latency. Self-corrects over a few cycles. + Decoded DT is informational and cannot move later capture windows.
diff --git a/desktop/src/ipc.ts b/desktop/src/ipc.ts index ebc5291fa..d627bdc3f 100644 --- a/desktop/src/ipc.ts +++ b/desktop/src/ipc.ts @@ -54,7 +54,7 @@ export interface ClockSyncEvent { offset_ms: number; /** true once an NTP sync has succeeded (or a saved offset was restored). */ synced: boolean; - /** auto-calibrated audio-capture latency compensation (ms) applied to the RX window. */ + /** Compatibility field; fixed at zero since unsafe RX auto-calibration was disabled. */ rx_offset_ms: number; }