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..a456f56 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/memory.rs @@ -0,0 +1,503 @@ +//! 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"); + } +} + +/// 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 new file mode 100644 index 0000000..81870dd --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/mod.rs @@ -0,0 +1,416 @@ +//! 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), + } +} + +/// Write one memory, then **prove it landed** by reading the slot back and +/// comparing the whole line. +/// +/// The read-back is not belt-and-braces, it is the only evidence there is. +/// This radio has no checksum and no commit step: a malformed line draws `?`, +/// but a *well-formed* line the radio chooses to interpret differently draws +/// nothing at all. On the D890UV a settings field turned out to be owned by the +/// firmware and silently reverted after a write — read-back is what makes that +/// visible instead of a lie in the report. +// ⚠ 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_memory(p: &mut dyn SerialPort, m: &memory::Memory) -> 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; + +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, + /// 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, + pub seen: Vec, + } + + impl FakeD710 { + fn new() -> Self { + Self { + model: "TM-D710", + slots: std::collections::BTreeMap::new(), + stubborn: false, + 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 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() + }; + 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"); + } + + /// 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 new file mode 100644 index 0000000..85ae4ac --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710_probe.rs @@ -0,0 +1,434 @@ +//! 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"); +} + +// ⚠ 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"); + } +} 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!(