From e2de1e062d2faa11d1882a151758e7f4e0585af4 Mon Sep 17 00:00:00 2001 From: ww8l Date: Sat, 22 Aug 2026 16:01:45 -0600 Subject: [PATCH 1/2] 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 | 12 +- 5 files changed, 952 insertions(+), 1 deletion(-) 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 ec63f29..bb97297 100644 --- a/src-tauri/src/radios/mod.rs +++ b/src-tauri/src/radios/mod.rs @@ -28,6 +28,12 @@ pub(crate) mod driver; pub(crate) mod fake_port; pub(crate) mod icom_id52; 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 672e2d3..63c228f 100644 --- a/src-tauri/src/radios/registry.rs +++ b/src-tauri/src/radios/registry.rs @@ -23,13 +23,14 @@ use crate::models::RadioModel; /// Every driver compiled into the app. Order is not significant — lookups are /// by `key()`, which is unique. (A static array rather than a slice literal: /// references to statics aren't const-promotable inside a returned temporary.) -static DRIVERS: [&dyn RadioDriver; 6] = [ +static DRIVERS: [&dyn RadioDriver; 7] = [ &super::baofeng_uv5r::DRIVER, &super::tidradio_tdh3::DRIVER, &super::anytone_atd890uv::DRIVER, &super::yaesu_ft5d::DRIVER, &super::icom_id52::DRIVER, &super::kenwood_thd75::DRIVER, + &super::kenwood_tmd710::DRIVER, ]; pub(crate) fn all_drivers() -> &'static [&'static dyn RadioDriver] { @@ -79,6 +80,7 @@ mod tests { "yaesu_ft5d", "icom_id52", "kenwood_thd75", + "kenwood_tmd710", ] { let d = driver_for_key(key).unwrap_or_else(|| panic!("no driver for '{key}'")); assert_eq!(d.key(), key); @@ -109,6 +111,14 @@ mod tests { // its `.icf` and the TH-D75's in its `.d75`, none of which goes // through these traits. "yaesu_ft5d" | "icom_id52" | "kenwood_thd75" => (false, false), + // 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 72278263308bc123b4f57a161cf4f36402780dbd Mon Sep 17 00:00:00 2001 From: ww8l Date: Sat, 22 Aug 2026 16:22:03 -0600 Subject: [PATCH 2/2] 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"); + } +}