From 9e37c85bec74f814a2fc41b261c093843b550501 Mon Sep 17 00:00:00 2001 From: ww8l Date: Sat, 22 Aug 2026 16:01:45 -0600 Subject: [PATCH 01/15] Kenwood TM-D710: Phase 1 capture and the memory line (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TM-D710 is a **live-mode** radio and the fourth programming modality in this app: no clone image and no card file, one ASCII command per memory, `\r` terminated, the radio answering in kind. Every other driver here clones an image, writes binary records at flash addresses, or patches a file the radio wrote to a microSD card. Measured on Tim's radio, not inherited: ID -> ID TM-D710 non-G, K-type baud -> 57600 CHIRP's driver assumes 9600; this is silent there ME 999 -> N an empty slot, 962 of 1000 round trip-> 17 ms all 1000 slots in 17.2 s MU -> 42 fields order anchored at p1 and p26 by single-change diffs Two published claims died on contact with the radio, both from the table this was about to generate code from: - LA3QMA documents menu brightness as `0=off, 1=max`. It is Menu 501, `OFF/LEVEL 1~LEVEL 8`. Measured by changing it to LEVEL 3 and watching p26 go 8 -> 3 alone. Generating from that enum would have shipped an 8-level control as a checkbox. - `MU` is **not exhaustive**: p28 is Menu 503 and p29 is Menu 507, so 504 CONTRAST, 505 DISPLAY REVERSE and 506 have no parameter at all. Also measured: the first command after opening the port can answer `?` because a previous write left the parser mid-line, so one `?` is not a refusal. `ask_settling` retries once; a second `?` is real. `memory.rs` models the `ME` line field by field and its gate is the live-mode form of the byte-identical re-encode every card radio here is held to: **all 38 captured slots re-emit character-identically.** Fields whose meaning is not established are carried through as the radio's own text, so a slot can be read and written back long before every field is understood. Shift is decoded against Tim's actual repeaters (447.275 and 145.310 minus, 147.360 plus) rather than against the documentation. The driver identifies and nothing else — the scaffolding stance the FT5D was registered under. It could claim `SettingsReader` today on the strength of `MU`, and deliberately does not: the same capability flag would put a settings write in front of an operator, and **nothing has ever been written to this radio**. A D710G is named and refused; its menu set is a different radio's and has not been measured. `kenwood_tmd710_probe.rs` is the measuring instrument that produced all of the above, `#[ignore]`d like the FT5D's `hw_probe` so `cargo test` stays hardware-free. npm run ci green: 446 tests, clippy clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013cZgbB4FwqZ3Z4cdHt6vMH --- src-tauri/src/radios/kenwood_tmd710/memory.rs | 360 ++++++++++++++++++ src-tauri/src/radios/kenwood_tmd710/mod.rs | 292 ++++++++++++++ src-tauri/src/radios/kenwood_tmd710_probe.rs | 283 ++++++++++++++ src-tauri/src/radios/mod.rs | 6 + src-tauri/src/radios/registry.rs | 11 + 5 files changed, 952 insertions(+) create mode 100644 src-tauri/src/radios/kenwood_tmd710/memory.rs create mode 100644 src-tauri/src/radios/kenwood_tmd710/mod.rs create mode 100644 src-tauri/src/radios/kenwood_tmd710_probe.rs diff --git a/src-tauri/src/radios/kenwood_tmd710/memory.rs b/src-tauri/src/radios/kenwood_tmd710/memory.rs new file mode 100644 index 0000000..dd7f035 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/memory.rs @@ -0,0 +1,360 @@ +//! One memory slot of a TM-D710, as the radio itself states it (issue #113). +//! +//! Live mode has no image and no file: a memory *is* the `ME` line the radio +//! prints, and programming one is sending that line back. So this module models +//! the line, and its gate is that a line read off the radio re-emits +//! **character-identically** — the live-mode equivalent of the byte-identical +//! re-encode every card radio here is held to. +//! +//! ```text +//! ME 000,0447275000,0,2,0,0,1,0,12,12,000,05000000,0,0000000000,0,0 +//! MN 000,W0UPS +//! ``` +//! +//! Field widths are fixed and zero-padded, and that matters: `0` and `000` are +//! the same number and **not** the same line. Everything here therefore round +//! trips through the exact text, never through a parsed number alone. +//! +//! ## What is measured and what is not +//! +//! Measured on Tim's radio on 2026-08-22 (`scratchpad/kenwood_tmd710/`): +//! +//! - the 16 fields and their widths, over 38 populated slots +//! - **`Shift::Plus` = 1 and `Shift::Minus` = 2**, cross-checked against real +//! repeaters: 447.275 and 145.310 are minus, 147.360 is plus +//! - an **empty** slot answers [`EMPTY_REPLY`] — `N`, not an error and not a +//! blank line. 962 of 1000 slots answered that way, with zero surprises +//! +//! ⚠ Not measured, and therefore not yet used to build a line from a channel: +//! the **tone and DCS index tables**. The captured lines carry indices (`12`, +//! `08`, `18`) whose meaning nothing here has established — a published table +//! would be a guess about what a number means, and writing a wrong tone to a +//! real repeater is the failure this project has hit most often. Building a +//! `Memory` from an app channel waits on one measurement pass. + +// ⚠ Phase 2 lands the encoder before the path that will call it, so in a +// non-test build every item below is unused. +// +// A `never used` warning on an encoder is normally a **bug report** in this repo +// — it is exactly how the ID-52's dead settings-write path was found, after the +// read path had been working for weeks and hid it. So this is silenced as +// narrowly as possible, with the reason, rather than by habit: nothing here is +// reachable from the app **on purpose**, because no byte has ever been written +// to this radio and the tone tables are unmeasured. The moment a capability +// trait calls into this module, this attribute comes out and the warning +// becomes meaningful again. +#![cfg_attr(not(test), allow(dead_code))] + +/// What the radio answers for a slot with nothing in it. Measured, not assumed. +pub(crate) const EMPTY_REPLY: &str = "N"; + +/// The radio's longest memory name — Menu 200, "up to 8 characters", and the +/// longest in the capture is exactly 8 (`FNL TOWE`, spaces included). +pub(crate) const MAX_NAME: usize = 8; + +/// Repeater shift, as field 4 encodes it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Shift { + Simplex, + Plus, + Minus, + /// Transmit on field 14's frequency instead of an offset. Present in + /// CHIRP's table; **not** seen in the capture, so it is carried through + /// verbatim rather than acted on. + Split, +} + +impl Shift { + fn from_field(f: &str) -> Result { + match f { + "0" => Ok(Shift::Simplex), + "1" => Ok(Shift::Plus), + "2" => Ok(Shift::Minus), + "3" => Ok(Shift::Split), + other => Err(format!("unknown shift {other:?} in field 4")), + } + } + + fn field(self) -> &'static str { + match self { + Shift::Simplex => "0", + Shift::Plus => "1", + Shift::Minus => "2", + Shift::Split => "3", + } + } +} + +/// A memory slot, one member per `ME` parameter, in the radio's own order. +/// +/// Fields whose meaning is not yet established are kept as the **text the radio +/// sent**. That is deliberate: a value carried through verbatim cannot be +/// corrupted by a wrong guess about what it means, and a slot can be read, +/// stored and written back long before every field is understood. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Memory { + pub slot: u16, + pub rx_hz: u64, + pub step: String, + pub shift: Shift, + pub reverse: String, + pub tone_on: String, + pub ctcss_on: String, + pub dcs_on: String, + pub tone_idx: String, + pub ctcss_idx: String, + pub dcs_idx: String, + pub offset_hz: u64, + pub mode: String, + pub tx_hz: u64, + pub tx_step: String, + pub lockout: String, +} + +impl Memory { + /// Parse one `ME` reply. + /// + /// Strict on purpose. A line with the wrong number of fields is a different + /// firmware or a different radio, and guessing which fields moved is how a + /// driver writes a plausible-looking wrong value. `N` — an empty slot — is + /// not a memory and is refused here rather than parsed into a blank one. + pub(crate) fn parse(line: &str) -> Result { + if line == EMPTY_REPLY { + return Err("empty slot".into()); + } + let body = line + .strip_prefix("ME ") + .ok_or_else(|| format!("not an ME reply: {line:?}"))?; + let f: Vec<&str> = body.split(',').collect(); + if f.len() != 16 { + return Err(format!( + "expected 16 fields in an ME reply, got {}: {line:?}", + f.len() + )); + } + let width = |i: usize, want: usize| -> Result<&str, String> { + if f[i].len() == want { + Ok(f[i]) + } else { + Err(format!( + "field {} is {:?}, expected {want} characters", + i + 1, + f[i] + )) + } + }; + let num = |i: usize, want: usize| -> Result { + width(i, want)? + .parse::() + .map_err(|e| format!("field {} is not a number: {e}", i + 1)) + }; + + Ok(Memory { + slot: num(0, 3)? as u16, + rx_hz: num(1, 10)?, + step: width(2, 1)?.into(), + shift: Shift::from_field(width(3, 1)?)?, + reverse: width(4, 1)?.into(), + tone_on: width(5, 1)?.into(), + ctcss_on: width(6, 1)?.into(), + dcs_on: width(7, 1)?.into(), + tone_idx: width(8, 2)?.into(), + ctcss_idx: width(9, 2)?.into(), + dcs_idx: width(10, 3)?.into(), + offset_hz: num(11, 8)?, + mode: width(12, 1)?.into(), + tx_hz: num(13, 10)?, + tx_step: width(14, 1)?.into(), + lockout: width(15, 1)?.into(), + }) + } + + /// Emit the `ME` line. Widths are the radio's, not Rust's defaults — see + /// the module doc on why `0` and `000` are not interchangeable here. + pub(crate) fn to_line(&self) -> String { + format!( + "ME {:03},{:010},{},{},{},{},{},{},{},{},{},{:08},{},{:010},{},{}", + self.slot, + self.rx_hz, + self.step, + self.shift.field(), + self.reverse, + self.tone_on, + self.ctcss_on, + self.dcs_on, + self.tone_idx, + self.ctcss_idx, + self.dcs_idx, + self.offset_hz, + self.mode, + self.tx_hz, + self.tx_step, + self.lockout + ) + } +} + +/// A memory's name, which the radio keeps in a separate command from the +/// memory itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MemoryName { + pub slot: u16, + pub text: String, +} + +impl MemoryName { + pub(crate) fn parse(line: &str) -> Result { + let body = line + .strip_prefix("MN ") + .ok_or_else(|| format!("not an MN reply: {line:?}"))?; + let (slot, text) = body + .split_once(',') + .ok_or_else(|| format!("no name field in {line:?}"))?; + if slot.len() != 3 { + return Err(format!("slot {slot:?} is not 3 digits")); + } + Ok(MemoryName { + slot: slot.parse().map_err(|e| format!("slot: {e}"))?, + text: text.to_string(), + }) + } + + pub(crate) fn to_line(&self) -> String { + format!("MN {:03},{}", self.slot, self.text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Real lines off Tim's TM-D710A, 2026-08-22. Kept verbatim: the point of + /// the gate is that these exact characters survive a round trip, so a + /// tidied-up copy would test nothing. (Repeater frequencies and call signs + /// are public record.) + const REAL: &[(&str, &str)] = &[ + ( + "ME 000,0447275000,0,2,0,0,1,0,12,12,000,05000000,0,0000000000,0,0", + "MN 000,W0UPS", + ), + ( + "ME 007,0147360000,0,1,0,0,1,0,12,12,000,00600000,0,0000000000,0,0", + "MN 007,W0QEY", + ), + ( + "ME 009,0145310000,0,2,0,0,1,0,08,18,000,00600000,0,0000000000,0,0", + "MN 009,KB0VJJ", + ), + ( + "ME 005,0224840000,0,2,0,0,1,0,12,12,000,01600000,0,0000000000,0,0", + "MN 005,W0UPS", + ), + ]; + + /// ★ The Phase 2 gate. A memory read off the radio must come back out as + /// the identical line — the live-mode form of the byte-identical re-encode + /// that has caught a real bug on every radio in this project. + #[test] + fn a_real_memory_re_emits_character_identically() { + for (me, mn) in REAL { + let parsed = Memory::parse(me).unwrap_or_else(|e| panic!("{me}: {e}")); + assert_eq!(&parsed.to_line(), me); + let name = MemoryName::parse(mn).unwrap_or_else(|e| panic!("{mn}: {e}")); + assert_eq!(&name.to_line(), mn); + } + } + + /// The shift decode, checked against what the repeaters actually are rather + /// than against the documentation that describes them. + #[test] + fn shift_matches_the_real_repeaters() { + // 447.275 UHF, 5 MHz down. + let uhf = Memory::parse(REAL[0].0).unwrap(); + assert_eq!(uhf.shift, Shift::Minus); + assert_eq!(uhf.offset_hz, 5_000_000); + // 147.360, 600 kHz up — the one plus-shift channel in the capture. + let vhf = Memory::parse(REAL[1].0).unwrap(); + assert_eq!(vhf.shift, Shift::Plus); + assert_eq!(vhf.offset_hz, 600_000); + // 224.840, 1.6 MHz down — the 220 band's own offset. + let band220 = Memory::parse(REAL[3].0).unwrap(); + assert_eq!(band220.shift, Shift::Minus); + assert_eq!(band220.offset_hz, 1_600_000); + } + + /// Tone and CTCSS are separate fields with separate indices, so a driver + /// that reads one into both would corrupt this slot. ME 009 is the proof: + /// the two differ. + #[test] + fn tone_and_ctcss_indices_are_independent() { + let m = Memory::parse(REAL[2].0).unwrap(); + assert_eq!(m.tone_idx, "08"); + assert_eq!(m.ctcss_idx, "18"); + assert_ne!(m.tone_idx, m.ctcss_idx); + } + + /// An empty slot is `N`, and it is not a memory. Refusing it here is what + /// stops 962 of Tim's 1000 slots turning into blank channels. + #[test] + fn an_empty_slot_is_refused_rather_than_parsed_blank() { + let err = Memory::parse(EMPTY_REPLY).unwrap_err(); + assert!(err.contains("empty"), "{err}"); + } + + /// Strictness, field by field: a short line, a wrong-width field and an + /// unknown shift are all refused with the field named. A driver that + /// shrugs these off writes a plausible wrong value to a real radio. + #[test] + fn a_malformed_line_is_refused_and_says_which_field() { + assert!(Memory::parse("ME 000,0447275000,0").unwrap_err().contains("16 fields")); + let short_slot = "ME 00,0447275000,0,2,0,0,1,0,12,12,000,05000000,0,0000000000,0,0"; + assert!(Memory::parse(short_slot).unwrap_err().contains("field 1")); + let bad_shift = "ME 000,0447275000,0,9,0,0,1,0,12,12,000,05000000,0,0000000000,0,0"; + assert!(Memory::parse(bad_shift).unwrap_err().contains("shift")); + assert!(Memory::parse("MN 000,W0UPS").unwrap_err().contains("not an ME")); + } + + /// Zero padding is not cosmetic. Slot 7 is `007`, and an offset of 600 kHz + /// is eight characters — a driver that emitted `7` or `600000` would send a + /// line the radio parses differently. + #[test] + fn widths_are_preserved_not_normalised() { + let m = Memory::parse(REAL[1].0).unwrap(); + assert_eq!(m.slot, 7); + let line = m.to_line(); + assert!(line.starts_with("ME 007,"), "{line}"); + assert!(line.contains(",00600000,"), "{line}"); + } + + /// Names can carry a space and can be the full 8 characters, so neither + /// trimming nor a shorter cap is safe. + #[test] + fn a_name_keeps_its_spaces_and_its_full_width() { + let n = MemoryName::parse("MN 012,FNL TOWE").unwrap(); + assert_eq!(n.text, "FNL TOWE"); + assert_eq!(n.text.len(), MAX_NAME); + assert_eq!(n.to_line(), "MN 012,FNL TOWE"); + } + + /// The whole capture, when it is on this machine. Gitignored, so this is a + /// no-op in CI and on anyone else's checkout — the four lines above are the + /// part that always runs. See the `test-the-gate-against-real-files` note: + /// the real corpus answers questions a handful of samples cannot. + #[test] + fn every_captured_memory_re_emits_identically() { + let Ok(text) = std::fs::read_to_string("../scratchpad/kenwood_tmd710/memories.txt") else { + return; + }; + let mut checked = 0; + for line in text.lines().filter(|l| !l.starts_with('#')) { + let round_tripped = if line.starts_with("ME ") { + Memory::parse(line).unwrap_or_else(|e| panic!("{line}: {e}")).to_line() + } else { + MemoryName::parse(line).unwrap_or_else(|e| panic!("{line}: {e}")).to_line() + }; + assert_eq!(round_tripped, line); + checked += 1; + } + assert!(checked >= 76, "expected the 38 captured slots, saw {checked} lines"); + } +} diff --git a/src-tauri/src/radios/kenwood_tmd710/mod.rs b/src-tauri/src/radios/kenwood_tmd710/mod.rs new file mode 100644 index 0000000..bebf6e0 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/mod.rs @@ -0,0 +1,292 @@ +//! Kenwood TM-D710A — live-mode command driver (issue #113). +//! +//! **This is the fourth programming modality in the app.** The others clone a +//! whole image (UV-5R, TD-H3), write binary records at flash addresses +//! (AnyTone), or patch a file the radio wrote to a microSD card (FT5D, ID-52, +//! TH-D75). The TM-D710 does none of those: the PC sends one ASCII command per +//! memory, `\r` terminated, and the radio answers in kind. +//! +//! ```text +//! ID -> ID TM-D710 +//! ME 000 -> ME 000,0447275000,0,2,0,0,1,0,12,12,000,05000000,0,0000000000,0,0 +//! ME 999 -> N (an empty slot) +//! MU -> MU 0,4,0,… (all 42 menu settings, one line) +//! ``` +//! +//! Two consequences worth stating before anyone extends this: +//! +//! - **A write is not atomic.** Every other radio here commits an image; this +//! one commits a memory at a time, so a failure halfway leaves the radio +//! half-programmed. Nothing in this module writes yet, and whatever does will +//! need to say where it stopped. +//! - **There is no image to back up.** The equivalent is a transcript of the +//! radio's own `ME`/`MU` lines. +//! +//! ## Measured on the radio, 2026-08-22 +//! +//! Tim's TM-D710A on an RT Systems cable, COM port on the rear of the operation +//! panel. Full notes in `scratchpad/kenwood_tmd710/FINDINGS.md`. +//! +//! | | | +//! |---|---| +//! | Baud | **57600** — CHIRP's driver assumes 9600; this radio is silent there | +//! | Round trip | 17 ms; all 1000 slots in 17.2 s | +//! | Empty slot | answers `N` | +//! | Identity | `ID TM-D710` | +//! +//! ⚠ **The first command after opening the port can answer `?`.** Seen during +//! the rate sweep: a wrong-rate write left the radio's parser mid-garbage and it +//! errored the next well-formed line. So one `?` is not a refusal — see +//! [`ask_settling`]. +//! +//! ## Capabilities: none yet, deliberately +//! +//! This driver identifies and nothing else, the same scaffolding stance the +//! FT5D was registered under. `memory.rs` can already read and re-emit a slot, +//! but **nothing has ever been written to this radio**, and the tone and DCS +//! index tables are still unmeasured. A capability trait here would put a +//! "Program radio" button in front of an operator for a path no one has proven. + +use serialport::SerialPort; +use std::time::{Duration, Instant}; + +use super::driver::{RadioDriver, RadioIdentity}; + +pub(crate) mod memory; + +/// Menu 528 on this radio sets it. 57600 is what Tim's is on and what the +/// capture ran at; the driver does not sweep, because a rate mismatch here is +/// an operator setting to fix, not something to paper over. +pub(crate) const BAUD: u32 = 57600; + +/// What the radio answers when it cannot parse a command. +const ERROR_REPLY: &str = "?"; + +/// Long enough for the radio to answer at 17 ms, short enough that a wrong port +/// fails while the operator is still looking at the screen. +const REPLY_TIMEOUT: Duration = Duration::from_millis(1500); + +fn open_port(port: &str) -> Result, String> { + serialport::new(port, BAUD) + .data_bits(serialport::DataBits::Eight) + .parity(serialport::Parity::None) + .stop_bits(serialport::StopBits::One) + .flow_control(serialport::FlowControl::None) + .timeout(Duration::from_millis(700)) + .open() + .map_err(|e| format!("could not open {port} at {BAUD} baud: {e}")) +} + +/// Send one command and return the radio's reply, without its terminator. +/// +/// `?` becomes an error naming the command that drew it. `N` is returned as-is: +/// it is a legitimate answer meaning "nothing here", and only the caller knows +/// whether that is a problem. +pub(crate) fn ask(p: &mut dyn SerialPort, cmd: &str) -> Result { + let _ = p.clear(serialport::ClearBuffer::All); + p.write_all(format!("{cmd}\r").as_bytes()) + .map_err(|e| format!("sending {cmd:?}: {e}"))?; + p.flush().map_err(|e| format!("sending {cmd:?}: {e}"))?; + + let mut reply = Vec::new(); + let deadline = Instant::now() + REPLY_TIMEOUT; + let mut byte = [0u8; 1]; + while Instant::now() < deadline { + match p.read(&mut byte) { + Ok(0) => continue, + Ok(_) if byte[0] == b'\r' => { + let text = String::from_utf8_lossy(&reply).into_owned(); + return if text == ERROR_REPLY { + Err(format!( + "the radio did not understand {cmd:?}. On a TM-D710 that usually means \ + the command is not one this model has, or the previous command left the \ + port mid-line." + )) + } else { + Ok(text) + }; + } + Ok(_) => reply.push(byte[0]), + Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => break, + Err(e) => return Err(format!("reading the reply to {cmd:?}: {e}")), + } + } + Err(format!( + "no reply to {cmd:?} within {} ms. Check the cable is in the COM port on the rear of the \ + operation panel — not the DATA jack — and that Menu 528 (COM PORT SPEED) is {BAUD}.", + REPLY_TIMEOUT.as_millis() + )) +} + +/// [`ask`], tolerating one `?` first. +/// +/// Measured behaviour, not defensive coding: during the rate sweep the radio +/// answered a well-formed `ID` with `?` because the preceding wrong-rate write +/// had left its parser mid-line. Every session therefore starts with one +/// throwaway, and a second `?` is a real refusal. +pub(crate) fn ask_settling(p: &mut dyn SerialPort, cmd: &str) -> Result { + match ask(p, cmd) { + Ok(reply) => Ok(reply), + Err(_) => ask(p, cmd), + } +} + +pub(crate) struct KenwoodTmD710; + +pub(crate) static DRIVER: KenwoodTmD710 = KenwoodTmD710; + +impl RadioDriver for KenwoodTmD710 { + fn key(&self) -> &'static str { + "kenwood_tmd710" + } + + fn display_name(&self) -> &'static str { + "Kenwood TM-D710" + } + + fn baud(&self) -> u32 { + BAUD + } + + /// Ask the radio what it is. Reads no memory and changes nothing, so it is + /// the safe first thing an operator can try with a new cable. + /// + /// The reply is matched loosely — `TM-D710` covers the D710A and D710E, + /// which answer identically. The **G** is a different radio with a menu set + /// this driver has not measured, so it is named and refused rather than + /// quietly accepted. + fn identify(&self, port: &str) -> Result { + let mut p = open_port(port)?; + let reply = ask_settling(&mut *p, "ID")?; + let model = reply + .strip_prefix("ID ") + .ok_or_else(|| format!("unexpected answer to ID: {reply:?}"))? + .to_string(); + + if model == "TM-D710G" { + return Err( + "this is a TM-D710G. Only the TM-D710 (non-G) has been measured — the G has a \ + different menu set, and programming it from this driver would write settings \ + nobody has checked against it (issue #113)." + .into(), + ); + } + if model != "TM-D710" { + return Err(format!( + "expected a TM-D710 on this port, but it says {model:?}." + )); + } + + Ok(RadioIdentity { + matched: model.clone(), + ident_hex: reply + .as_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(" "), + ident_ascii: Some(reply), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::radios::fake_port::{FakePort, FakeRadio}; + + /// A TM-D710 at the far end of the cable, answering the commands the + /// capture proved it answers. + struct FakeD710 { + model: &'static str, + /// Answer the first command with `?` regardless — the settling + /// behaviour measured during the rate sweep. + garbled_first: bool, + pub seen: Vec, + } + + impl FakeD710 { + fn new() -> Self { + Self { + model: "TM-D710", + garbled_first: false, + seen: Vec::new(), + } + } + } + + impl FakeRadio for FakeD710 { + fn step(&mut self, req: &[u8], out: &mut Vec) -> usize { + let Some(end) = req.iter().position(|&b| b == b'\r') else { + return 0; + }; + let cmd = String::from_utf8_lossy(&req[..end]).into_owned(); + self.seen.push(cmd.clone()); + + let reply = if self.garbled_first && self.seen.len() == 1 { + "?".to_string() + } else if cmd == "ID" { + format!("ID {}", self.model) + } else if cmd == "ME 999" { + memory::EMPTY_REPLY.to_string() + } else if cmd == "ME 000" { + "ME 000,0447275000,0,2,0,0,1,0,12,12,000,05000000,0,0000000000,0,0".to_string() + } else { + "?".to_string() + }; + out.extend_from_slice(reply.as_bytes()); + out.push(b'\r'); + end + 1 + } + } + + #[test] + fn a_command_gets_its_reply_without_the_terminator() { + let mut p = FakePort::new(FakeD710::new()); + assert_eq!(ask(&mut p, "ID").unwrap(), "ID TM-D710"); + assert_eq!(ask(&mut p, "ME 999").unwrap(), memory::EMPTY_REPLY); + } + + /// `?` is an error and names the command, so a driver bug reads as a driver + /// bug rather than as a silent empty result. + #[test] + fn an_error_reply_names_the_command_that_drew_it() { + let mut p = FakePort::new(FakeD710::new()); + let err = ask(&mut p, "NOPE").unwrap_err(); + assert!(err.contains("NOPE"), "{err}"); + assert!(err.contains("did not understand"), "{err}"); + } + + /// ★ The measured settling behaviour. One `?` on the first command is + /// survivable; the retry is what makes a fresh session work. + #[test] + fn one_error_on_the_first_command_is_retried_not_failed() { + let mut radio = FakeD710::new(); + radio.garbled_first = true; + let mut p = FakePort::new(radio); + assert_eq!(ask_settling(&mut p, "ID").unwrap(), "ID TM-D710"); + assert_eq!(p.radio.seen, vec!["ID", "ID"]); + } + + /// …but a second `?` is a real refusal, so a genuinely unknown command + /// still fails instead of retrying forever. + #[test] + fn a_persistent_error_still_fails() { + let mut p = FakePort::new(FakeD710::new()); + assert!(ask_settling(&mut p, "NOPE").is_err()); + } + + /// The G is a different radio. Refusing it by name beats programming it + /// with a menu table measured on the non-G. + #[test] + fn a_d710g_is_named_and_refused() { + let mut radio = FakeD710::new(); + radio.model = "TM-D710G"; + let mut p = FakePort::new(radio); + let reply = ask(&mut p, "ID").unwrap(); + assert_eq!(reply, "ID TM-D710G"); + // identify() itself needs a real port; the refusal it applies to this + // reply is the branch under test, so exercise the same condition. + assert!(reply.strip_prefix("ID ").unwrap() == "TM-D710G"); + } +} diff --git a/src-tauri/src/radios/kenwood_tmd710_probe.rs b/src-tauri/src/radios/kenwood_tmd710_probe.rs new file mode 100644 index 0000000..aea1757 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710_probe.rs @@ -0,0 +1,283 @@ +//! Phase 1 capture harness for the Kenwood TM-D710A (issue #113). +//! +//! This is a **measuring instrument, not driver code**. It exists to answer the +//! four questions the plan in `scratchpad/kenwood_tmd710/PLAN.md` says must be +//! answered before a line of driver is written, and it is `#[cfg(test)]` + +//! `#[ignore]`d so `cargo test` stays hardware-free. +//! +//! ## Why this radio needs a different harness from every other one here +//! +//! The TM-D710 is a **live-mode** radio. There is no clone image and no card +//! file: the PC sends one ASCII command per memory, terminated by `\r`, and the +//! radio answers in kind. So the thing to capture is a **transcript**, and the +//! Phase 2 gate is re-emitting these lines character-identically — the same gate +//! as a byte-identical re-encode, on a different substrate. +//! +//! ## Everything sent here is a query. Nothing can change the radio. +//! +//! On this protocol a command **with no parameter list** reads, and the same +//! command **with** one writes. Every command in `QUERIES` below is the bare +//! form. That is the whole safety argument, so the list is explicit and short +//! rather than assembled at runtime: +//! +//! - `ID` — model string the radio calls itself +//! - `TY` — type/variant +//! - `FV 0` — firmware version of unit 0 +//! - `MU` — **all 42 menu parameters in one line**, per LA3QMA's `MU.md` +//! - `ME nnn` — memory channel `nnn`, 16 comma-separated fields +//! - `MN nnn` — memory channel `nnn`'s name +//! +//! ⚠ `TX` is a command on this radio and it **keys the transmitter**. It is not +//! in the list and must never be. Nor is `MC`, which moves the radio's current +//! channel — harmless but it changes state under the operator. +//! +//! ## Running it +//! +//! Radio on, RT Systems cable into the PC port on the **main unit** (not the +//! control head). From `src-tauri/`: +//! +//! ```text +//! D710_PORT=/dev/cu.usbserial-XXXX cargo test --lib d710_find_the_radio -- --ignored --nocapture +//! D710_PORT=/dev/cu.usbserial-XXXX D710_BAUD=9600 cargo test --lib d710_capture -- --ignored --nocapture +//! ``` +//! +//! One radio operation per process, per the `hw-test-harness-pattern` note. + +use serialport::SerialPort; +use std::time::{Duration, Instant}; + +/// The bare, parameter-less forms. See the module doc: this list *is* the +/// safety argument, so it is written out rather than built. +const QUERIES: &[&str] = &["ID", "TY", "AI", "MU", "MS", "FV"]; + +/// Rates the PC port offers (menu 519 on this family). CHIRP's driver assumes +/// 9600; AG7GN's CLI defaults to 57600. Neither is evidence about *this* radio, +/// so all four get tried. +const RATES: &[u32] = &[9600, 19200, 38400, 57600]; + +fn port_path() -> String { + std::env::var("D710_PORT") + .expect("set D710_PORT to the cable's /dev/cu.* path (ls /dev/cu.*)") +} + +/// Open with no flow control. +/// +/// ⚠ The rate is **not** verified by reading it back: `baud_rate()` echoes the +/// value that was set, on some adapters even when the hardware ignored it, so it +/// proves nothing (see the `verify-hardware-claims-not-reports` note). Here that +/// does not matter — the reply is ASCII, so a wrong rate produces visible +/// garbage rather than a plausible-looking answer. That is the check. +fn open(port: &str, rate: u32) -> Result, String> { + serialport::new(port, rate) + .data_bits(serialport::DataBits::Eight) + .parity(serialport::Parity::None) + .stop_bits(serialport::StopBits::One) + .flow_control(serialport::FlowControl::None) + .timeout(Duration::from_millis(700)) + .open() + .map_err(|e| format!("could not open {port} at {rate}: {e}")) +} + +/// Send one command and read the reply up to its `\r`. +/// +/// Returns the raw bytes as well as the lossy string: at a wrong baud rate the +/// bytes are the interesting half, and a reply that is not valid UTF-8 is itself +/// the finding. +fn ask(p: &mut dyn SerialPort, cmd: &str) -> Result<(String, Vec), String> { + let _ = p.clear(serialport::ClearBuffer::All); + p.write_all(format!("{cmd}\r").as_bytes()) + .map_err(|e| format!("write {cmd}: {e}"))?; + p.flush().map_err(|e| format!("flush {cmd}: {e}"))?; + + let mut raw = Vec::new(); + let deadline = Instant::now() + Duration::from_millis(1500); + let mut byte = [0u8; 1]; + while Instant::now() < deadline { + match p.read(&mut byte) { + Ok(0) => continue, + Ok(_) => { + if byte[0] == b'\r' { + break; + } + raw.push(byte[0]); + } + Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => break, + Err(e) => return Err(format!("read after {cmd}: {e}")), + } + } + Ok((String::from_utf8_lossy(&raw).into_owned(), raw)) +} + +/// Sweep the four PC-port rates asking `ID`, and print what comes back. +/// +/// A reply containing `TM-D710` at exactly one rate settles both the rate and +/// the model in one pass. **Silence at every rate is the RT Systems cable +/// question**, not a protocol question: those cables carry FTDI chips programmed +/// with RT Systems' own USB VID/PID. If nothing enumerated as `/dev/cu.*` at +/// all, this test cannot even start, which is the same answer arriving earlier. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_find_the_radio() { + let path = port_path(); + println!("\n=== TM-D710 rate sweep on {path} ===\n"); + let mut found = Vec::new(); + for &rate in RATES { + match open(&path, rate) { + Err(e) => println!("{rate:>6}: {e}"), + Ok(mut p) => match ask(&mut *p, "ID") { + Err(e) => println!("{rate:>6}: {e}"), + Ok((_, raw)) if raw.is_empty() => println!("{rate:>6}: (silence)"), + Ok((text, raw)) => { + println!("{rate:>6}: {text:?} raw={raw:02x?}"); + if text.contains("TM-D") || text.contains("TM-V") { + found.push((rate, text)); + } + } + }, + } + std::thread::sleep(Duration::from_millis(200)); + } + println!("\n--- radio answered at: {found:?}\n"); + assert!( + !found.is_empty(), + "no rate produced an ID reply naming a Kenwood. Before reading anything into this: is \ + the cable in the PC port on the MAIN unit rather than the control head, and does the \ + port enumerate at all? An RT Systems cable's FTDI carries their own VID/PID and may \ + not bind a driver here." + ); +} + +/// Capture the transcript Phase 2 will be built against: identity, the whole +/// menu line, and the first memories the radio already holds. +/// +/// Writes `scratchpad/kenwood_tmd710/capture-.txt` — gitignored, and the +/// anchor every later claim gets checked against. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_capture() { + let path = port_path(); + let rate: u32 = std::env::var("D710_BAUD") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(9600); + let mut p = open(&path, rate).expect("open"); + + let mut log = String::new(); + log.push_str(&format!("# TM-D710 capture — {path} @ {rate} baud\n")); + + for cmd in QUERIES { + let (text, raw) = ask(&mut *p, cmd).expect("query"); + println!("{cmd:>6} -> {text}"); + log.push_str(&format!("{cmd}\t{text}\traw={raw:02x?}\n")); + } + + // The first ten memories, both record and name. Ten is enough to see the + // field shape and to spot an empty slot's encoding without a long session. + for ch in 0..10 { + for cmd in [format!("ME {ch:03}"), format!("MN {ch:03}")] { + let (text, _) = ask(&mut *p, &cmd).expect("memory query"); + println!("{cmd:>7} -> {text}"); + log.push_str(&format!("{cmd}\t{text}\n")); + } + } + + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let out = format!("../scratchpad/kenwood_tmd710/capture-{stamp}.txt"); + std::fs::write(&out, &log).expect("write transcript"); + println!("\n--- wrote {out}\n"); +} + +/// Read `MU` alone and append it to `scratchpad/kenwood_tmd710/mu-log.txt`, +/// labelled with `D710_LABEL`. +/// +/// The unit of work for Phase 4: **one** menu item changed on the front panel +/// between two runs, so every field that moves can be attributed to it. Two +/// controls that both go `0 -> 1` in the same pass cannot be told apart, and +/// attributing them by position is how a previous radio shipped two exactly +/// swapped fields. +/// +/// The first run of all is the noise floor — read twice with nothing changed. +/// If any field moves on its own, every later attribution is worthless, so this +/// gets established before a single value is read into. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_mu() { + let path = port_path(); + let rate: u32 = std::env::var("D710_BAUD") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(57600); + let label = std::env::var("D710_LABEL").unwrap_or_else(|_| "unlabelled".into()); + let mut p = open(&path, rate).expect("open"); + + // ⚠ The first command after opening can draw a bare `?`: the rate sweep left + // the radio's parser mid-garbage and it answered the next line with an + // error. Ask twice and keep the second — and note that a real driver will + // need the same retry rather than treating one `?` as a refusal. + let _ = ask(&mut *p, "ID"); + let (text, _) = ask(&mut *p, "MU").expect("MU"); + + let fields: Vec<&str> = text.trim_start_matches("MU ").split(',').collect(); + println!("\n{label}: {} fields\n{text}\n", fields.len()); + for (i, f) in fields.iter().enumerate() { + print!("p{}={} ", i + 1, f); + } + println!(); + + let log = "../scratchpad/kenwood_tmd710/mu-log.txt"; + let mut all = std::fs::read_to_string(log).unwrap_or_default(); + all.push_str(&format!("{label}\t{text}\n")); + std::fs::write(log, all).expect("write mu log"); +} + +/// Read every memory slot and record three things Phase 2 cannot be written +/// without: the **full transcript** (its re-emit is the gate), how an **empty** +/// slot answers, and how long 1000 round trips actually take. +/// +/// Needs nobody at the radio — just the cable — so it costs no operator time. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_dump_memories() { + let path = port_path(); + let rate: u32 = std::env::var("D710_BAUD") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(57600); + let mut p = open(&path, rate).expect("open"); + let _ = ask(&mut *p, "ID"); + + let started = Instant::now(); + let mut log = String::new(); + let (mut populated, mut empty, mut other) = (0usize, 0usize, Vec::new()); + + for ch in 0..1000 { + let (text, _) = ask(&mut *p, &format!("ME {ch:03}")).expect("ME"); + if text.starts_with("ME ") { + populated += 1; + let (name, _) = ask(&mut *p, &format!("MN {ch:03}")).expect("MN"); + log.push_str(&format!("{text}\n{name}\n")); + } else if text == "N" { + empty += 1; + } else { + other.push((ch, text.clone())); + log.push_str(&format!("# ch {ch}: unexpected reply {text:?}\n")); + } + } + + let elapsed = started.elapsed(); + println!("\n=== {populated} populated, {empty} empty, {} other", other.len()); + for (ch, t) in other.iter().take(10) { + println!(" ch {ch}: {t:?}"); + } + println!( + "=== {:.1}s for {} round trips ({:.0} ms each)\n", + elapsed.as_secs_f64(), + 1000 + populated, + elapsed.as_millis() as f64 / (1000 + populated) as f64 + ); + + std::fs::write("../scratchpad/kenwood_tmd710/memories.txt", &log).expect("write"); +} diff --git a/src-tauri/src/radios/mod.rs b/src-tauri/src/radios/mod.rs index ca5876e..c80d420 100644 --- a/src-tauri/src/radios/mod.rs +++ b/src-tauri/src/radios/mod.rs @@ -34,6 +34,12 @@ pub(crate) mod fake_port; pub(crate) mod icom_id52; pub(crate) mod kenwood_thd72; pub(crate) mod kenwood_thd75; +pub(crate) mod kenwood_tmd710; +/// Phase 1 capture harness for the TM-D710 (#113). Throwaway, like the FT5D's +/// `hw_probe`: it measures, it is never linked into the app, and it goes when +/// the driver it is informing exists. +#[cfg(test)] +mod kenwood_tmd710_probe; pub(crate) mod port_lock; pub(crate) mod registry; pub(crate) mod settings_bounds; diff --git a/src-tauri/src/radios/registry.rs b/src-tauri/src/radios/registry.rs index 1d6c18b..b01ab93 100644 --- a/src-tauri/src/radios/registry.rs +++ b/src-tauri/src/radios/registry.rs @@ -32,6 +32,7 @@ static DRIVERS: [&dyn RadioDriver; 8] = [ &super::icom_id52::DRIVER, &super::kenwood_thd75::DRIVER, &super::kenwood_thd72::DRIVER, + &super::kenwood_tmd710::DRIVER, ]; pub(crate) fn all_drivers() -> &'static [&'static dyn RadioDriver] { @@ -82,6 +83,8 @@ mod tests { "yaesu_ft5d", "icom_id52", "kenwood_thd75", + "kenwood_thd72", + "kenwood_tmd710", ] { let d = driver_for_key(key).unwrap_or_else(|| panic!("no driver for '{key}'")); assert_eq!(d.key(), key); @@ -129,6 +132,14 @@ mod tests { // caught anywhere downstream — this radio stored 127 in fields // whose maxima are 9, 2, 3 and 1 (issue #43). "binteradio_bt9000" => (true, true), + // The TM-D710 reads its whole menu in one `MU` line and could + // claim SettingsReader today. It does not, because the same + // capability flag would put a settings *write* in front of an + // operator, and **nothing has ever been written to this radio** + // (issue #113). Two of the published menu enums were already + // wrong when checked against the hardware. Identify only until + // the ladder in the `new-radio` skill has been climbed. + "kenwood_tmd710" => (false, false), _ => (true, true), }; assert_eq!( From a1772b9dd8214e8176abf2b8db51f8822cdb76ee Mon Sep 17 00:00:00 2001 From: ww8l Date: Sat, 22 Aug 2026 16:22:03 -0600 Subject: [PATCH 02/15] TM-D710: the write path, verified by read-back (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tim's counter-proposal, and a better shape than mine: build the write path first, then changes are made from here and verified on the radio's screen — instead of him setting things by hand for me to read back. Every write proves itself by reading the slot back and comparing the whole line. That is not belt-and-braces, it is the only evidence available: this radio has no checksum and no commit step, so a malformed line draws `?` but a well-formed line the radio interprets differently draws nothing at all. The D890UV had a settings field the firmware owned that silently reverted after a write; read-back is what makes that visible rather than a lie in the report. - `write_memory` / `write_name` — write, re-read, refuse to claim success unless the line comes back identical - `write_menu` — `MU` sets all 42 parameters at once, so it returns which ones did NOT take rather than throwing. A field the firmware owns is a finding, not an exception - `Menu` — the 42-field line, kept as text. `with_field` pads to the width the radio used, because `8` where the radio said `08` shifts every field after it: one badly formatted field is not one wrong setting, it is potentially forty-two. A too-wide value is refused - `d710_restore` — puts the radio back from the captured transcript. This is what makes writing to a real radio a reasonable thing to do at all HARDWARE, on Tim's radio: Ladder step 1 (identity write) PASSED. Slot 000 read, written back identically, re-read: byte-for-byte the same line. Ladder step 2 in flight: slots 500-503 were empty (`N`) and now hold known tone and DCS values for Tim to read off the radio's own screen. Nothing populated was touched. Also measured, free of radio time: joining the radio's 38 memories to Tim's channel library on frequency AND callsign, field 9 (Tone) predicts the library's TX tone 33 right / 0 wrong under "standard 42-tone CTCSS list, 0-indexed", while field 10 (CTCSS) gets 27/6 — so field 9 is the transmit tone and field 10 is a separate squelch tone. The ENDS of the table are what slots 500/501 are for; DCS is unmeasured entirely, which is what 502/503 are for. npm run ci green: 453 tests, clippy clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013cZgbB4FwqZ3Z4cdHt6vMH --- src-tauri/src/radios/kenwood_tmd710/memory.rs | 143 +++++++++++++++++ src-tauri/src/radios/kenwood_tmd710/mod.rs | 128 ++++++++++++++- src-tauri/src/radios/kenwood_tmd710_probe.rs | 151 ++++++++++++++++++ 3 files changed, 420 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/radios/kenwood_tmd710/memory.rs b/src-tauri/src/radios/kenwood_tmd710/memory.rs index dd7f035..a456f56 100644 --- a/src-tauri/src/radios/kenwood_tmd710/memory.rs +++ b/src-tauri/src/radios/kenwood_tmd710/memory.rs @@ -358,3 +358,146 @@ mod tests { assert!(checked >= 76, "expected the 38 captured slots, saw {checked} lines"); } } + +/// The radio's whole menu, as the single `MU` line carries it. +/// +/// 42 comma-separated parameters, measured on the radio — the count and the +/// order both. `p1` is Menu 000 KEY BEEP and `p26` is Menu 501 BRIGHTNESS, each +/// pinned by changing that one control and watching that one field move. +/// +/// Fields are kept as **text**, never as numbers, for the same reason memories +/// are: `p29`–`p34` (the PF key assignments) are two-digit **hex**, and the +/// widths are part of the line. A field re-emitted as `8` where the radio said +/// `08` is a different line. +/// +/// ⚠ `MU` is **not** exhaustive. p28 is Menu 503 and p29 is Menu 507, so Menus +/// 504 CONTRAST, 505 DISPLAY REVERSE and 506 have no parameter here at all. +/// A menu missing from this line cannot be read or written through it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Menu { + fields: Vec, +} + +/// Measured on the radio: the `MU` line carries exactly this many parameters. +pub(crate) const MENU_FIELDS: usize = 42; + +impl Menu { + pub(crate) fn parse(line: &str) -> Result { + let body = line + .strip_prefix("MU ") + .ok_or_else(|| format!("not an MU reply: {line:?}"))?; + let fields: Vec = body.split(',').map(str::to_string).collect(); + if fields.len() != MENU_FIELDS { + return Err(format!( + "expected {MENU_FIELDS} menu fields, got {} — this is a different model or a \ + different firmware, and guessing which fields moved is how a wrong value gets \ + written to a real radio", + fields.len() + )); + } + Ok(Menu { fields }) + } + + pub(crate) fn to_line(&self) -> String { + format!("MU {}", self.fields.join(",")) + } + + /// One parameter, 1-based to match `p1`…`p42` as everything documenting + /// this radio numbers them. + pub(crate) fn field(&self, p: usize) -> Result<&str, String> { + self.fields + .get(p.wrapping_sub(1)) + .map(String::as_str) + .ok_or_else(|| format!("p{p} is outside the {MENU_FIELDS} menu fields")) + } + + /// A copy with one parameter changed, **padded to the width the radio + /// used**. + /// + /// The padding is the point. Writing `8` where the radio said `08` sends a + /// line whose fields no longer line up, and this command sets all 42 at + /// once — so one badly formatted field is not one wrong setting, it is + /// potentially forty-two. + pub(crate) fn with_field(&self, p: usize, value: &str) -> Result { + let current = self.field(p)?; + if value.len() > current.len() { + return Err(format!( + "p{p} is {} characters on this radio ({current:?}); {value:?} is wider and would \ + shift every field after it", + current.len() + )); + } + let mut fields = self.fields.clone(); + fields[p - 1] = format!("{value:0>width$}", width = current.len()); + Ok(Menu { fields }) + } + + /// Which parameters differ, as `(p, mine, theirs)`. The basis of every + /// measurement pass: change one control, diff, and exactly one row should + /// come back. + pub(crate) fn diff(&self, other: &Menu) -> Vec<(usize, String, String)> { + self.fields + .iter() + .zip(&other.fields) + .enumerate() + .filter(|(_, (a, b))| a != b) + .map(|(i, (a, b))| (i + 1, a.clone(), b.clone())) + .collect() + } +} + +#[cfg(test)] +mod menu_tests { + use super::*; + + /// The real line off Tim's radio, before anything was changed. + const REAL_MU: &str = "MU 0,4,0,1,0,4,1,0,10,0,0,0,0,0,0,2,0,0,0,0,2,0,1,0,0,8,0,0,00,02,14,15,0C,0E,0,1,0,1,0,4,1,1"; + + #[test] + fn the_real_menu_line_re_emits_identically() { + let m = Menu::parse(REAL_MU).unwrap(); + assert_eq!(m.to_line(), REAL_MU); + assert_eq!(m.field(1).unwrap(), "0"); // Menu 000 KEY BEEP, off + assert_eq!(m.field(26).unwrap(), "8"); // Menu 501 BRIGHTNESS, level 8 + assert_eq!(m.field(33).unwrap(), "0C"); // a PF key, in hex + } + + /// ★ The measured pair. Turning KEY BEEP on moved p1 and nothing else; + /// setting BRIGHTNESS to LEVEL 3 moved p26 and nothing else. + #[test] + fn the_two_measured_changes_move_exactly_one_field_each() { + let before = Menu::parse(REAL_MU).unwrap(); + let beep_on = Menu::parse("MU 1,4,0,1,0,4,1,0,10,0,0,0,0,0,0,2,0,0,0,0,2,0,1,0,0,8,0,0,00,02,14,15,0C,0E,0,1,0,1,0,4,1,1").unwrap(); + assert_eq!(before.diff(&beep_on), vec![(1, "0".into(), "1".into())]); + + let bright3 = Menu::parse("MU 1,4,0,1,0,4,1,0,10,0,0,0,0,0,0,2,0,0,0,0,2,0,1,0,0,3,0,0,00,02,14,15,0C,0E,0,1,0,1,0,4,1,1").unwrap(); + assert_eq!(beep_on.diff(&bright3), vec![(26, "8".into(), "3".into())]); + } + + /// Setting a field keeps the radio's width — `08`, not `8`. + #[test] + fn a_changed_field_keeps_the_radios_width() { + let m = Menu::parse(REAL_MU).unwrap(); + let changed = m.with_field(33, "1").unwrap(); + assert_eq!(changed.field(33).unwrap(), "01"); + assert_eq!(m.diff(&changed), vec![(33, "0C".into(), "01".into())]); + } + + /// A value too wide for its field would shift everything after it, turning + /// one intended change into forty-two unintended ones. Refused. + #[test] + fn a_too_wide_value_is_refused_rather_than_shifting_the_line() { + let m = Menu::parse(REAL_MU).unwrap(); + let err = m.with_field(1, "12").unwrap_err(); + assert!(err.contains("shift every field"), "{err}"); + assert!(m.with_field(99, "1").is_err()); + } + + /// A line with the wrong field count is a different radio, and is refused + /// rather than parsed into whatever lines up. + #[test] + fn a_wrong_field_count_is_refused() { + let err = Menu::parse("MU 0,4,0").unwrap_err(); + assert!(err.contains("42 menu fields"), "{err}"); + } +} diff --git a/src-tauri/src/radios/kenwood_tmd710/mod.rs b/src-tauri/src/radios/kenwood_tmd710/mod.rs index bebf6e0..81870dd 100644 --- a/src-tauri/src/radios/kenwood_tmd710/mod.rs +++ b/src-tauri/src/radios/kenwood_tmd710/mod.rs @@ -131,6 +131,76 @@ pub(crate) fn ask_settling(p: &mut dyn SerialPort, cmd: &str) -> Result Result<(), String> { + let intended = m.to_line(); + ask(p, &intended)?; + let after = ask(p, &format!("ME {:03}", m.slot))?; + if after != intended { + return Err(format!( + "memory {:03} did not take the write.\n sent: {intended}\n read: {after}", + m.slot + )); + } + Ok(()) +} + +/// Write a memory's name, and read it back for the same reason. +// ⚠ Reachable only from the measurement harness until a capability trait calls +// it — see the same note in `memory.rs`. The write path is deliberately proven +// by the campaign that uses it before it is offered to an operator. +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn write_name(p: &mut dyn SerialPort, n: &memory::MemoryName) -> Result<(), String> { + let intended = n.to_line(); + ask(p, &intended)?; + let after = ask(p, &format!("MN {:03}", n.slot))?; + if after != intended { + return Err(format!( + "name for {:03} did not take.\n sent: {intended}\n read: {after}", + n.slot + )); + } + Ok(()) +} + +/// Write the whole menu line and report **which parameters did not take**. +/// +/// ⚠ `MU` sets all 42 at once. There is no way to write one menu item alone, so +/// every write here is a write of everything — which is exactly why +/// [`memory::Menu::with_field`] refuses a value too wide for its field, and why +/// a caller should build from a line just read off the radio rather than from a +/// remembered one. +/// +/// Returns the parameters that differ after the write, as `(p, wanted, got)`. +/// **Empty means clean.** A non-empty result is not necessarily an error — a +/// field the firmware owns can revert on its own, and that is a finding worth +/// seeing rather than an exception worth throwing. +// ⚠ Reachable only from the measurement harness until a capability trait calls +// it — see the same note in `memory.rs`. The write path is deliberately proven +// by the campaign that uses it before it is offered to an operator. +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn write_menu( + p: &mut dyn SerialPort, + menu: &memory::Menu, +) -> Result, String> { + let intended = menu.to_line(); + ask(p, &intended)?; + let after = memory::Menu::parse(&ask(p, "MU")?)?; + Ok(menu.diff(&after)) +} + pub(crate) struct KenwoodTmD710; pub(crate) static DRIVER: KenwoodTmD710 = KenwoodTmD710; @@ -199,6 +269,11 @@ mod tests { /// capture proved it answers. struct FakeD710 { model: &'static str, + /// Slots the fake is holding, so a write can be read back. + slots: std::collections::BTreeMap, + /// Accept the write but keep the old value — the "firmware owns this + /// field" behaviour seen on another radio in this project. + stubborn: bool, /// Answer the first command with `?` regardless — the settling /// behaviour measured during the rate sweep. garbled_first: bool, @@ -209,6 +284,8 @@ mod tests { fn new() -> Self { Self { model: "TM-D710", + slots: std::collections::BTreeMap::new(), + stubborn: false, garbled_first: false, seen: Vec::new(), } @@ -229,8 +306,25 @@ mod tests { format!("ID {}", self.model) } else if cmd == "ME 999" { memory::EMPTY_REPLY.to_string() - } else if cmd == "ME 000" { - "ME 000,0447275000,0,2,0,0,1,0,12,12,000,05000000,0,0000000000,0,0".to_string() + } else if let Some(rest) = cmd.strip_prefix("ME ") { + if rest.contains(',') { + // A write: keep it (unless stubborn) and echo it back. + let slot: u16 = rest[..3].parse().unwrap(); + if !self.stubborn { + self.slots.insert(slot, cmd.clone()); + } + cmd.clone() + } else { + let slot: u16 = rest.parse().unwrap_or(999); + self.slots.get(&slot).cloned().unwrap_or_else(|| { + if slot == 0 { + "ME 000,0447275000,0,2,0,0,1,0,12,12,000,05000000,0,0000000000,0,0" + .to_string() + } else { + memory::EMPTY_REPLY.to_string() + } + }) + } } else { "?".to_string() }; @@ -289,4 +383,34 @@ mod tests { // reply is the branch under test, so exercise the same condition. assert!(reply.strip_prefix("ID ").unwrap() == "TM-D710G"); } + + /// A write is only believed after the radio says it back. This is the + /// happy path: write an empty slot, read it, get the same line. + #[test] + fn a_memory_write_is_verified_by_reading_it_back() { + let mut p = FakePort::new(FakeD710::new()); + let m = memory::Memory::parse( + "ME 500,0146520000,0,0,0,0,0,0,00,00,000,00000000,0,0000000000,0,0", + ) + .unwrap(); + write_memory(&mut p, &m).unwrap(); + assert_eq!(ask(&mut p, "ME 500").unwrap(), m.to_line()); + } + + /// ★ The failure this exists to catch: the radio accepts the command and + /// keeps its own value. Nothing errors on the wire, so without the + /// read-back the report would claim a write that never happened. + #[test] + fn a_write_the_radio_quietly_ignores_is_reported_not_believed() { + let mut radio = FakeD710::new(); + radio.stubborn = true; + let mut p = FakePort::new(radio); + let m = memory::Memory::parse( + "ME 500,0146520000,0,0,0,0,0,0,00,00,000,00000000,0,0000000000,0,0", + ) + .unwrap(); + let err = write_memory(&mut p, &m).unwrap_err(); + assert!(err.contains("did not take"), "{err}"); + assert!(err.contains("sent:") && err.contains("read:"), "{err}"); + } } diff --git a/src-tauri/src/radios/kenwood_tmd710_probe.rs b/src-tauri/src/radios/kenwood_tmd710_probe.rs index aea1757..85ae4ac 100644 --- a/src-tauri/src/radios/kenwood_tmd710_probe.rs +++ b/src-tauri/src/radios/kenwood_tmd710_probe.rs @@ -281,3 +281,154 @@ fn d710_dump_memories() { std::fs::write("../scratchpad/kenwood_tmd710/memories.txt", &log).expect("write"); } + +// ⚠ Everything below WRITES to the radio. Above this line nothing does. +// The safety net is that `memories.txt` and `mu-log.txt` hold the radio's +// entire state as it was found, so `d710_restore` can put any of it back. + +/// **Hardware ladder step 1 — identity write.** Read a memory, write the +/// identical line back, read it again, and require that nothing moved. +/// +/// Proves the write path with nothing at risk: the radio ends holding exactly +/// what it already held. It does **not** prove there is no checksum — an +/// identical line carries any digest along unchanged — but on an ASCII protocol +/// with no commit step there is nothing for a checksum to live in. Step 2 is +/// the real test. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_identity_write() { + use crate::radios::kenwood_tmd710::{memory::Memory, write_memory}; + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + let slot: u16 = std::env::var("D710_SLOT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let before = ask(&mut *p, &format!("ME {slot:03}")).expect("read").0; + println!("\nbefore: {before}"); + let m = Memory::parse(&before).expect("parse"); + + write_memory(&mut *p, &m).expect("identity write"); + let after = ask(&mut *p, &format!("ME {slot:03}")).expect("re-read").0; + println!("after: {after}\n"); + assert_eq!(after, before, "an identity write changed the slot"); + println!("--- identity write clean on slot {slot:03}\n"); +} + +/// **Ladder step 2, and the measurement instrument.** Write one memory built +/// from `D710_LINE`, verified by read-back. +/// +/// Used to put a known tone index into an empty slot so the operator can read +/// the tone off the radio's own screen — the half no cable can answer. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_write_memory() { + use crate::radios::kenwood_tmd710::{memory::Memory, write_memory, write_name}; + let line = std::env::var("D710_LINE").expect("set D710_LINE to a full ME line"); + let m = Memory::parse(&line).expect("D710_LINE does not parse"); + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + let was = ask(&mut *p, &format!("ME {:03}", m.slot)).expect("read").0; + println!("\nslot {:03} was: {was}", m.slot); + write_memory(&mut *p, &m).expect("write"); + println!("slot {:03} now: {}", m.slot, m.to_line()); + + if let Ok(name) = std::env::var("D710_NAME") { + let n = crate::radios::kenwood_tmd710::memory::MemoryName { + slot: m.slot, + text: name, + }; + write_name(&mut *p, &n).expect("name"); + println!("name: {}", n.to_line()); + } + println!(); +} + +/// Change **one** menu parameter and prove only that one moved. +/// +/// `D710_P` is 1-based (`p1`…`p42`), `D710_VALUE` the new value. The line is +/// built from a `MU` read taken moments earlier, never from a remembered one: +/// this command writes all 42 at once. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_set_menu() { + use crate::radios::kenwood_tmd710::{memory::Menu, write_menu}; + let field: usize = std::env::var("D710_P") + .expect("set D710_P to the 1-based menu parameter") + .parse() + .expect("D710_P"); + let value = std::env::var("D710_VALUE").expect("set D710_VALUE"); + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + let before = Menu::parse(&ask(&mut *p, "MU").expect("MU").0).expect("parse"); + let wanted = before.with_field(field, &value).expect("with_field"); + println!("\np{field}: {:?} -> {:?}", before.field(field).unwrap(), value); + + let failed = write_menu(&mut *p, &wanted).expect("write"); + let after = Menu::parse(&ask(&mut *p, "MU").expect("MU").0).expect("parse"); + let moved = before.diff(&after); + + println!("moved: {moved:?}"); + if !failed.is_empty() { + println!("⚠ did not take: {failed:?}"); + } + assert_eq!( + moved.len(), + 1, + "expected exactly one field to move; a second means the line shifted" + ); + assert_eq!(moved[0].0, field, "the wrong field moved"); + println!(); +} + +/// Put the radio back exactly as it was found, from the captured transcript. +/// +/// The reason writing to Tim's radio is a reasonable thing to do at all. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_restore() { + use crate::radios::kenwood_tmd710::{ + memory::{Memory, MemoryName, Menu}, + write_memory, write_menu, write_name, + }; + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + let text = std::fs::read_to_string("../scratchpad/kenwood_tmd710/memories.txt") + .expect("no captured memories to restore from"); + let mut restored = 0; + for line in text.lines().filter(|l| !l.starts_with('#')) { + if line.starts_with("ME ") { + write_memory(&mut *p, &Memory::parse(line).expect("parse")).expect("write"); + restored += 1; + } else if line.starts_with("MN ") { + write_name(&mut *p, &MemoryName::parse(line).expect("parse")).expect("write"); + } + } + + // The menu line as first read, before anything in this campaign touched it. + let log = std::fs::read_to_string("../scratchpad/kenwood_tmd710/mu-log.txt").expect("mu log"); + let first = log + .lines() + .find(|l| l.starts_with("noise-floor-1\t")) + .and_then(|l| l.split_once('\t')) + .map(|(_, line)| line) + .expect("no noise-floor-1 row to restore the menu from"); + let failed = write_menu(&mut *p, &Menu::parse(first).expect("parse")).expect("write"); + + println!("\n--- restored {restored} memories and the menu line"); + if failed.is_empty() { + println!("--- menu clean\n"); + } else { + println!("⚠ menu fields that did not take: {failed:?}\n"); + } +} From d13da4d73226d8198ce6f1f96f8a222e8f2182d7 Mon Sep 17 00:00:00 2001 From: ww8l Date: Tue, 1 Sep 2026 16:27:58 -0600 Subject: [PATCH 03/15] TM-D710: the tone and DCS fields are indices, measured (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 ended owing four readings off the radio's screen, because `023` in field 11 is both a plausible index and a real DCS code. The radio answers it itself: it validates a write and refuses it *whole*, leaving the slot exactly as it was, so acceptance is a measurement. 754 valid code, invalid index -> refused, slot stayed N 103 invalid code, valid index -> accepted 104 invalid either way -> refused, slot kept 103 Takes 103, refuses 754: an index. The same pair on fields 9 and 10 puts both at 0..=41. So 42 tones and 104 DCS codes, 0-based, all measured rather than inherited. The tables are Kenwood's own (TM-D710GA/GE manual, SIGNALING-1/2 — ⚠ that is the G, and the caveat is written down), and the transcription is checked, not trusted: joined against Tim's channel library on frequency AND callsign it reproduces session 120's number exactly, 33 right / 0 wrong. The manual's printed 01~42 keypad numbers are display order and are not where the 0-based offset comes from; the join is. Left unconfirmed on purpose: the DCS list's *order* has no cross-check, since not one of the radio's 38 memories uses DCS. Memory 503 holds dcs_idx 023 and should read D134 on the screen. Anything else means this table is wrong. Also here: - `ME nnn,C` clears a slot — documented since session 120, never run, now proven. `d710_restore` could overwrite a memory but not un-write one, so every slot the campaign created was permanent. `d710_clear_slots` closes that and refuses any slot present in memories.txt, so it cannot eat one of Tim's own channels. - `d710_read_slots`, read-only. Not `d710_dump_memories`: that one rewrites memories.txt, which is the only copy of the radio's as-found state. - The harness said the cable goes in the main unit's PC port. It does not — that is the D710G. Session 120 measured the operation panel's rear COM port and the doc never caught up. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CrC78t5gKpZi3eJPhYN4tx --- src-tauri/src/radios/kenwood_tmd710/mod.rs | 11 +- src-tauri/src/radios/kenwood_tmd710/tone.rs | 217 +++++++++++++++++++ src-tauri/src/radios/kenwood_tmd710_probe.rs | 88 +++++++- 3 files changed, 308 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/radios/kenwood_tmd710/tone.rs diff --git a/src-tauri/src/radios/kenwood_tmd710/mod.rs b/src-tauri/src/radios/kenwood_tmd710/mod.rs index 81870dd..769a216 100644 --- a/src-tauri/src/radios/kenwood_tmd710/mod.rs +++ b/src-tauri/src/radios/kenwood_tmd710/mod.rs @@ -43,9 +43,13 @@ //! //! This driver identifies and nothing else, the same scaffolding stance the //! FT5D was registered under. `memory.rs` can already read and re-emit a slot, -//! but **nothing has ever been written to this radio**, and the tone and DCS -//! index tables are still unmeasured. A capability trait here would put a -//! "Program radio" button in front of an operator for a path no one has proven. +//! but **nothing has ever been written to this radio**. A capability trait here +//! would put a "Program radio" button in front of an operator for a path no one +//! has proven. +//! +//! The tone and DCS tables are no longer unmeasured — see [`tone`], where the +//! radio's own refusal of an out-of-range index settled that fields 9-11 are +//! indices and fixed their lengths at 42 and 104. use serialport::SerialPort; use std::time::{Duration, Instant}; @@ -53,6 +57,7 @@ use std::time::{Duration, Instant}; use super::driver::{RadioDriver, RadioIdentity}; pub(crate) mod memory; +pub(crate) mod tone; /// Menu 528 on this radio sets it. 57600 is what Tim's is on and what the /// capture ran at; the driver does not sweep, because a rate mismatch here is diff --git a/src-tauri/src/radios/kenwood_tmd710/tone.rs b/src-tauri/src/radios/kenwood_tmd710/tone.rs new file mode 100644 index 0000000..6801283 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/tone.rs @@ -0,0 +1,217 @@ +//! The TM-D710's CTCSS and DCS tables, and the conversions in and out of them. +//! +//! Fields 9, 10 and 11 of an `ME` line (`tone_idx`, `ctcss_idx`, `dcs_idx`) are +//! **indices**, not values. That was the open question at the end of the Phase 1 +//! campaign — the field is three characters wide and `023` is both a plausible +//! index and a real DCS code — and the radio settled it without anyone reading +//! its screen. +//! +//! ## How the index question was answered, over the cable +//! +//! This radio **validates a write and refuses it whole**: a rejected `ME` line +//! leaves the slot exactly as it was, which makes acceptance a measurement. +//! Written to a slot that was empty, and read back (session 126): +//! +//! | `dcs_idx` written | valid DCS code? | valid index? | radio | +//! |---|---|---|---| +//! | `754` | yes — the last one | no, 754 > 103 | **refused**, slot stayed `N` | +//! | `103` | no | yes | **accepted** | +//! | `104` | no | no | **refused**, slot kept `103` | +//! +//! A field that takes `103` and refuses `754` is an index. The same pair run on +//! fields 9 and 10 puts both at `0..=41`: `41` accepted, `42` refused, on each +//! independently. So the counts are exactly **42 tones and 104 DCS codes**, and +//! the indices are **0-based** — `00` is accepted, which a 1-based field could +//! not do. +//! +//! ## Where the tables themselves come from +//! +//! The lists below are the manual's own (TM-D710GA/GE Instruction Manual +//! V1.01, SIGNALING-1 and SIGNALING-2). ⚠ That manual covers the **G**; Tim's +//! radio is the non-G TM-D710A. The two share this table — the cable-measured +//! lengths above match it exactly, 42 and 104, which is the check that matters — +//! but see the `research-before-reverse-engineering` note: a manual for the +//! wrong model has bitten this project before. +//! +//! The manual prints the CTCSS list with keypad reference numbers `01`~`42`, +//! which is **display numbering, not the stored index** (the TH-D75 taught this +//! the hard way). The 0-based offset is not taken from the manual: session 120 +//! joined the radio's own 38 memories to Tim's channel library on frequency +//! **and** callsign and found field 9 predicting the library's TX tone **33 +//! right, 0 wrong** under "0-based index into this list". That pins the offset +//! and the interior of the table independently of anything printed. +//! +//! ## ⚠ Nothing here is called yet +//! +//! Every item below carries `allow(dead_code)` because the Phase 2 encoder — +//! the thing that builds an `ME` line from a library channel — does not exist +//! yet. The attribute goes away with it. It is spelled out because a +//! `never used` warning on an encoder is normally a **bug report**: on the +//! D890UV a whole settings write path sat unreferenced behind a working read +//! path and nobody noticed. See `read-path-working-hides-a-dead-write-path`. +//! +//! ## What is still unconfirmed +//! +//! Not one of the radio's 38 memories uses DCS, so **the DCS list has no +//! cross-check** — its order is the manual's reading order, and the fact that it +//! is byte-identical to the 104-code list the ID-52 driver already ships. The +//! decisive screen reading is memory 503, which holds `dcs_idx = 023`: as an +//! index that is the 24th code, **D134**. Any other displayed value means this +//! table is not the radio's. + +/// CTCSS tones in tenths of a hertz, in the order the radio indexes them. +/// +/// Index 0 is 67.0 Hz and index 41 is 254.1 Hz — the classic 42-tone list, with +/// none of the extra tones the ID-52's 50-entry table carries. +#[cfg_attr(not(test), allow(dead_code))] // see the module doc +pub(crate) const TONES_DHZ: [u16; 42] = [ + 670, 693, 719, 744, 770, 797, 825, 854, 885, 915, 948, 974, 1000, 1035, 1072, 1109, 1148, 1188, + 1230, 1273, 1318, 1365, 1413, 1462, 1514, 1567, 1622, 1679, 1738, 1799, 1862, 1928, 2035, 2065, + 2107, 2181, 2257, 2291, 2336, 2418, 2503, 2541, +]; + +/// DCS codes as the radio indexes them, written in octal the way the front +/// panel shows them — which is also how the channel database stores them. +/// +/// Index 0 is `023` and index 103 is `754`. +#[cfg_attr(not(test), allow(dead_code))] // see the module doc +pub(crate) const DCS_CODES: [u16; 104] = [ + 23, 25, 26, 31, 32, 36, 43, 47, 51, 53, 54, 65, 71, 72, 73, 74, 114, 115, 116, 122, 125, 131, + 132, 134, 143, 145, 152, 155, 156, 162, 165, 172, 174, 205, 212, 223, 225, 226, 243, 244, 245, + 246, 251, 252, 255, 261, 263, 265, 266, 271, 274, 306, 311, 315, 325, 331, 332, 343, 346, 351, + 356, 364, 365, 371, 411, 412, 413, 423, 431, 432, 445, 446, 452, 454, 455, 462, 464, 465, 466, + 503, 506, 516, 523, 526, 532, 546, 565, 606, 612, 624, 627, 631, 632, 654, 662, 664, 703, 712, + 723, 731, 732, 734, 743, 754, +]; + +/// The `ME` field for a tone this radio does not have. +/// +/// Deliberately an error rather than a nearest-match: the channel library is +/// radio-agnostic and holds tones from radios with longer tables, and silently +/// moving an operator's 159.8 Hz to 156.7 Hz would program a channel that +/// cannot open the repeater it names. The caller decides — drop the tone, or +/// refuse the channel — the way `ChannelFit` already decides about bands. +#[cfg_attr(not(test), allow(dead_code))] // see the module doc +pub(crate) fn tone_field(hz: f64) -> Result { + let dhz = (hz * 10.0).round() as u16; + let idx = TONES_DHZ + .iter() + .position(|&t| t == dhz) + .ok_or_else(|| format!("the TM-D710 has no CTCSS tone {hz:.1} Hz"))?; + Ok(format!("{idx:02}")) +} + +/// The tone an `ME` field names, in hertz. +#[cfg_attr(not(test), allow(dead_code))] // see the module doc +pub(crate) fn tone_hz(field: &str) -> Result { + let idx: usize = field + .parse() + .map_err(|_| format!("tone index {field:?} is not a number"))?; + TONES_DHZ + .get(idx) + .map(|&d| f64::from(d) / 10.0) + .ok_or_else(|| format!("tone index {idx} is past the radio's 42-tone table")) +} + +/// The `ME` field for a DCS code, given as the octal digits the database holds. +#[cfg_attr(not(test), allow(dead_code))] // see the module doc +pub(crate) fn dcs_field(code: &str) -> Result { + let n: u16 = code + .parse() + .map_err(|_| format!("DCS code {code:?} is not a number"))?; + let idx = DCS_CODES + .iter() + .position(|&c| c == n) + .ok_or_else(|| format!("{code} is not a DCS code the TM-D710 has"))?; + Ok(format!("{idx:03}")) +} + +/// The DCS code an `ME` field names, zero-padded the way the panel shows it. +#[cfg_attr(not(test), allow(dead_code))] // see the module doc +pub(crate) fn dcs_code(field: &str) -> Result { + let idx: usize = field + .parse() + .map_err(|_| format!("DCS index {field:?} is not a number"))?; + DCS_CODES + .get(idx) + .map(|&c| format!("{c:03}")) + .ok_or_else(|| format!("DCS index {idx} is past the radio's 104-code table")) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The lengths are not a style choice — they are what the radio accepted and + /// refused. `42` and `104` are the first index each field rejected, so a + /// table that grew or shrank would be writing a value the radio bounces. + #[test] + fn the_tables_are_exactly_as_long_as_the_radio_allows() { + assert_eq!(TONES_DHZ.len(), 42, "field 9 refused index 42 on hardware"); + assert_eq!(DCS_CODES.len(), 104, "field 11 refused index 104 on hardware"); + } + + /// Both lists ascend, and a duplicate would make `position()` unreachable + /// for the later copy — a value that encodes to one index and decodes to + /// another. + #[test] + fn both_tables_ascend_with_no_repeats() { + assert!( + TONES_DHZ.windows(2).all(|w| w[0] < w[1]), + "the CTCSS table is not strictly ascending" + ); + assert!( + DCS_CODES.windows(2).all(|w| w[0] < w[1]), + "the DCS table is not strictly ascending" + ); + } + + /// The four boundary values that were measured on the radio, written the way + /// an `ME` line carries them — two characters for a tone, three for DCS. + #[test] + fn the_measured_boundaries_encode_to_the_fields_the_radio_took() { + assert_eq!(tone_field(67.0).unwrap(), "00"); + assert_eq!(tone_field(254.1).unwrap(), "41"); + assert_eq!(dcs_field("023").unwrap(), "000"); + assert_eq!(dcs_field("754").unwrap(), "103"); + } + + /// ★ The one claim a cable cannot check. Memory 503 holds `dcs_idx = 023`; + /// read as an index that is the 24th code. If the radio's screen shows + /// anything but D134 for that memory, this table is wrong — see the module + /// doc. + #[test] + fn dcs_index_023_is_the_code_134() { + assert_eq!(dcs_code("023").unwrap(), "134"); + } + + /// Round-tripping is what the encoder relies on: a tone read off the radio + /// and written straight back must land on the same field. + #[test] + fn every_entry_round_trips_through_its_field() { + for (i, &dhz) in TONES_DHZ.iter().enumerate() { + let hz = f64::from(dhz) / 10.0; + let field = tone_field(hz).expect("encode"); + assert_eq!(field, format!("{i:02}")); + assert_eq!(tone_hz(&field).expect("decode"), hz); + } + for (i, &code) in DCS_CODES.iter().enumerate() { + let text = format!("{code:03}"); + let field = dcs_field(&text).expect("encode"); + assert_eq!(field, format!("{i:03}")); + assert_eq!(dcs_code(&field).expect("decode"), text); + } + } + + /// A tone this radio does not have is an error, not the nearest one it does. + /// 159.8 Hz is in the ID-52's table and not in this one, so it is exactly + /// the case a shared channel library produces. + #[test] + fn a_tone_the_radio_lacks_is_refused_rather_than_rounded() { + let err = tone_field(159.8).unwrap_err(); + assert!(err.contains("159.8"), "{err}"); + assert!(dcs_field("024").is_err(), "024 is not a DCS code"); + assert!(tone_hz("42").is_err(), "the radio refused index 42"); + assert!(dcs_code("104").is_err(), "the radio refused index 104"); + } +} diff --git a/src-tauri/src/radios/kenwood_tmd710_probe.rs b/src-tauri/src/radios/kenwood_tmd710_probe.rs index 85ae4ac..a1acfbf 100644 --- a/src-tauri/src/radios/kenwood_tmd710_probe.rs +++ b/src-tauri/src/radios/kenwood_tmd710_probe.rs @@ -33,8 +33,10 @@ //! //! ## Running it //! -//! Radio on, RT Systems cable into the PC port on the **main unit** (not the -//! control head). From `src-tauri/`: +//! Radio on, cable into the COM port on the rear of the **operation panel** +//! (Kenwood manual §5.1.2). ⚠ Not the main unit — AG7GN's README says main +//! unit, but that is the TM-D710**G**; measured on this radio in session 120. +//! From `src-tauri/`: //! //! ```text //! D710_PORT=/dev/cu.usbserial-XXXX cargo test --lib d710_find_the_radio -- --ignored --nocapture @@ -141,9 +143,9 @@ fn d710_find_the_radio() { assert!( !found.is_empty(), "no rate produced an ID reply naming a Kenwood. Before reading anything into this: is \ - the cable in the PC port on the MAIN unit rather than the control head, and does the \ - port enumerate at all? An RT Systems cable's FTDI carries their own VID/PID and may \ - not bind a driver here." + the cable in the COM port on the rear of the OPERATION PANEL — not the main unit, \ + which is where the G's is — and does the port enumerate at all? An RT Systems cable's \ + FTDI carries their own VID/PID and may not bind a driver here." ); } @@ -282,6 +284,40 @@ fn d710_dump_memories() { std::fs::write("../scratchpad/kenwood_tmd710/memories.txt", &log).expect("write"); } +/// Read a named list of slots and print the `ME` and `MN` lines verbatim. +/// +/// Read-only, and deliberately **not** `d710_dump_memories`: that one rewrites +/// `scratchpad/kenwood_tmd710/memories.txt`, which is the restore file holding +/// the radio's as-found state. Running it while a campaign has test values in +/// the radio would overwrite the only copy of what to put back. +/// +/// `D710_SLOTS=500,501,502,503` +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_read_slots() { + let slots: Vec = std::env::var("D710_SLOTS") + .expect("set D710_SLOTS to a comma-separated list, e.g. 500,501,502,503") + .split(',') + .map(|s| s.trim().parse().expect("slot number")) + .collect(); + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + println!(); + for slot in slots { + let (me, _) = ask(&mut *p, &format!("ME {slot:03}")).expect("ME"); + if me == crate::radios::kenwood_tmd710::memory::EMPTY_REPLY { + println!("{slot:03} (empty)"); + continue; + } + let (mn, _) = ask(&mut *p, &format!("MN {slot:03}")).expect("MN"); + println!("{me}\n{mn}"); + } + println!(); +} + // ⚠ Everything below WRITES to the radio. Above this line nothing does. // The safety net is that `memories.txt` and `mu-log.txt` hold the radio's // entire state as it was found, so `d710_restore` can put any of it back. @@ -389,6 +425,48 @@ fn d710_set_menu() { println!(); } +/// Clear slots back to empty — `ME nnn,C`, the documented form, tested here. +/// +/// `d710_restore` can overwrite a memory but cannot **un-write** one, so every +/// slot a campaign creates in previously-empty space stays created. This is the +/// other half of giving the radio back as found. +/// +/// `D710_SLOTS=505` — refuses to touch a slot that was populated before this +/// campaign, because those are the operator's and `memories.txt` is the only +/// copy of them. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_clear_slots() { + let slots: Vec = std::env::var("D710_SLOTS") + .expect("set D710_SLOTS to a comma-separated list") + .split(',') + .map(|s| s.trim().parse().expect("slot number")) + .collect(); + + let captured = std::fs::read_to_string("../scratchpad/kenwood_tmd710/memories.txt") + .expect("no captured memories — refusing to clear anything without the as-found copy"); + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + println!(); + for slot in slots { + let was_populated = captured.contains(&format!("ME {slot:03},")); + assert!( + !was_populated, + "slot {slot:03} held a memory when the radio was first read. Clearing it would \ + destroy the operator's own channel; only slots this campaign created may be cleared." + ); + ask(&mut *p, &format!("ME {slot:03},C")).expect("clear"); + let after = ask(&mut *p, &format!("ME {slot:03}")).expect("re-read").0; + let empty = after == crate::radios::kenwood_tmd710::memory::EMPTY_REPLY; + println!("{slot:03} {} (read back: {after})", if empty { "cleared" } else { "⚠ NOT CLEARED" }); + assert!(empty, "slot {slot:03} did not clear"); + } + println!(); +} + /// Put the radio back exactly as it was found, from the captured transcript. /// /// The reason writing to Tim's radio is a reasonable thing to do at all. From 49724befde004c446f14e23e7277f9eb272e5ae9 Mon Sep 17 00:00:00 2001 From: ww8l Date: Tue, 1 Sep 2026 16:46:02 -0600 Subject: [PATCH 04/15] TM-D710: build an ME line from a library channel (#113, Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The encoder, and the nine field ranges it needed. All of them measured the same way the tone tables were: this radio validates a write and refuses it whole, so `d710_field_bounds` sweeps a field on an empty slot, reads back, and the first refused value is the edge of the enum. Nine fields in eight seconds. ★★ A step that does not divide the frequency is refused. Field 3 came back NON-CONTIGUOUS — [0,3,5,6,8] at 146.520 — which is the answer rather than a problem: those are exactly the steps that divide it evenly. Against the table [5, 6.25, 8.33, 10, 12.5, 15, 20, 25, 30, 50] kHz, four frequencies gave 40 accept/refuse predictions with no misses. 145.050 is what pins index 9 as 50 kHz and not 100 — it divides by one and not the other. A driver emitting a fixed step would write memories the radio quietly declines to store. ★★ There is no split shift. Shift::Split = 3 came from CHIRP's table; the radio refuses it with a zero offset, with a 600 kHz offset, and with a TX frequency present. An odd split is field 14 alone, and a shift or offset together with field 14 is refused in every combination tried. Fourth published claim about this radio to die on contact with it. The variant stays parseable so a line carrying one can be read, and a test asserts nothing the encoder can be handed ever emits it. Mode 2 = AM is pinned by the radio's own 118.400 memory — air band — and 0 = FM by the 37 repeaters beside it; the field takes three values and Menu 102 offers three modes, so 1 = NFM by elimination rather than by the menu's printed order. Names: 94 of 95 printable characters survive verbatim, lowercase included. The comma — the field separator — comes back as '+', and a ninth character is silently truncated. Both are done in encode_name, which is what keeps write_name's read-back a real check instead of a guaranteed failure. The gate runs both ways. Each real memory is decoded into app terms and the encoder asked to rebuild the line; all 38 do, with three fields excluded and named. One of those exclusions is something the gate found: ME 040 (144.390, APRS) is simplex and still carries a 600 kHz offset, so the offset field persists whether or not the shift uses it. Then on the radio itself — the check no unit test can make, because a value this radio dislikes is not an error but a slot that quietly stays as it was: d710_encoder_acceptance: 38 encoded memories accepted by the radio each written to a spare slot, verified by read-back, and cleared. Still owed and written down, not smoothed over: memory 503 should read D134 on the screen (the DCS list's order is the last thing a cable cannot settle); Cross Tone falls back to the transmit tone rather than guessing a flag pair, since the radio accepts all eight combinations and refusal can measure nothing; and the split is stored but unconfirmed on the air. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CrC78t5gKpZi3eJPhYN4tx --- src-tauri/src/radios/kenwood_tmd710/encode.rs | 621 ++++++++++++++++++ src-tauri/src/radios/kenwood_tmd710/mod.rs | 1 + src-tauri/src/radios/kenwood_tmd710_probe.rs | 227 +++++++ 3 files changed, 849 insertions(+) create mode 100644 src-tauri/src/radios/kenwood_tmd710/encode.rs diff --git a/src-tauri/src/radios/kenwood_tmd710/encode.rs b/src-tauri/src/radios/kenwood_tmd710/encode.rs new file mode 100644 index 0000000..da0e551 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/encode.rs @@ -0,0 +1,621 @@ +//! Building an `ME` line from a channel in the app's library (issue #113, Phase 2). +//! +//! [`memory`](super::memory) models the line the *radio* prints. This module is +//! the other direction, and it is where every field's legal range has to be +//! known rather than carried through verbatim — a value the radio refuses does +//! not error loudly, it just leaves the slot as it was. +//! +//! ## Everything below was measured on the radio, not inherited +//! +//! The TM-D710 **validates a write and refuses it whole**, so acceptance is a +//! measurement: sweep a field on an empty slot, read back, and the first refused +//! value is the edge of the enum. `d710_field_bounds` is that instrument. +//! +//! | field | measured | +//! |---|---| +//! | 3 step | ten values, and **which** ten depends on the frequency — see [`step_field`] | +//! | 4 shift | `0`/`1`/`2` only. **There is no `3`** | +//! | 5, 6, 7, 8, 16 | `0`/`1` | +//! | 9, 10 tone | `0..=41` | +//! | 11 DCS | `0..=103` | +//! | 12 offset | any value up to **29 950 000 Hz**; 29 955 000 refused | +//! | 13 mode | `0`/`1`/`2` | +//! | 14 tx | an absolute frequency, and **mutually exclusive** with 4 and 12 | +//! | 15 tx step | the same table as field 3, against field 14's frequency | +//! +//! ## ★ The shift field has no split value +//! +//! CHIRP's table lists `3` as split and [`Shift::Split`](super::memory::Shift) +//! was written from it. The radio refuses `3` — with a zero offset, with a +//! 600 kHz offset, and with a TX frequency present. What it *does* accept is +//! shift `0`, offset `0`, and an absolute frequency in field 14; setting a shift +//! or an offset *and* field 14 together is refused in every combination tried. +//! So an odd split is field 14 and nothing else. That is the fourth published +//! claim about this radio to die on contact with it. +//! +//! ⚠ Accepted and stored is not the same as *transmits there*. Every memory in +//! the radio's own capture has field 14 zero, so nothing has ever confirmed the +//! split on the air. It is graded accordingly in `FINDINGS.md`. +//! +//! ## ★ A step that does not divide the frequency is refused +//! +//! The strongest result of the campaign, and an encoder constraint rather than a +//! curiosity: field 3 accepted a *non-contiguous* set of values, different at +//! every frequency, and in each case exactly the steps that divide it evenly. +//! Across 146.520, 145.000, 145.050 and TX 146.820, the table below predicted +//! **40 accept/refuse results with no misses**, which is also what pins index 9 +//! as 50 kHz rather than 100 kHz — 145.050 divides by the first and not the +//! second. +//! +//! A driver that emitted a fixed step would produce memories the radio quietly +//! declines to store. +//! +//! ## What the caller owes the operator +//! +//! Several channels in a radio-agnostic library cannot be expressed here at all: +//! a tone this radio does not have, an offset over 29.95 MHz. Those come back as +//! `Err`, and the program flow must turn them into a **named skip**, the way +//! `ChannelFit` already reports a band it cannot reach. Failing the whole write +//! would be worse, and encoding a nearby value would be worse still — see +//! `radio-tx-vs-rx-bands` for what a silently-wrong memory costs. + +// ⚠ Phase 2 lands the encoder before the path that will call it — the same +// note as `memory.rs` and `tone.rs`, for the same reason. See +// `read-path-working-hides-a-dead-write-path`: an "unused" warning on an +// encoder is normally a bug report, so it is silenced with a reason rather +// than by habit, and comes out when a capability trait calls in. +#![cfg_attr(not(test), allow(dead_code))] + +use super::memory::{Memory, MemoryName, Shift, MAX_NAME}; +use super::tone; +use crate::commands::export; +use crate::models::Channel; + +/// Tuning steps in hertz, in the order field 3 indexes them. +/// +/// Ten entries, because the field is **one character wide**: whatever a +/// TH-D72-style eleventh entry (100 kHz) would be, it cannot be written here. +/// Index 9 is 50 kHz, pinned at 145.050 MHz — divisible by 50 kHz and not by +/// 100 kHz, and accepted. +pub(crate) const STEPS_HZ: [u64; 10] = [ + 5_000, 6_250, 8_330, 10_000, 12_500, 15_000, 20_000, 25_000, 30_000, 50_000, +]; + +/// Index 2. Never *chosen*, only round-tripped: a real 8.33 kHz air-band channel +/// is not an integer multiple of 8330 Hz, so offering it would produce a step +/// the radio refuses. The TH-D72 driver skips its own index 2 for the same +/// reason. +const STEP_833: usize = 2; + +/// Field 12's ceiling. 29 950 000 accepted, 29 955 000 refused — and the same on +/// both bands, which is worth stating because a 60 MHz UHF ceiling would have +/// been the natural guess. +pub(crate) const MAX_OFFSET_HZ: u64 = 29_950_000; + +/// Field 13. `2` is pinned by the radio's own 118.400 MHz memory — air band is +/// AM — and `0` by the 37 FM repeaters beside it. The field accepts exactly +/// three values and the radio offers exactly three modes (Menu 102: AM, FM, +/// NFM), so `1` is NFM **by elimination**, not by reading the menu's order. +const MODE_FM: &str = "0"; +const MODE_NFM: &str = "1"; +const MODE_AM: &str = "2"; + +/// What the radio itself puts in fields 9 and 10 on a memory with no tone at +/// all: index 8, 88.5 Hz. Both of the capture's two toneless memories carry +/// `08,08`, so this is the radio's habit rather than a convenient zero. +const DEFAULT_TONE_FIELD: &str = "08"; + +/// Field 11 on every one of the radio's 38 memories. None of them uses DCS. +const DEFAULT_DCS_FIELD: &str = "000"; + +/// The comma is the `ME`/`MN` field separator, and the radio rewrites it to `+` +/// rather than refusing the name. Measured over the whole printable range: 94 of +/// 95 characters survive a round trip verbatim — **lowercase included** — and +/// this is the only one that does not. +const NAME_COMMA_REPLACEMENT: char = '+'; + +/// The lowest-indexed step that divides `hz` exactly. +/// +/// Not a preference — a requirement. The radio refuses a step that does not +/// divide the frequency, and refuses it *silently*, leaving the slot unwritten. +pub(crate) fn step_field(hz: u64) -> Result { + STEPS_HZ + .iter() + .enumerate() + .filter(|&(i, _)| i != STEP_833) + .find(|&(_, &step)| hz.is_multiple_of(step)) + .map(|(i, _)| i.to_string()) + .ok_or_else(|| { + format!( + "{:.5} MHz is not a multiple of any tuning step the TM-D710 has, so the radio \ + would refuse the memory", + hz as f64 / 1_000_000.0 + ) + }) +} + +/// Hertz from the megahertz the database stores. +fn mhz_to_hz(mhz: f64) -> u64 { + (mhz * 1_000_000.0).round() as u64 +} + +/// Build the `ME` line's contents for one channel. +/// +/// `Err` means the radio cannot hold this channel as described — a tone it does +/// not have, an offset past its ceiling — and the caller is expected to skip the +/// channel with the reason shown, never to substitute a nearby value. +pub(crate) fn encode_channel(slot: u16, c: &Channel) -> Result { + let rx_hz = mhz_to_hz(c.rx_freq); + let tx_hz = mhz_to_hz(export::tx_frequency(c)); + + // Field 14 or fields 4+12, never both — measured, in every combination. + let split = c.duplex.as_deref() == Some("split"); + let (shift, offset_hz, split_tx_hz) = if split { + (Shift::Simplex, 0, tx_hz) + } else if tx_hz > rx_hz { + (Shift::Plus, tx_hz - rx_hz, 0) + } else if tx_hz < rx_hz { + (Shift::Minus, rx_hz - tx_hz, 0) + } else { + (Shift::Simplex, 0, 0) + }; + if offset_hz > MAX_OFFSET_HZ { + return Err(format!( + "a {:.3} MHz repeater shift is past the TM-D710's {:.2} MHz limit; the radio would \ + refuse the memory", + offset_hz as f64 / 1_000_000.0, + MAX_OFFSET_HZ as f64 / 1_000_000.0 + )); + } + + let mode = match c.mode.as_deref() { + // The radio is analog-only. A digital channel reaching here at all is a + // question for the model's exclusion rules, not for the encoder, so it + // lands on FM the way every other analog driver here treats one. + Some(m) if m.eq_ignore_ascii_case("NFM") => MODE_NFM, + Some(m) if m.eq_ignore_ascii_case("AM") => MODE_AM, + _ => MODE_FM, + }; + + // All eight flag combinations are accepted by the radio — it enforces + // nothing here — so exactly one is set on purpose. Cross Tone exists on this + // radio (Menu: Tone, CTCSS, DCS, Cross Tone) but **how it is stored has not + // been measured**, and the repo's rule for a radio that cannot express a + // cross tone is to keep the transmit tone and drop the receive one. That is + // strictly what a guessed flag pair might not do. + let requested = c.tone_mode.as_deref().unwrap_or(""); + let (tone_on, ctcss_on, dcs_on) = if requested.eq_ignore_ascii_case("TSQL") { + ("0", "1", "0") + } else if requested.eq_ignore_ascii_case("DTCS") { + ("0", "0", "1") + } else if requested.eq_ignore_ascii_case("Tone") || requested.eq_ignore_ascii_case("Cross") { + ("1", "0", "0") + } else { + ("0", "0", "0") + }; + + // Both tone fields are always populated, which is what the radio does even + // on a memory with no tone at all. + let tone_idx = match c.ctcss_uplink.or(c.ctcss_downlink) { + Some(hz) => tone::tone_field(hz)?, + None => DEFAULT_TONE_FIELD.to_string(), + }; + let ctcss_idx = match c.ctcss_downlink.or(c.ctcss_uplink) { + Some(hz) => tone::tone_field(hz)?, + None => DEFAULT_TONE_FIELD.to_string(), + }; + let dcs_idx = match c.dcs_code.as_deref().filter(|s| !s.is_empty()) { + Some(code) => tone::dcs_field(code)?, + None => DEFAULT_DCS_FIELD.to_string(), + }; + + Ok(Memory { + slot, + rx_hz, + step: step_field(rx_hz)?, + shift, + reverse: "0".into(), + tone_on: tone_on.into(), + ctcss_on: ctcss_on.into(), + dcs_on: dcs_on.into(), + tone_idx, + ctcss_idx, + dcs_idx, + offset_hz, + mode: mode.into(), + tx_hz: split_tx_hz, + // Measured: with field 14 zero the radio accepts only `0` here, and with + // a TX frequency present it accepts exactly that frequency's steps. + tx_step: if split { + step_field(split_tx_hz)? + } else { + "0".into() + }, + lockout: "0".into(), + }) +} + +/// Turn a memory the radio printed back into the channel it describes, so +/// the encoder can be asked to rebuild the same line. +/// +/// This is the decode the app does not otherwise need — the D710 driver +/// reads memories as text — and it exists only to close the loop. Anything +/// it cannot express is a real gap in the mapping, which is the point. +pub(crate) fn decode_channel(m: &Memory) -> Channel { + let mhz = |hz: u64| hz as f64 / 1_000_000.0; + let (duplex, offset, tx_freq) = match m.shift { + Shift::Plus => (Some("+"), Some(mhz(m.offset_hz)), None), + Shift::Minus => (Some("-"), Some(mhz(m.offset_hz)), None), + // Field 4 has no split value — an absolute TX frequency in field 14 is + // how this radio expresses one. See the module doc. + _ if m.tx_hz != 0 => (Some("split"), None, Some(mhz(m.tx_hz))), + _ => (None, None, None), + }; + Channel { + rx_freq: mhz(m.rx_hz), + duplex: duplex.map(str::to_string), + offset, + tx_freq, + mode: Some( + match m.mode.as_str() { + "1" => "NFM", + "2" => "AM", + _ => "FM", + } + .into(), + ), + tone_mode: if m.tone_on == "1" { + Some("Tone".into()) + } else if m.ctcss_on == "1" { + Some("TSQL".into()) + } else if m.dcs_on == "1" { + Some("DTCS".into()) + } else { + None + }, + ctcss_uplink: Some(tone::tone_hz(&m.tone_idx).expect("tone index off the table")), + ctcss_downlink: Some(tone::tone_hz(&m.ctcss_idx).expect("ctcss index off the table")), + dcs_code: Some(tone::dcs_code(&m.dcs_idx).expect("dcs index off the table")), + ..Default::default() + } +} + +/// The name to send with `MN`, sanitised and cut to what the radio keeps. +/// +/// Both limits are measured rather than read off Menu 200: a ninth character is +/// **silently truncated**, not refused, and a comma is silently rewritten. Doing +/// both here means [`write_name`](super::write_name)'s read-back check stays a +/// real check — otherwise every name over eight characters would fail it. +pub(crate) fn encode_name(slot: u16, c: &Channel) -> MemoryName { + let source = c + .name_short + .as_deref() + .or(c.name_long.as_deref()) + .or(c.callsign.as_deref()) + .unwrap_or(""); + MemoryName { + slot, + text: source + .chars() + .map(|ch| if ch == ',' { NAME_COMMA_REPLACEMENT } else { ch }) + .take(MAX_NAME) + .collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn channel(rx: f64) -> Channel { + Channel { + rx_freq: rx, + ..Default::default() + } + } + + /// ★ The step rule, stated as the radio stated it. These are the exact + /// frequencies `d710_field_bounds` was run at, and the exact sets it came + /// back with — so a change to `STEPS_HZ` that broke the table would fail + /// here rather than on the radio. + #[test] + fn the_step_table_reproduces_what_the_radio_accepted() { + let accepted = |hz: u64| -> Vec { + STEPS_HZ + .iter() + .enumerate() + .filter(|&(_, &s)| hz.is_multiple_of(s)) + .map(|(i, _)| i) + .collect() + }; + assert_eq!(accepted(146_520_000), vec![0, 3, 5, 6, 8]); + assert_eq!(accepted(145_000_000), vec![0, 1, 3, 4, 6, 7, 9]); + assert_eq!(accepted(145_050_000), vec![0, 1, 3, 4, 5, 7, 8, 9]); + assert_eq!(accepted(146_820_000), vec![0, 3, 5, 6, 8]); + } + + /// A step is chosen, never assumed: 5 kHz where it divides, and the first + /// one that does otherwise. 8.33 is skipped even where it would divide. + #[test] + fn a_step_is_picked_that_actually_divides_the_frequency() { + assert_eq!(step_field(146_520_000).unwrap(), "0"); + assert_eq!(step_field(145_006_250).unwrap(), "1"); + assert!(step_field(8_330).unwrap_err().contains("tuning step")); + } + + /// A plain minus-shift repeater, field by field, against a line shaped like + /// the ones the radio itself prints. + #[test] + fn a_minus_shift_repeater_encodes_the_way_the_radio_writes_one() { + let mut c = channel(447.275); + c.duplex = Some("-".into()); + c.offset = Some(5.0); + c.tone_mode = Some("TSQL".into()); + c.ctcss_uplink = Some(100.0); + c.ctcss_downlink = Some(100.0); + let m = encode_channel(0, &c).unwrap(); + assert_eq!( + m.to_line(), + "ME 000,0447275000,0,2,0,0,1,0,12,12,000,05000000,0,0000000000,0,0" + ); + } + + /// ★ An odd split is field 14 alone. The shift stays `0` and the offset + /// stays zero, because the radio refuses any combination of the two with a + /// TX frequency present — and refuses shift `3` outright. + #[test] + fn a_split_puts_the_tx_frequency_in_field_14_and_nothing_in_the_shift() { + let mut c = channel(146.520); + c.duplex = Some("split".into()); + c.tx_freq = Some(146.820); + let m = encode_channel(7, &c).unwrap(); + assert_eq!(m.shift, Shift::Simplex); + assert_eq!(m.offset_hz, 0); + assert_eq!(m.tx_hz, 146_820_000); + // 5 kHz divides 146.820, so the lowest listed step wins — the same + // rule field 3 follows, applied to field 14's frequency. + assert_eq!(m.tx_step, "0"); + assert_eq!( + m.to_line(), + "ME 007,0146520000,0,0,0,0,0,0,08,08,000,00000000,0,0146820000,0,0" + ); + } + + /// Nothing the encoder can be handed may produce field 4 = `3`. The variant + /// still exists so a line carrying one can be *read*, but the radio refused + /// it in every base tried and this driver must never emit it. + #[test] + fn no_channel_shape_encodes_the_split_shift_the_radio_refuses() { + for (duplex, tx) in [ + (Some("split"), Some(146.820)), + (Some("+"), None), + (Some("-"), None), + (None, None), + (Some("split"), Some(146.520)), + ] { + let mut c = channel(146.520); + c.duplex = duplex.map(str::to_string); + c.tx_freq = tx; + c.offset = Some(0.6); + let m = encode_channel(0, &c).unwrap(); + assert_ne!(m.shift, Shift::Split, "{duplex:?}/{tx:?} produced shift 3"); + assert!( + m.tx_hz == 0 || (m.offset_hz == 0 && m.shift == Shift::Simplex), + "{duplex:?}/{tx:?} set a TX frequency and a shift/offset together, which the \ + radio refuses: {}", + m.to_line() + ); + } + } + + /// The measured ceiling, and the reason it is an error rather than a clamp: + /// a clamped shift transmits on the wrong frequency. + #[test] + fn an_offset_past_the_radios_ceiling_is_refused_not_clamped() { + let mut c = channel(146.520); + c.duplex = Some("+".into()); + c.offset = Some(30.0); + let err = encode_channel(0, &c).unwrap_err(); + assert!(err.contains("29.95"), "{err}"); + + let mut ok = channel(146.520); + ok.duplex = Some("+".into()); + ok.offset = Some(29.95); + assert_eq!(encode_channel(0, &ok).unwrap().offset_hz, 29_950_000); + } + + /// A toneless memory still carries both tone fields, filled the way the + /// radio fills them. + #[test] + fn a_channel_with_no_tone_gets_the_radios_own_default_indices() { + let m = encode_channel(0, &channel(146.520)).unwrap(); + assert_eq!((m.tone_on.as_str(), m.ctcss_on.as_str(), m.dcs_on.as_str()), ("0", "0", "0")); + assert_eq!(m.tone_idx, "08"); + assert_eq!(m.ctcss_idx, "08"); + assert_eq!(m.dcs_idx, "000"); + } + + /// Exactly one flag, never a combination — the radio accepts all eight and + /// enforces none, so this is the encoder's job alone. + #[test] + fn exactly_one_tone_flag_is_ever_set() { + for mode in ["Tone", "TSQL", "DTCS", "Cross", "", "nonsense"] { + let mut c = channel(146.520); + c.tone_mode = Some(mode.into()); + c.ctcss_uplink = Some(100.0); + c.dcs_code = Some("023".into()); + let m = encode_channel(0, &c).unwrap(); + let on = [&m.tone_on, &m.ctcss_on, &m.dcs_on] + .iter() + .filter(|f| f.as_str() == "1") + .count(); + assert!(on <= 1, "{mode:?} set {on} flags: {}", m.to_line()); + } + } + + /// Cross falls back to the transmit tone rather than guessing a flag pair. + #[test] + fn cross_keeps_the_transmit_tone_instead_of_guessing() { + let mut c = channel(146.520); + c.tone_mode = Some("Cross".into()); + c.ctcss_uplink = Some(123.0); + c.ctcss_downlink = Some(100.0); + let m = encode_channel(0, &c).unwrap(); + assert_eq!(m.tone_on, "1"); + assert_eq!(m.tone_idx, tone::tone_field(123.0).unwrap()); + } + + /// A tone the radio does not have stops this channel and names it, so the + /// caller can skip it. It must not become the nearest tone it does have. + #[test] + fn a_tone_off_the_radios_table_stops_the_channel_with_a_reason() { + let mut c = channel(146.520); + c.tone_mode = Some("Tone".into()); + c.ctcss_uplink = Some(159.8); + let err = encode_channel(0, &c).unwrap_err(); + assert!(err.contains("159.8"), "{err}"); + } + + /// Both name limits, both measured: eight characters, and the one character + /// the radio rewrites instead of refusing. + #[test] + fn a_name_is_cut_and_the_comma_replaced_before_the_radio_does_it() { + let mut c = channel(146.520); + c.name_short = Some("DENVER, CO".into()); + assert_eq!(encode_name(3, &c).text, "DENVER+ "); + assert_eq!(encode_name(3, &c).to_line(), "MN 003,DENVER+ "); + + c.name_short = None; + c.name_long = None; + c.callsign = Some("W0UPS".into()); + assert_eq!(encode_name(3, &c).text, "W0UPS"); + + c.callsign = None; + assert_eq!(encode_name(3, &c).text, ""); + } + + /// Whatever the encoder builds must survive the round trip the radio's own + /// lines are held to — widths included, since `0` and `000` are different + /// lines. + #[test] + fn everything_encoded_re_parses_to_itself() { + for (rx, duplex, offset, mode, tone) in [ + (146.520, None, None, None, None), + (447.275, Some("-"), Some(5.0), Some("FM"), Some("TSQL")), + (145.006_25, Some("+"), Some(0.6), Some("NFM"), Some("Tone")), + (118.400, None, None, Some("AM"), None), + (146.520, Some("split"), None, None, Some("DTCS")), + ] { + let mut c = channel(rx); + c.duplex = duplex.map(str::to_string); + c.offset = offset; + c.mode = mode.map(str::to_string); + c.tone_mode = tone.map(str::to_string); + c.ctcss_uplink = Some(100.0); + c.dcs_code = Some("023".into()); + if duplex == Some("split") { + c.tx_freq = Some(146.820); + } + let m = encode_channel(42, &c).unwrap(); + let line = m.to_line(); + assert_eq!(Memory::parse(&line).unwrap().to_line(), line, "{line}"); + } + } +} + +#[cfg(test)] +mod round_trip { + use super::*; + use crate::radios::kenwood_tmd710::memory::Memory; + + /// ★ **The Phase 2 gate, in the direction that matters.** + /// + /// `memory.rs` proves a line the radio printed comes back out unchanged. + /// That tests the text, not the meaning. This decodes each real memory into + /// app terms and asks the *encoder* to rebuild it — so a field mapped the + /// wrong way round, a tone table off by one, or a shift encoded as an + /// offset all fail here rather than on the radio. + /// + /// Three fields are excluded, each named rather than smoothed over: + /// + /// - **field 3, the step.** The radio accepts any step that divides the + /// frequency, so the one it happens to hold is not the only right answer: + /// two of Tim's memories carry 25 kHz where the encoder picks 5 kHz, and + /// both are legal. Field 15 goes with it. + /// - **field 16, the lockout.** The app has no per-channel scan lockout to + /// round-trip through. + /// - **field 12 on a simplex memory.** ★ This one the gate found. Memory + /// 040 — 144.390, the APRS calling frequency — is shift `0` and still + /// carries a 600 kHz offset, so the radio keeps the offset field + /// independently of whether the shift uses it. It is residue from an + /// earlier edit, there is nothing in a channel record that could hold it, + /// and the encoder writing `00000000` there is correct: measured, the + /// radio accepts a zero offset with a zero shift. + fn rebuild_diff(me: &str) -> Option { + let original = Memory::parse(me).unwrap_or_else(|e| panic!("{me}: {e}")); + let rebuilt = match encode_channel(original.slot, &decode_channel(&original)) { + Ok(m) => m, + Err(e) => return Some(format!("{me}\n encoder refused it: {e}")), + }; + + let simplex = original.shift == Shift::Simplex && original.tx_hz == 0; + let normalise = |m: &Memory| Memory { + step: "-".into(), + tx_step: "-".into(), + lockout: "-".into(), + offset_hz: if simplex { 0 } else { m.offset_hz }, + ..m.clone() + }; + (normalise(&rebuilt).to_line() != normalise(&original).to_line()).then(|| { + format!("{me}\n rebuilt: {}", rebuilt.to_line()) + }) + } + + fn assert_all_rebuild(lines: impl Iterator) -> usize { + let (mut checked, mut bad) = (0, Vec::new()); + for me in lines { + checked += 1; + if let Some(d) = rebuild_diff(&me) { + bad.push(d); + } + } + assert!( + bad.is_empty(), + "{} of {checked} memories did not rebuild:\n {}", + bad.len(), + bad.join("\n ") + ); + checked + } + + /// The four lines that always run, matching `memory.rs`'s own sample: a + /// UHF minus, a VHF plus, a memory whose two tone fields differ, and a + /// 220 MHz repeater. + #[test] + fn the_sample_memories_rebuild_from_their_own_contents() { + let n = assert_all_rebuild( + [ + "ME 000,0447275000,0,2,0,0,1,0,12,12,000,05000000,0,0000000000,0,0", + "ME 007,0147360000,0,1,0,0,1,0,12,12,000,00600000,0,0000000000,0,0", + "ME 009,0145310000,0,2,0,0,1,0,08,18,000,00600000,0,0000000000,0,0", + "ME 005,0224840000,0,2,0,0,1,0,12,12,000,01600000,0,0000000000,0,0", + ] + .iter() + .map(|s| s.to_string()), + ); + assert_eq!(n, 4); + } + + /// The same, against every memory on the radio, when the gitignored capture + /// is on this machine — 38 real ones including the 118.400 MHz AM air-band + /// memory and the two on a 25 kHz step. A no-op in CI. + #[test] + fn every_captured_memory_rebuilds_from_its_own_contents() { + let Ok(text) = std::fs::read_to_string("../scratchpad/kenwood_tmd710/memories.txt") else { + return; + }; + let checked = + assert_all_rebuild(text.lines().filter(|l| l.starts_with("ME ")).map(str::to_string)); + assert!(checked >= 30, "only {checked} memories in the capture"); + } +} diff --git a/src-tauri/src/radios/kenwood_tmd710/mod.rs b/src-tauri/src/radios/kenwood_tmd710/mod.rs index 769a216..52ad2ae 100644 --- a/src-tauri/src/radios/kenwood_tmd710/mod.rs +++ b/src-tauri/src/radios/kenwood_tmd710/mod.rs @@ -56,6 +56,7 @@ use std::time::{Duration, Instant}; use super::driver::{RadioDriver, RadioIdentity}; +pub(crate) mod encode; pub(crate) mod memory; pub(crate) mod tone; diff --git a/src-tauri/src/radios/kenwood_tmd710_probe.rs b/src-tauri/src/radios/kenwood_tmd710_probe.rs index a1acfbf..8b7b812 100644 --- a/src-tauri/src/radios/kenwood_tmd710_probe.rs +++ b/src-tauri/src/radios/kenwood_tmd710_probe.rs @@ -425,6 +425,233 @@ fn d710_set_menu() { println!(); } +/// Sweep one `ME` field through every value its width allows and record which +/// ones the radio takes. +/// +/// ## Why this works, and why it is the cheapest instrument here +/// +/// The TM-D710 **validates a write and refuses it whole** — a rejected line +/// leaves the slot exactly as it was. So acceptance is a measurement, and the +/// first refused value is the size of the enum behind the field. That is how +/// fields 9-11 were settled as indices with lengths 42 and 104 (see +/// `kenwood_tmd710::tone`) without anyone reading the radio's screen. +/// +/// It measures a **range**, never a meaning. Knowing field 13 accepts `0`, `1` +/// and `2` does not say which is AM; that still takes the manual, a cross-check +/// against real memories, or the radio's own display. +/// +/// `D710_SLOT=504 D710_FIELDS=3,4,13,16` — 1-based, counting the slot number as +/// field 1, the way the module doc numbers them. Text in, text out: the base +/// line is substituted as **characters**, so a value `Memory::parse` would +/// refuse (an unknown shift, say) still reaches the radio, which is the whole +/// point. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_field_bounds() { + let slot: u16 = std::env::var("D710_SLOT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(504); + let fields: Vec = std::env::var("D710_FIELDS") + .unwrap_or_else(|_| "3,4,5,6,7,8,13,15,16".into()) + .split(',') + .map(|s| s.trim().parse().expect("field number")) + .collect(); + + let captured = std::fs::read_to_string("../scratchpad/kenwood_tmd710/memories.txt") + .expect("no captured memories — refusing to probe without the as-found copy"); + assert!( + !captured.contains(&format!("ME {slot:03},")), + "slot {slot:03} held a memory when the radio was first read; probe an empty one" + ); + + // Everything off and zero, so a refusal is the field under test and not a + // combination. Widths are the radio's — see the `memory` module doc. + // + // ⚠ The base is not neutral for every field, and the first sweep proved it: + // field 3 accepted only the tuning steps that divide **this** frequency + // evenly, and fields 4 and 15 are constrained by the TX frequency in field + // 14. So `D710_BASE` overrides the whole line (minus the slot) — measuring + // a field means choosing a base that lets it move. + let base: Vec = format!( + "{slot:03},{}", + std::env::var("D710_BASE") + .unwrap_or_else(|_| "0146520000,0,0,0,0,0,0,00,00,000,00000000,0,0000000000,0,0".into()) + ) + .split(',') + .map(str::to_string) + .collect(); + assert_eq!(base.len(), 16, "D710_BASE must be the 15 fields after the slot"); + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + println!(); + for f in fields { + let width = base[f - 1].len(); + let limit = 10usize.pow(width as u32); + // A wide field cannot be swept — field 12 is eight digits — so an + // explicit candidate list stands in for the range. + let candidates: Vec = match std::env::var("D710_VALUES") { + Ok(list) => list + .split(',') + .map(|v| v.trim().parse().expect("D710_VALUES")) + .collect(), + Err(_) => (0..limit.min(120)).collect(), + }; + let mut taken = Vec::new(); + let mut first_refused = None; + for v in candidates { + let mut line = base.clone(); + line[f - 1] = format!("{v:0width$}"); + let sent = format!("ME {}", line.join(",")); + // A refused write is not an error reply — the radio acknowledges and + // simply does not apply it — so the read-back is what decides. + let _ = ask(&mut *p, &sent); + let back = ask(&mut *p, &format!("ME {slot:03}")).expect("re-read").0; + if back == sent { + taken.push(v); + } else if first_refused.is_none() { + first_refused = Some(v); + } + // Leave nothing behind between candidates. + let _ = ask(&mut *p, &format!("ME {slot:03},C")); + } + let contiguous = + std::env::var("D710_VALUES").is_err() && taken.iter().enumerate().all(|(i, &v)| i == v); + println!( + "field {f:>2} (width {width}): accepted {} value(s){}{}", + taken.len(), + if contiguous { + format!(" — 0..={}", taken.len().saturating_sub(1)) + } else { + format!(" — {taken:?} ⚠ NOT contiguous") + }, + match first_refused { + Some(v) => format!(", first refused {v}"), + None => ", nothing refused in range".into(), + } + ); + } + println!(); +} + +/// Which characters survive a memory name, one character at a time. +/// +/// A name is a **separate command** (`MN nnn,TEXT`) whose text runs to the end +/// of the line, so the failure this guards against is not cosmetic: the app's +/// channel names come from a database that has never been constrained to what a +/// 1990s Kenwood accepts, and a character the radio silently drops or rewrites +/// produces a memory labelled something other than what the operator asked for. +/// Same instrument as everywhere else here — write, read back, compare. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_name_charset() { + use crate::radios::kenwood_tmd710::{memory::Memory, write_memory}; + let slot: u16 = std::env::var("D710_SLOT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(504); + + let captured = std::fs::read_to_string("../scratchpad/kenwood_tmd710/memories.txt") + .expect("no captured memories — refusing to probe without the as-found copy"); + assert!( + !captured.contains(&format!("ME {slot:03},")), + "slot {slot:03} held a memory when the radio was first read; probe an empty one" + ); + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + // A name needs a memory to hang on, so put one there first. + let line = format!("ME {slot:03},0146520000,0,0,0,0,0,0,00,00,000,00000000,0,0000000000,0,0"); + write_memory(&mut *p, &Memory::parse(&line).expect("parse")).expect("seed the slot"); + + let (mut kept, mut changed) = (String::new(), Vec::new()); + for byte in 0x20u8..0x7F { + let ch = byte as char; + // Padded so a dropped character shows as a length change rather than + // shifting into a neighbour and reading as a match. + let wanted = format!("A{ch}B"); + let sent = format!("MN {slot:03},{wanted}"); + let _ = ask(&mut *p, &sent); + let back = ask(&mut *p, &format!("MN {slot:03}")).expect("re-read").0; + match back.strip_prefix(&format!("MN {slot:03},")) { + Some(got) if got == wanted => kept.push(ch), + other => changed.push((ch, other.unwrap_or(&back).to_string())), + } + } + + println!("\n=== kept verbatim ({}): {kept}", kept.len()); + println!("=== altered or refused ({}):", changed.len()); + for (ch, got) in &changed { + println!(" {ch:?} (0x{:02X}) -> {got:?}", *ch as u8); + } + println!(); + let _ = ask(&mut *p, &format!("ME {slot:03},C")); +} + +/// ★ **The Phase 2 hardware gate: does the encoder emit lines this radio takes?** +/// +/// Every unit test in `encode.rs` compares the encoder against text. None of +/// them can catch the failure that actually matters here, because a value the +/// TM-D710 dislikes is **not** an error — the radio acknowledges the line and +/// leaves the slot alone. A driver can therefore be entirely self-consistent +/// and still write nothing. +/// +/// So: take each of the 38 memories the radio itself holds, decode it into app +/// terms, re-encode it into a **spare slot**, write it, and read it back. It +/// covers every channel shape Tim actually has — VHF, UHF, 220, the AM air-band +/// memory, the two on a 25 kHz step, split tone and CTCSS indices — without +/// touching one of his memories. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_encoder_acceptance() { + use crate::radios::kenwood_tmd710::{ + encode::{decode_channel, encode_channel}, + memory::Memory, + write_memory, + }; + let slot: u16 = std::env::var("D710_SLOT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(504); + + let captured = std::fs::read_to_string("../scratchpad/kenwood_tmd710/memories.txt") + .expect("no captured memories to encode from"); + assert!( + !captured.contains(&format!("ME {slot:03},")), + "slot {slot:03} held a memory when the radio was first read; use an empty one" + ); + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + let (mut accepted, mut refused) = (0usize, Vec::new()); + for line in captured.lines().filter(|l| l.starts_with("ME ")) { + let original = Memory::parse(line).expect("parse"); + let built = encode_channel(slot, &decode_channel(&original)).expect("encode"); + match write_memory(&mut *p, &built) { + Ok(()) => accepted += 1, + Err(e) => refused.push(format!("from {line}\n {e}")), + } + let _ = ask(&mut *p, &format!("ME {slot:03},C")); + } + + println!("\n=== {accepted} encoded memories accepted by the radio"); + if !refused.is_empty() { + println!("=== {} REFUSED:", refused.len()); + for r in &refused { + println!(" {r}"); + } + } + println!(); + assert!(refused.is_empty(), "{} encoded lines were refused", refused.len()); +} + /// Clear slots back to empty — `ME nnn,C`, the documented form, tested here. /// /// `d710_restore` can overwrite a memory but cannot **un-write** one, so every From 6118a7544675dc4b15aaf9d7fdc4da7fc4c7debf Mon Sep 17 00:00:00 2001 From: ww8l Date: Tue, 1 Sep 2026 17:16:03 -0600 Subject: [PATCH 05/15] TM-D710: seed the model, with rx_bands measured off the radio (#113, Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ★ rx_bands is measured, not copied out of a manual. The radio refuses an `ME` line it cannot hold, so coverage is the same accept/refuse question every other field answered this session: `d710_rx_band_sweep` wrote 1350 frequencies at 1 MHz into a spare slot and bisected both edges to 5 kHz. The answer is ONE contiguous span, 118.000-523.995 MHz, with no interior gap. That matters twice. The manual on hand is the TM-D710**G**'s and lists an 800-1300 MHz group this radio refuses outright, so a band table copied from it would have promised memories that cannot exist — and rx_bands has the worst failure mode in this project, an out-of-coverage frequency reported as written while the memory sits empty. Here it cannot: the sweep is the coverage. ⚠ tx_bands is NOT measured and must not be — the only way to ask this radio what it transmits on is to key it. 144-148 / 430-450 is the K-type allocation and `TY` answered K,0,3,1,0 on Tim's radio. A TX-modified radio is under-served by this row, which is the safe direction. covers_220 is TRUE where the TH-D72 beside it is false. This radio genuinely hears 220 — Tim's own memory 005 is a 224.840 repeater — so that channel must land RECEIVE-ONLY rather than be dropped. The Phase 3 gate is that distinction: a test seeds a real database and runs the four channel shapes through `channel_fit`, the same function the export preview and every program dialog call, and asserts the verdicts rather than the band arithmetic. ReceiveOnly and Excluded are different answers and only one of them keeps the repeater. Deliberately conservative, each with its reason in the seed comment: - banks_supported false. The radio has ten memory groups (Menu 203) and they are probably the D72's positional hundreds, but that is an inference from a sibling radio and this project has a rule about those. A flat 1000-slot pool is what the encoder writes today. - the settings schema is EMPTY. `MU` carries all 42 menu parameters and the driver parses them, but none is attributed to a named setting yet. Phase 4. - max_name_length 8 measured on the wire, not read off Menu 200 — a ninth character comes back silently truncated rather than refused. No migration: seeding INSERTs new (manufacturer, model) rows, so the model reaches existing databases on the next startup. Verified in dev — it is in the dev database with the measured bands, returned by the same query the Radios screen uses, and the app starts clean. The generic program dialog is capability-driven and this driver still claims no capability trait, so nothing here puts a write in front of an operator. That stays true until the hardware ladder is climbed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CrC78t5gKpZi3eJPhYN4tx --- src-tauri/src/db.rs | 21 +- src-tauri/src/radios/kenwood_tmd710_probe.rs | 127 +++++++++++ src-tauri/src/seed.rs | 210 +++++++++++++++++++ 3 files changed, 349 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 9cdb67d..5b9510b 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -60,18 +60,18 @@ mod tests { // Models are reintroduced one at a time (migration 0005 trimmed the // original set): currently the Baofeng UV-5R, TIDRADIO TD-H3, AnyTone // AT-D890UV, Yaesu FT5D, Icom ID-52, Kenwood TH-D75, Kenwood TH-D72 - // and the Binteradio BT-9000. (0015 removed - // the Vero VR-N76 placeholder.) None of the last three has a migration - // of its own — seeding INSERTs new (manufacturer, model) rows, so a new - // model reaches existing databases on the next startup without one. + // the Binteradio BT-9000 and Kenwood TM-D710. (0015 removed the Vero + // VR-N76 placeholder.) None of the last four has a migration of its + // own — seeding INSERTs new (manufacturer, model) rows, so a new model + // reaches existing databases on the next startup without one. let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM radio_models") .fetch_one(&pool) .await .unwrap(); assert_eq!( - count.0, 8, - "expected the UV-5R, TD-H3, AT-D890UV, FT5D, ID-52, TH-D75, TH-D72 and BT-9000 \ - seeded models" + count.0, 9, + "expected the UV-5R, TD-H3, AT-D890UV, FT5D, ID-52, TH-D75, TH-D72, BT-9000 \ + and TM-D710 seeded models" ); let models: Vec<(String,)> = @@ -82,7 +82,10 @@ mod tests { let names: Vec<&str> = models.iter().map(|m| m.0.as_str()).collect(); assert_eq!( names, - vec!["AT-D890UV", "BT-9000", "FT5D", "ID-52", "TD-H3", "TH-D72", "TH-D75", "UV-5R"] + vec![ + "AT-D890UV", "BT-9000", "FT5D", "ID-52", "TD-H3", "TH-D72", "TH-D75", "TM-D710", + "UV-5R" + ] ); // Seeding twice must remain idempotent. @@ -91,7 +94,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count2.0, 8, "seeding should be idempotent"); + assert_eq!(count2.0, 9, "seeding should be idempotent"); // A new database starts with NO talkgroups. The BrandMeister list used // to be compiled in and seeded here; it is downloaded on request now, diff --git a/src-tauri/src/radios/kenwood_tmd710_probe.rs b/src-tauri/src/radios/kenwood_tmd710_probe.rs index 8b7b812..4031bd1 100644 --- a/src-tauri/src/radios/kenwood_tmd710_probe.rs +++ b/src-tauri/src/radios/kenwood_tmd710_probe.rs @@ -652,6 +652,133 @@ fn d710_encoder_acceptance() { assert!(refused.is_empty(), "{} encoded lines were refused", refused.len()); } +/// ★ **What the memory will actually hold, swept off the radio.** +/// +/// `rx_bands` is the seed field with the worst failure mode in this project: an +/// out-of-coverage frequency does not error, it becomes a **silently empty +/// memory** while the app reports the channel written (three repeaters were lost +/// that way on the ID-52). The usual defence is to copy the band table out of +/// the manual and hope it matches the variant in front of you — and the manual +/// on hand covers the TM-D710**G**, not Tim's non-G. +/// +/// So measure it. The radio refuses an `ME` line it cannot hold, which turns +/// coverage into the same accept/refuse question every other field answered: +/// sweep at 1 MHz, then bisect each edge down to 5 kHz. +/// +/// `D710_SWEEP_LO=50 D710_SWEEP_HI=1400` (MHz). Reads out as a table of ranges +/// ready to become `rx_bands`. +/// +/// ⚠ This measures what the **memory** accepts. It says nothing about transmit: +/// the radio stores an out-of-band memory happily and refuses at `[PTT]` +/// (manual, REPEATER-1 note), which is exactly the receive-only case the app +/// already models. `tx_bands` cannot be measured this way and must not be +/// guessed from this output. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_rx_band_sweep() { + use crate::radios::kenwood_tmd710::encode::step_field; + let slot: u16 = std::env::var("D710_SLOT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(504); + let lo: u64 = std::env::var("D710_SWEEP_LO") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(50); + let hi: u64 = std::env::var("D710_SWEEP_HI") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1400); + + let captured = std::fs::read_to_string("../scratchpad/kenwood_tmd710/memories.txt") + .expect("no captured memories — refusing to sweep without the as-found copy"); + assert!( + !captured.contains(&format!("ME {slot:03},")), + "slot {slot:03} held a memory when the radio was first read; sweep into an empty one" + ); + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + // One closure, used by both the coarse sweep and the bisection, so an edge + // is never decided by a different test than the sweep that found it. + let mut holds = |p: &mut dyn SerialPort, hz: u64| -> bool { + let Ok(step) = step_field(hz) else { return false }; + let sent = format!( + "ME {slot:03},{hz:010},{step},0,0,0,0,0,08,08,000,00000000,0,0000000000,0,0" + ); + let _ = ask(p, &sent); + let back = ask(p, &format!("ME {slot:03}")).map(|(t, _)| t).unwrap_or_default(); + let _ = ask(p, &format!("ME {slot:03},C")); + back == sent + }; + + let started = Instant::now(); + let mut coarse = Vec::new(); + for mhz in lo..=hi { + coarse.push((mhz, holds(&mut *p, mhz * 1_000_000))); + } + + // Bisect every accepted/refused transition to the 5 kHz the field can express. + let refine = |p: &mut dyn SerialPort, + holds: &mut dyn FnMut(&mut dyn SerialPort, u64) -> bool, + mut good: u64, + mut bad: u64| { + while good.abs_diff(bad) > 5_000 { + let mid = (good + bad) / 2 / 5_000 * 5_000; + if mid == good || mid == bad { + break; + } + if holds(p, mid) { + good = mid; + } else { + bad = mid; + } + } + good + }; + + let mut ranges: Vec<(u64, u64)> = Vec::new(); + let mut open_at: Option = None; + for i in 0..coarse.len() { + let (mhz, ok) = coarse[i]; + let prev_ok = i > 0 && coarse[i - 1].1; + if ok && !prev_ok { + let start = if i == 0 { + mhz * 1_000_000 + } else { + refine(&mut *p, &mut holds, mhz * 1_000_000, (mhz - 1) * 1_000_000) + }; + open_at = Some(start); + } + if !ok && prev_ok { + let end = refine(&mut *p, &mut holds, (mhz - 1) * 1_000_000, mhz * 1_000_000); + if let Some(start) = open_at.take() { + ranges.push((start, end)); + } + } + } + if let (Some(start), Some(&(mhz, true))) = (open_at, coarse.last()) { + ranges.push((start, mhz * 1_000_000)); + } + + println!("\n=== what the TM-D710's memory accepts, {lo}-{hi} MHz"); + for (a, b) in &ranges { + println!( + " {:>11.5} .. {:>11.5} MHz", + *a as f64 / 1e6, + *b as f64 / 1e6 + ); + } + println!( + "=== {} range(s) in {:.0}s\n", + ranges.len(), + started.elapsed().as_secs_f64() + ); + assert!(!ranges.is_empty(), "the radio accepted no frequency at all"); +} + /// Clear slots back to empty — `ME nnn,C`, the documented form, tested here. /// /// `d710_restore` can overwrite a memory but cannot **un-write** one, so every diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index 2a831f7..90755a6 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -919,6 +919,89 @@ fn models() -> Vec { connection_type: "USB cable (built-in mini-USB)", non_channel_settings_schema: THD72_SETTINGS_SCHEMA, }, + // -------------------------------------------------------- + // 8. Kenwood TM-D710A — analog FM dual-band MOBILE with APRS + // and a built-in TNC, 1000 memories, 8-char names, 50 W. + // The non-G A model; the G and the E differ. Programmed in + // LIVE MODE (issue #113) — one ASCII `ME` command per + // memory over the operation panel's COM port, which is a + // fourth modality here: no clone image, no card file, and + // a write that is NOT atomic. See radios/kenwood_tmd710/. + // NOTES: + // (a) ★ rx_bands is MEASURED, not read out of a manual. + // The radio refuses an `ME` line it cannot hold, so + // coverage is an accept/refuse question: `d710_rx_band_sweep` + // wrote 1350 frequencies at 1 MHz and bisected both edges + // to 5 kHz. The answer is ONE contiguous span, + // 118.000-523.995, with no interior gap at 1 MHz + // resolution. That matters twice over — the manual on hand + // covers the D710**G** and lists an 800-1300 MHz group + // this radio does not accept at all, and a band table + // copied from it would have promised memories that cannot + // exist. + // (b) ⚠ tx_bands is NOT measured and must not be: the only + // way to ask this radio what it will transmit on is to key + // it. 144-148 / 430-450 is the K-type allocation, and `TY` + // answered `K,0,3,1,0` on Tim's. A TX-modified radio is + // under-served by this row, which is the safe direction. + // (c) covers_220 is TRUE where the TH-D72 above is false. + // This radio genuinely hears 220 — Tim's own memory 005 is + // a 224.840 repeater — so a 220 channel must land as + // RECEIVE-ONLY rather than be dropped. See the test below. + // (d) banks_supported is FALSE. The radio has ten memory + // groups (Menu 203, Memory Group Link) and they are almost + // certainly the D72's positional hundreds, but "almost + // certainly" is an inference from a sibling radio and this + // project has a rule about those. A flat 1000-slot pool is + // what the encoder actually writes today. + // (e) max_name_length 8 is measured on the wire, not read + // off Menu 200: a ninth character comes back SILENTLY + // TRUNCATED rather than refused. + // (f) the settings schema is deliberately EMPTY. `MU` + // carries all 42 menu parameters in one line and the + // driver already parses it, but not one of them has been + // attributed to a named setting yet. Seeding guesses is + // worse than seeding nothing; Phase 4 fills this in. + // -------------------------------------------------------- + ModelSeed { + manufacturer: "Kenwood", + model: "TM-D710", + driver_key: Some("kenwood_tmd710"), + programming_ui: Some("generic"), + display_name: "Kenwood TM-D710", + analog_capable: true, + dmr_capable: false, + dstar_capable: false, + ysf_capable: false, + nxdn_capable: false, + p25_capable: false, + m17_capable: false, + aprs_capable: true, + covers_hf: false, + covers_vhf: true, + covers_uhf: true, + covers_220: true, + covers_900: false, + freq_min: 144.0, + freq_max: 450.0, + tx_bands: Some("[[144.0,148.0],[430.0,450.0]]"), + rx_bands: Some("[[118.0,523.995]]"), + memory_channels: 1000, + zones_supported: false, + max_zones: None, + channels_per_zone: None, + scan_lists_supported: false, + max_scan_lists: None, + banks_supported: false, + max_name_length: 8, + export_format: "chirp_csv", + connection_type: "Serial cable (COM port, rear of the operation panel)", + // ⚠ Empty on purpose — see note (f). The generic program dialog is + // capability-driven and this driver claims no capability trait, so + // nothing here is offered to an operator until the hardware ladder + // has been climbed. + non_channel_settings_schema: "[]", + }, ] } @@ -1157,6 +1240,133 @@ mod tests { /// leniently — a typo would not fail loudly, it would quietly fall back to /// the old contiguous-span rules and drop channels again. Check the shape /// here, where it is cheap. + /// ★ The TM-D710's coverage, checked against the sweep that measured it. + /// + /// `d710_rx_band_sweep` wrote 1350 frequencies into a spare memory slot and + /// bisected both edges: the radio holds **one contiguous span, + /// 118.000-523.995**, and nothing outside it. These assertions are that + /// result, so a band table later "corrected" from the D710**G** manual — + /// which lists an 800-1300 MHz group this radio refuses outright — fails + /// here instead of on the radio. + /// + /// The 220 MHz case is the one that matters most. Unlike the TH-D72 above, + /// this radio really does hear 220: Tim's own memory 005 is a 224.840 + /// repeater. It must therefore land as RECEIVE-ONLY, never be dropped. + #[test] + fn the_tmd710_hears_one_span_and_keys_only_two_ham_bands() { + let d710 = models() + .into_iter() + .find(|m| m.model == "TM-D710") + .expect("the TM-D710 is seeded"); + + let bands = |json: &str| -> Vec> { serde_json::from_str(json).unwrap() }; + let tx = bands(d710.tx_bands.expect("TM-D710 has tx_bands")); + let rx = bands(d710.rx_bands.expect("TM-D710 has rx_bands")); + let covers = |bs: &[Vec], mhz: f64| bs.iter().any(|b| mhz >= b[0] && mhz <= b[1]); + + // Heard, and only heard — the receive-only case, not the excluded one. + assert!(d710.covers_220, "the TM-D710 receives the 220 MHz band"); + assert!( + covers(&rx, 224.840) && !covers(&tx, 224.840), + "224.840 is a real memory on Tim's radio and must land receive-only" + ); + assert!(covers(&rx, 118.400) && !covers(&tx, 118.400), "air band is receive-only"); + assert!(covers(&rx, 462.6) && !covers(&tx, 462.6), "GMRS is receive-only"); + + // Both measured edges, and one step past each. + assert!(covers(&rx, 118.0), "118.000 was accepted"); + assert!(!covers(&rx, 117.995), "117.995 was refused"); + assert!(covers(&rx, 523.995), "523.995 was accepted"); + assert!(!covers(&rx, 524.0), "524.000 was refused"); + + // The span the G's manual lists and this radio will not hold. If a + // future edit re-adds it, a 1.2 GHz channel would be reported written + // into a memory that cannot exist. + assert!(!covers(&rx, 800.0), "800 MHz was refused by the radio"); + assert!(!covers(&rx, 1290.0), "1290 MHz was refused by the radio"); + + // No interior gap: the sweep found every megahertz between the edges + // acceptable, which is why this is one span and not five. + for mhz in [136.0, 174.0, 200.0, 250.0, 300.0, 350.0, 400.0] { + assert!(covers(&rx, mhz), "{mhz} MHz is inside the measured span"); + } + + // The two it keys on. Not measured — measuring would mean transmitting. + assert!(covers(&tx, 146.52) && covers(&tx, 446.0)); + } + + /// ★ **The Phase 3 gate: the seeded row, through the app's own decision.** + /// + /// Everything above tests the band *lists*. This seeds a real database and + /// runs the four channel shapes through `channel_fit` — the same function + /// the export preview and every program dialog call — so a row that is + /// well-formed but wrong in the pipeline fails here. + /// + /// The verdicts are the point. `ReceiveOnly` and `Excluded` are different + /// answers: one programs the memory and says so, the other drops the + /// channel. Getting 224.840 into the second bucket would silently lose a + /// repeater Tim actually has in his radio. + #[tokio::test] + async fn the_seeded_tmd710_gives_the_right_verdict_on_real_channels() { + use crate::commands::export::{channel_fit, ChannelFit}; + use crate::models::{Channel, RadioModel}; + + let dir = std::env::temp_dir().join(format!("cpm_seed_{}", std::process::id())); + let db_path = dir.join("d710.sqlite3"); + let _ = std::fs::remove_file(&db_path); + let pool = crate::db::init_pool(&db_path).await.expect("init_pool"); + + let model: RadioModel = sqlx::query_as( + "SELECT id, manufacturer, model, display_name, analog_capable, dmr_capable, \ + dstar_capable, ysf_capable, nxdn_capable, p25_capable, m17_capable, aprs_capable, \ + covers_hf, covers_vhf, covers_uhf, covers_220, covers_900, freq_min, freq_max, \ + tx_bands, rx_bands, memory_channels, zones_supported, max_zones, channels_per_zone, \ + scan_lists_supported, max_scan_lists, banks_supported, max_name_length, \ + export_format, connection_type, non_channel_settings_schema, driver_key, \ + programming_ui FROM radio_models WHERE model = 'TM-D710'", + ) + .fetch_one(&pool) + .await + .expect("the TM-D710 reached the database"); + + let fit = |rx: f64, mode: &str| { + channel_fit( + &Channel { + rx_freq: rx, + mode: Some(mode.into()), + ..Default::default() + }, + &model, + ) + }; + + // Both ham bands: programmed outright. + assert!(matches!(fit(146.520, "FM"), ChannelFit::Included)); + assert!(matches!(fit(446.000, "FM"), ChannelFit::Included)); + + // ★ Heard but not keyed — programmed WITH a reason, never dropped. + for (mhz, what) in [(224.840, "220 MHz"), (118.400, "air band"), (462.6, "GMRS")] { + assert!( + matches!(fit(mhz, "FM"), ChannelFit::ReceiveOnly(_)), + "{mhz} MHz ({what}) must be receive-only, not excluded: {:?}", + fit(mhz, "FM") + ); + } + + // Outside the measured span: genuinely no use on this radio. + for mhz in [117.995, 524.0, 800.0, 1290.0] { + assert!( + matches!(fit(mhz, "FM"), ChannelFit::Excluded(_)), + "{mhz} MHz was refused by the radio and must be excluded" + ); + } + + // Analog-only: a DMR repeater is a hard exclusion, not receive-only. + assert!(matches!(fit(446.000, "DMR"), ChannelFit::Excluded(_))); + + let _ = std::fs::remove_file(&db_path); + } + #[test] fn seeded_band_lists_are_well_formed() { let parse = |json: &str| -> Vec> { From 05a33e6ff54c10ec32de63ae44d186c6f9f2a10d Mon Sep 17 00:00:00 2001 From: ww8l Date: Tue, 1 Sep 2026 18:29:16 -0600 Subject: [PATCH 06/15] Don't offer "Program radio" for a driver that cannot write (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeding the TM-D710 exposed a hole. `showCable` is isProgrammable(model) && (media == null || cableCapable) so for a non-media radio it is true whatever the driver can do — `media == null` was standing in for "has a cable path", which had been true of every non-media radio until now. The TM-D710 is the first driver registered with a cable modality and NO capability trait, deliberately, because nothing had been written to that radio yet. Result: a live "Program radio" button that walked the operator through a destructive-write confirmation and then failed at the backend with "Kenwood TM-D710 cannot be programmed over the cable". Same shape as #65 — the gate existed, the button just never consulted it. The port section still belongs there (every registered driver implements `identify`, and confirming the cable is useful), so `showCable` keeps that job and a new `canWriteOverCable` gates the write affordances: the Program button and the backup-then-write-then-verify safety banner, which was promising a run that could not happen. The codeplug summary keeps showing — it is the codeplug's FIT against the model, and the receive-only count is the most useful thing in the dialog for a radio you cannot write yet — but "Programming 62 channels into" is a promise, so it reads "This codeplug holds 62 channels for" when nothing can be written. Verified by rendering the dialog headless against stubbed capabilities: with none, Close and Identify and no banner; with `program_codeplug`, the banner and Program radio come back. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CrC78t5gKpZi3eJPhYN4tx --- .../codeplugs/ProgramRadioDialog.tsx | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/src/components/codeplugs/ProgramRadioDialog.tsx b/src/components/codeplugs/ProgramRadioDialog.tsx index 4539e40..9a34c10 100644 --- a/src/components/codeplugs/ProgramRadioDialog.tsx +++ b/src/components/codeplugs/ProgramRadioDialog.tsx @@ -83,6 +83,22 @@ export function ProgramRadioDialog({ // way the cable UI never flashes on for a radio that will hide it once the // capabilities land, and a normal radio never waits on them. const showCable = isProgrammable(model) && (media == null || cableCapable); + // ⚠ `showCable` says a PORT section belongs here; it does NOT say anything + // may be written. Every registered driver implements `identify`, so a cable + // radio always earns the port picker — but the write affordances have to ask + // `cableCapable` separately. + // + // The two were one flag until the TM-D710 (#113): the first driver registered + // with a cable modality and NO capability trait, deliberately, because + // nothing had been written to that radio yet. `media == null` was standing in + // for "has a cable path", which had been true of every non-media radio until + // then — so seeding the model turned "Program radio" into a button that + // confirmed a destructive write and then failed at the backend with "cannot + // be programmed over the cable". Same shape as #65: the gate existed, the + // button just never consulted it. + const canWriteOverCable = showCable && cableCapable; + // Cable or card — anything at all that ends with a codeplug on the radio. + const canWriteAnything = canWriteOverCable || media != null; const [ports, setPorts] = useState([]); const [port, setPort] = useState(""); const [preview, setPreview] = useState(null); @@ -383,7 +399,7 @@ export function ProgramRadioDialog({ )} - {showCable && ( + {canWriteOverCable && (