diff --git a/.claude/skills/new-radio/SKILL.md b/.claude/skills/new-radio/SKILL.md index 5f73f54..fbedc35 100644 --- a/.claude/skills/new-radio/SKILL.md +++ b/.claude/skills/new-radio/SKILL.md @@ -37,6 +37,38 @@ not the opening move, and this has paid off on every radio where it was tried. | GitHub RE repos for the model or its siblings | Config-file structure, command tables, enum tables | | **The radio's own manual** | Menu order, option lists, defaults — and it is a *published source*, not a fallback | +### ★ First enumerate what the RADIO has, not what a source describes + +Do this before reading any source in depth, and write it down. A published table +covers what its author needed; the gap between that and the radio is invisible +unless you have the radio's own list to hold it against. + +1. **What is this radio FOR?** Read the model's feature list — the manual's + first pages, the manufacturer's product page. A built-in TNC, a GPS, D-STAR, + a second receiver, cross-band repeat: each is a whole family of settings. + Manufacturer naming often carries it (Kenwood's `D` in TM-**D**710 and + TH-**D**75 means the data/APRS half; the TM-V71 is the same radio without it). +2. **Enumerate the complete menu map** from the manual — every group, and how + many menus are in each. This is the denominator for everything after it. +3. **For each group, name the transport that reaches it.** A group with no + transport is a **finding**, not an omission, and it belongs in `PLAN.md` + before a line of code. + +⚠ **One command's coverage is not the radio's settings.** The TM-D710 (#113) +shipped a 35-field settings schema built on its `MU` command, every field +measured on the radio and correct — and **no APRS at all**, on a radio whose +headline feature is APRS. `MU` carries menus 000-5xx; the APRS and TNC settings +are the 600-series and there is no `MU` parameter for one of them. The radio's +own `aprs_capable` flag was set to `true` in the same session. Nobody counted +the menus, so nobody noticed the settings stopped at 500. + +The earlier note that "`MU` is not exhaustive — menus 504, 505 and 506 have no +parameter" was already in `FINDINGS.md`. It was read as a three-menu gap instead +of the question it actually was: *what else is missing, and how would we know?* + +**Ask that question out loud in `PLAN.md`, with a number.** "The manual lists N +menus in G groups; this transport reaches M of them; the other N-M are ." + Then classify, because it decides how much of this process applies: - **Clone of a family already supported** — AT-D868UV/D578 against the D890UV, @@ -49,9 +81,14 @@ Then classify, because it decides how much of this process applies: record-by-record programming (AnyTone). **Gate:** a `PLAN.md` in `scratchpad//` naming the sources found, -the programming medium, the family, and what the user owns. Template in -`templates/PLAN.md`. Nothing is written before this exists — it is also the -thing that makes a resumed session cheap. +the programming medium, the family, and what the user owns — **plus the menu +census above: how many menus the radio has, how many the chosen transport +reaches, and where the rest live.** Nothing is written before this exists; it is +also the thing that makes a resumed session cheap. + +⚠ If the census cannot be completed because a group's transport is unknown, that +is the finding to report, not a detail to settle later. A radio shipped with a +whole feature's settings missing looks finished from the inside. ## 2. Anchor on a file the radio wrote @@ -178,7 +215,19 @@ Then wire *both* ends, and check each off explicitly: - [ ] **`apply_settings` called by the export path** - [ ] the table↔schema agreement test -**Gate:** a test proving an export carries memories **and** settings together. +- [ ] **the coverage check against step 1's menu census** — the schema's field + count and groups reconciled against the menus the radio actually has, with + every absence named + +**Gate:** a test proving an export carries memories **and** settings together, +and a **stated count**: N of the radio's M menus are exposed, and the M-N are +listed with a reason. "35 fields" is not a result; "35 of the 42 this transport +reaches, and the transport reaches 42 of the radio's ~90" is. + +⚠ A cheap mechanical version of that reconciliation: the seed row already +asserts what the radio can do. A model with `aprs_capable: true` and no APRS +field in its settings schema is a contradiction the test suite can catch on its +own, and the TM-D710 shipped exactly that pairing for a whole session. ⚠ The fourth box is the one that nearly shipped broken. The read path worked and the form filled correctly, so nothing looked wrong — the values simply never @@ -235,6 +284,14 @@ if the folder is empty, the process above still stands on its own. ## Traps, each of which has already cost time +- ★ **A source's coverage is not the radio's.** Every field measured off one + command can be right and the set still be badly incomplete — the TM-D710 + shipped a correct 35-field settings schema with no APRS on an APRS radio, + because `MU` stops at menu 500 and nobody counted the menus. Enumerate what + the radio HAS first, then hold every source against it. +- ★ **A noted gap is a question, not a footnote.** "`MU` is not exhaustive — + three menus have no parameter" sat in the findings for two sessions. It was + the same fact as "an entire feature is unreachable", written small. - A working **read** path hides a dead **write** path. Verify the write. - A printed option list is **display** order, not the stored index. One radio prints High/Medium/Low and stores Low as 0. diff --git a/.claude/skills/new-radio/templates/PLAN.md b/.claude/skills/new-radio/templates/PLAN.md index 14f7a9b..f3f2f13 100644 --- a/.claude/skills/new-radio/templates/PLAN.md +++ b/.claude/skills/new-radio/templates/PLAN.md @@ -25,6 +25,33 @@ re-measured. **What the user has:** radio / cable / microSD card / programming software (RT Systems, OEM CPS, none) / availability for hardware steps. +## ★ What this radio IS — the census + +Fill this in **before** reading any source in depth. A published table covers +what its author needed; the gap is invisible without the radio's own list. + +**Headline features** (from the manual's first pages, not from a driver): TNC / +APRS · GPS · D-STAR · DMR · second receiver · cross-band repeat · weather alert +· … Each one is a whole family of settings, and the model name often says so +(Kenwood's `D` in TM-**D**710 is the data/APRS half; the TM-V71 is the same +radio without it). + +**Menu census** — the denominator for everything downstream: + +| menu group | what it covers | how many | transport that reaches it | +|---|---|---|---| +| 0xx | | | | +| 1xx | | | | +| … | | | | +| **total** | | **N** | **M reached, N-M elsewhere** | + +⚠ A group with **no** transport is a finding, not an omission — write it here +and say so out loud, with the number. "This command reaches M of N menus; the +other N-M are in \." The TM-D710 shipped a correct 35-field settings +schema with **no APRS at all** on an APRS radio, because `MU` stops at menu 500 +and nobody ever counted. Every field in it was measured and right; the set was +the problem. + ## Shape of the work `driver_key = "_"`, `export_format = ""`, diff --git a/CLAUDE.md b/CLAUDE.md index 43e8de7..ba2970f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,11 @@ substantial work under `src-tauri/src/radios/`. three OSes before it lands, which is the point: a branch that has never been verified anywhere but the author's Mac should not reach `main`. This reverses the old rule, which existed only because a PR cost metered minutes. +- **⚠ A radio model is finished work only when the whole model is done.** Keep + pushing the branch — that is what runs CI — but do not open a PR per phase or + per hardware step, and do not treat an open one as something to keep + merge-ready commit by commit. One PR, opened when the radio is essentially + complete: channels and settings both working, the hardware ladder climbed. - **`main` is still verified on its own.** CI runs on push to `main` as well, so a merge of two green branches gets checked as the combination — this project has shipped bugs that existed nowhere else. Landing by local merge is still diff --git a/README.md b/README.md index c8fa9da..bc1bc3e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ card — or exported as CSV for tools that expect it. | **Icom ID-52** | D-STAR + Analog | VHF / UHF TX, 108–174 / 225–479 MHz RX | microSD — patches the radio's own `.icf` file | 1000 memories in 100 groups; memories and menu settings restore in one operation | | **Kenwood TH-D72** | APRS + Analog | 2 m / 70 cm TX, 118–174 / 320–524 MHz RX | Direct USB — read, write, settings | 1000 memories; 113 menu settings over the radio's own `MU` command | | **Kenwood TH-D75** | D-STAR + APRS + Analog | VHF / 1.25 m / UHF TX, 0.1–524 MHz RX | microSD — patches the radio's own `.d75` file | 1000 memories in 30 groups; memories and menu settings, including the APRS setup | +| **Kenwood TM-D710** | APRS + Analog | 2 m / 70 cm TX, 118–524 MHz RX | Direct USB — read, write, settings | 1000 memories; 35 menu settings. Programmed live, one memory at a time over the operation panel's COM port — there is no image file, so a backup is a transcript of the radio's own lines | | **Binteradio BT-9000** | Analog FM/NFM | 18–64 / 136–174 / 200–260 / 400–520 MHz TX, 18–520 MHz RX | Direct USB — read, write, settings | 960 channels in 15 fixed zones; 42 menu settings. Also sold as the Radtel RT-950 Pro, Bajeton BJ-9000 and Tenway TP-900 Pro — the radio reports itself as `RT-950` | Direct USB programming reads the radio's current image, applies your changes, backs up the @@ -59,7 +60,6 @@ settings together, then verify on the actual radio before shipping. | **AnyTone AT-D578UV** | DMR + Analog mobile | [#47](https://github.com/ww8l/codeplug-magic/issues/47) | | **AnyTone AT-D868UV** | DMR + Analog handheld | [#51](https://github.com/ww8l/codeplug-magic/issues/51) | | **Icom ID-51** | D-STAR + Analog handheld | [#50](https://github.com/ww8l/codeplug-magic/issues/50) | -| **Kenwood TM-D710** | APRS + Analog mobile | [#113](https://github.com/ww8l/codeplug-magic/issues/113) | | **Icom ID-5100** | D-STAR + Analog mobile | [#49](https://github.com/ww8l/codeplug-magic/issues/49) | | **Icom IC-9100** | HF / VHF / UHF base | [#45](https://github.com/ww8l/codeplug-magic/issues/45) | | **Icom IC-7610** | HF / 6 m SDR base | [#46](https://github.com/ww8l/codeplug-magic/issues/46) | diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 9cdb67d..5b9510b 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -60,18 +60,18 @@ mod tests { // Models are reintroduced one at a time (migration 0005 trimmed the // original set): currently the Baofeng UV-5R, TIDRADIO TD-H3, AnyTone // AT-D890UV, Yaesu FT5D, Icom ID-52, Kenwood TH-D75, Kenwood TH-D72 - // and the Binteradio BT-9000. (0015 removed - // the Vero VR-N76 placeholder.) None of the last three has a migration - // of its own — seeding INSERTs new (manufacturer, model) rows, so a new - // model reaches existing databases on the next startup without one. + // the Binteradio BT-9000 and Kenwood TM-D710. (0015 removed the Vero + // VR-N76 placeholder.) None of the last four has a migration of its + // own — seeding INSERTs new (manufacturer, model) rows, so a new model + // reaches existing databases on the next startup without one. let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM radio_models") .fetch_one(&pool) .await .unwrap(); assert_eq!( - count.0, 8, - "expected the UV-5R, TD-H3, AT-D890UV, FT5D, ID-52, TH-D75, TH-D72 and BT-9000 \ - seeded models" + count.0, 9, + "expected the UV-5R, TD-H3, AT-D890UV, FT5D, ID-52, TH-D75, TH-D72, BT-9000 \ + and TM-D710 seeded models" ); let models: Vec<(String,)> = @@ -82,7 +82,10 @@ mod tests { let names: Vec<&str> = models.iter().map(|m| m.0.as_str()).collect(); assert_eq!( names, - vec!["AT-D890UV", "BT-9000", "FT5D", "ID-52", "TD-H3", "TH-D72", "TH-D75", "UV-5R"] + vec![ + "AT-D890UV", "BT-9000", "FT5D", "ID-52", "TD-H3", "TH-D72", "TH-D75", "TM-D710", + "UV-5R" + ] ); // Seeding twice must remain idempotent. @@ -91,7 +94,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count2.0, 8, "seeding should be idempotent"); + assert_eq!(count2.0, 9, "seeding should be idempotent"); // A new database starts with NO talkgroups. The BrandMeister list used // to be compiled in and seeded here; it is downloaded on request now, diff --git a/src-tauri/src/radios/kenwood_tmd710/encode.rs b/src-tauri/src/radios/kenwood_tmd710/encode.rs new file mode 100644 index 0000000..2b71ee6 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/encode.rs @@ -0,0 +1,631 @@ +//! Building an `ME` line from a channel in the app's library (issue #113, Phase 2). +//! +//! [`memory`](super::memory) models the line the *radio* prints. This module is +//! the other direction, and it is where every field's legal range has to be +//! known rather than carried through verbatim — a value the radio refuses does +//! not error loudly, it just leaves the slot as it was. +//! +//! ## Everything below was measured on the radio, not inherited +//! +//! The TM-D710 **validates a write and refuses it whole**, so acceptance is a +//! measurement: sweep a field on an empty slot, read back, and the first refused +//! value is the edge of the enum. `d710_field_bounds` is that instrument. +//! +//! | field | measured | +//! |---|---| +//! | 3 step | ten values, and **which** ten depends on the frequency — see [`step_field`] | +//! | 4 shift | `0`/`1`/`2` only. **There is no `3`** | +//! | 5, 6, 7, 8, 16 | `0`/`1` | +//! | 9, 10 tone | `0..=41` | +//! | 11 DCS | `0..=103` | +//! | 12 offset | any value up to **29 950 000 Hz**; 29 955 000 refused | +//! | 13 mode | `0`/`1`/`2` | +//! | 14 tx | an absolute frequency, and **mutually exclusive** with 4 and 12 | +//! | 15 tx step | the same table as field 3, against field 14's frequency | +//! +//! ## ★ The shift field has no split value +//! +//! CHIRP's table lists `3` as split and [`Shift::Split`](super::memory::Shift) +//! was written from it. The radio refuses `3` — with a zero offset, with a +//! 600 kHz offset, and with a TX frequency present. What it *does* accept is +//! shift `0`, offset `0`, and an absolute frequency in field 14; setting a shift +//! or an offset *and* field 14 together is refused in every combination tried. +//! So an odd split is field 14 and nothing else. That is the fourth published +//! claim about this radio to die on contact with it. +//! +//! ⚠ Accepted and stored is not the same as *transmits there*. Every memory in +//! the radio's own capture has field 14 zero, so nothing has ever confirmed the +//! split on the air. It is graded accordingly in `FINDINGS.md`. +//! +//! ## ★ A step that does not divide the frequency is refused +//! +//! The strongest result of the campaign, and an encoder constraint rather than a +//! curiosity: field 3 accepted a *non-contiguous* set of values, different at +//! every frequency, and in each case exactly the steps that divide it evenly. +//! Across 146.520, 145.000, 145.050 and TX 146.820, the table below predicted +//! **40 accept/refuse results with no misses**, which is also what pins index 9 +//! as 50 kHz rather than 100 kHz — 145.050 divides by the first and not the +//! second. +//! +//! A driver that emitted a fixed step would produce memories the radio quietly +//! declines to store. +//! +//! ## What the caller owes the operator +//! +//! Several channels in a radio-agnostic library cannot be expressed here at all: +//! a tone this radio does not have, an offset over 29.95 MHz. Those come back as +//! `Err`, and the program flow must turn them into a **named skip**, the way +//! `ChannelFit` already reports a band it cannot reach. Failing the whole write +//! would be worse, and encoding a nearby value would be worse still — see +//! `radio-tx-vs-rx-bands` for what a silently-wrong memory costs. + +// ⚠ Phase 2 lands the encoder before the path that will call it — the same +// note as `memory.rs` and `tone.rs`, for the same reason. See +// `read-path-working-hides-a-dead-write-path`: an "unused" warning on an +// encoder is normally a bug report, so it is silenced with a reason rather +// than by habit, and comes out when a capability trait calls in. +#![cfg_attr(not(test), allow(dead_code))] + +use super::memory::{Memory, MemoryName, Shift, MAX_NAME}; +use super::tone; +use crate::commands::export; +use crate::models::Channel; + +/// Tuning steps in hertz, in the order field 3 indexes them. +/// +/// Ten entries, because the field is **one character wide**: whatever a +/// TH-D72-style eleventh entry (100 kHz) would be, it cannot be written here. +/// Index 9 is 50 kHz, pinned at 145.050 MHz — divisible by 50 kHz and not by +/// 100 kHz, and accepted. +pub(crate) const STEPS_HZ: [u64; 10] = [ + 5_000, 6_250, 8_330, 10_000, 12_500, 15_000, 20_000, 25_000, 30_000, 50_000, +]; + +/// Index 2. Never *chosen*, only round-tripped: a real 8.33 kHz air-band channel +/// is not an integer multiple of 8330 Hz, so offering it would produce a step +/// the radio refuses. The TH-D72 driver skips its own index 2 for the same +/// reason. +const STEP_833: usize = 2; + +/// Field 12's ceiling. 29 950 000 accepted, 29 955 000 refused — and the same on +/// both bands, which is worth stating because a 60 MHz UHF ceiling would have +/// been the natural guess. +pub(crate) const MAX_OFFSET_HZ: u64 = 29_950_000; + +/// Field 13. `2` is pinned by the radio's own 118.400 MHz memory — air band is +/// AM — and `0` by the 37 FM repeaters beside it. The field accepts exactly +/// three values and the radio offers exactly three modes (Menu 102: AM, FM, +/// NFM), so `1` is NFM **by elimination**, not by reading the menu's order. +const MODE_FM: &str = "0"; +const MODE_NFM: &str = "1"; +const MODE_AM: &str = "2"; + +/// What the radio itself puts in fields 9 and 10 on a memory with no tone at +/// all: index 8, 88.5 Hz. Both of the capture's two toneless memories carry +/// `08,08`, so this is the radio's habit rather than a convenient zero. +const DEFAULT_TONE_FIELD: &str = "08"; + +/// Field 11 on every one of the radio's 38 memories. None of them uses DCS. +const DEFAULT_DCS_FIELD: &str = "000"; + +/// The comma is the `ME`/`MN` field separator, and the radio rewrites it to `+` +/// rather than refusing the name. Measured over the whole printable range: 94 of +/// 95 characters survive a round trip verbatim — **lowercase included** — and +/// this is the only one that does not. +const NAME_COMMA_REPLACEMENT: char = '+'; + +/// The lowest-indexed step that divides `hz` exactly. +/// +/// Not a preference — a requirement. The radio refuses a step that does not +/// divide the frequency, and refuses it *silently*, leaving the slot unwritten. +pub(crate) fn step_field(hz: u64) -> Result { + STEPS_HZ + .iter() + .enumerate() + .filter(|&(i, _)| i != STEP_833) + .find(|&(_, &step)| hz.is_multiple_of(step)) + .map(|(i, _)| i.to_string()) + .ok_or_else(|| { + format!( + "{:.5} MHz is not a multiple of any tuning step the TM-D710 has, so the radio \ + would refuse the memory", + hz as f64 / 1_000_000.0 + ) + }) +} + +/// Hertz from the megahertz the database stores. +fn mhz_to_hz(mhz: f64) -> u64 { + (mhz * 1_000_000.0).round() as u64 +} + +/// Build the `ME` line's contents for one channel. +/// +/// `Err` means the radio cannot hold this channel as described — a tone it does +/// not have, an offset past its ceiling — and the caller is expected to skip the +/// channel with the reason shown, never to substitute a nearby value. +pub(crate) fn encode_channel(slot: u16, c: &Channel) -> Result { + let rx_hz = mhz_to_hz(c.rx_freq); + let tx_hz = mhz_to_hz(export::tx_frequency(c)); + + // Field 14 or fields 4+12, never both — measured, in every combination. + let split = c.duplex.as_deref() == Some("split"); + let (shift, offset_hz, split_tx_hz) = if split { + (Shift::Simplex, 0, tx_hz) + } else if tx_hz > rx_hz { + (Shift::Plus, tx_hz - rx_hz, 0) + } else if tx_hz < rx_hz { + (Shift::Minus, rx_hz - tx_hz, 0) + } else { + (Shift::Simplex, 0, 0) + }; + if offset_hz > MAX_OFFSET_HZ { + return Err(format!( + "a {:.3} MHz repeater shift is past the TM-D710's {:.2} MHz limit; the radio would \ + refuse the memory", + offset_hz as f64 / 1_000_000.0, + MAX_OFFSET_HZ as f64 / 1_000_000.0 + )); + } + + let mode = match c.mode.as_deref() { + // The radio is analog-only. A digital channel reaching here at all is a + // question for the model's exclusion rules, not for the encoder, so it + // lands on FM the way every other analog driver here treats one. + Some(m) if m.eq_ignore_ascii_case("NFM") => MODE_NFM, + Some(m) if m.eq_ignore_ascii_case("AM") => MODE_AM, + _ => MODE_FM, + }; + + // All eight flag combinations are accepted by the radio — it enforces + // nothing here — so exactly one is set on purpose. Cross Tone exists on this + // radio (Menu: Tone, CTCSS, DCS, Cross Tone) but **how it is stored has not + // been measured**, and the repo's rule for a radio that cannot express a + // cross tone is to keep the transmit tone and drop the receive one. That is + // strictly what a guessed flag pair might not do. + let requested = c.tone_mode.as_deref().unwrap_or(""); + let (tone_on, ctcss_on, dcs_on) = if requested.eq_ignore_ascii_case("TSQL") { + ("0", "1", "0") + } else if requested.eq_ignore_ascii_case("DTCS") { + ("0", "0", "1") + } else if requested.eq_ignore_ascii_case("Tone") || requested.eq_ignore_ascii_case("Cross") { + ("1", "0", "0") + } else { + ("0", "0", "0") + }; + + // Both tone fields are always populated, which is what the radio does even + // on a memory with no tone at all. + let tone_idx = match c.ctcss_uplink.or(c.ctcss_downlink) { + Some(hz) => tone::tone_field(hz)?, + None => DEFAULT_TONE_FIELD.to_string(), + }; + let ctcss_idx = match c.ctcss_downlink.or(c.ctcss_uplink) { + Some(hz) => tone::tone_field(hz)?, + None => DEFAULT_TONE_FIELD.to_string(), + }; + let dcs_idx = match c.dcs_code.as_deref().filter(|s| !s.is_empty()) { + Some(code) => tone::dcs_field(code)?, + None => DEFAULT_DCS_FIELD.to_string(), + }; + + Ok(Memory { + slot, + rx_hz, + step: step_field(rx_hz)?, + shift, + reverse: "0".into(), + tone_on: tone_on.into(), + ctcss_on: ctcss_on.into(), + dcs_on: dcs_on.into(), + tone_idx, + ctcss_idx, + dcs_idx, + offset_hz, + mode: mode.into(), + tx_hz: split_tx_hz, + // Measured: with field 14 zero the radio accepts only `0` here, and with + // a TX frequency present it accepts exactly that frequency's steps. + tx_step: if split { + step_field(split_tx_hz)? + } else { + "0".into() + }, + lockout: "0".into(), + }) +} + +/// Turn a memory the radio printed back into the channel it describes, so +/// the encoder can be asked to rebuild the same line. +/// +/// This is the decode the app does not otherwise need — the D710 driver +/// reads memories as text — and it exists only to close the loop. Anything +/// it cannot express is a real gap in the mapping, which is the point. +pub(crate) fn decode_channel(m: &Memory) -> Channel { + let mhz = |hz: u64| hz as f64 / 1_000_000.0; + let (duplex, offset, tx_freq) = match m.shift { + Shift::Plus => (Some("+"), Some(mhz(m.offset_hz)), None), + Shift::Minus => (Some("-"), Some(mhz(m.offset_hz)), None), + // Field 4 has no split value — an absolute TX frequency in field 14 is + // how this radio expresses one. See the module doc. + _ if m.tx_hz != 0 => (Some("split"), None, Some(mhz(m.tx_hz))), + _ => (None, None, None), + }; + Channel { + rx_freq: mhz(m.rx_hz), + duplex: duplex.map(str::to_string), + offset, + tx_freq, + mode: Some( + match m.mode.as_str() { + "1" => "NFM", + "2" => "AM", + _ => "FM", + } + .into(), + ), + tone_mode: if m.tone_on == "1" { + Some("Tone".into()) + } else if m.ctcss_on == "1" { + Some("TSQL".into()) + } else if m.dcs_on == "1" { + Some("DTCS".into()) + } else { + None + }, + ctcss_uplink: Some(tone::tone_hz(&m.tone_idx).expect("tone index off the table")), + ctcss_downlink: Some(tone::tone_hz(&m.ctcss_idx).expect("ctcss index off the table")), + dcs_code: Some(tone::dcs_code(&m.dcs_idx).expect("dcs index off the table")), + ..Default::default() + } +} + +/// The name to send with `MN`, sanitised and cut to what the radio keeps. +/// +/// Both limits are measured rather than read off Menu 200: a ninth character is +/// **silently truncated**, not refused, and a comma is silently rewritten. Doing +/// both here means [`write_name`](super::write_name)'s read-back check stays a +/// real check — otherwise every name over eight characters would fail it. +pub(crate) fn encode_name(slot: u16, c: &Channel) -> MemoryName { + let source = c + .name_short + .as_deref() + .or(c.name_long.as_deref()) + .or(c.callsign.as_deref()) + .unwrap_or(""); + MemoryName { + slot, + text: sanitize_name(source), + } +} + +/// Cut and clean any string into what the radio will keep verbatim. +/// +/// Split out because the program path names channels with `expanded_name` — the +/// app's own disambiguated name, which is what the export preview shows — and +/// that string needs exactly the same treatment. Two places doing this +/// differently is two different names on the radio for the same channel. +pub(crate) fn sanitize_name(source: &str) -> String { + source + .chars() + .map(|ch| if ch == ',' { NAME_COMMA_REPLACEMENT } else { ch }) + .take(MAX_NAME) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn channel(rx: f64) -> Channel { + Channel { + rx_freq: rx, + ..Default::default() + } + } + + /// ★ The step rule, stated as the radio stated it. These are the exact + /// frequencies `d710_field_bounds` was run at, and the exact sets it came + /// back with — so a change to `STEPS_HZ` that broke the table would fail + /// here rather than on the radio. + #[test] + fn the_step_table_reproduces_what_the_radio_accepted() { + let accepted = |hz: u64| -> Vec { + STEPS_HZ + .iter() + .enumerate() + .filter(|&(_, &s)| hz.is_multiple_of(s)) + .map(|(i, _)| i) + .collect() + }; + assert_eq!(accepted(146_520_000), vec![0, 3, 5, 6, 8]); + assert_eq!(accepted(145_000_000), vec![0, 1, 3, 4, 6, 7, 9]); + assert_eq!(accepted(145_050_000), vec![0, 1, 3, 4, 5, 7, 8, 9]); + assert_eq!(accepted(146_820_000), vec![0, 3, 5, 6, 8]); + } + + /// A step is chosen, never assumed: 5 kHz where it divides, and the first + /// one that does otherwise. 8.33 is skipped even where it would divide. + #[test] + fn a_step_is_picked_that_actually_divides_the_frequency() { + assert_eq!(step_field(146_520_000).unwrap(), "0"); + assert_eq!(step_field(145_006_250).unwrap(), "1"); + assert!(step_field(8_330).unwrap_err().contains("tuning step")); + } + + /// A plain minus-shift repeater, field by field, against a line shaped like + /// the ones the radio itself prints. + #[test] + fn a_minus_shift_repeater_encodes_the_way_the_radio_writes_one() { + let mut c = channel(447.275); + c.duplex = Some("-".into()); + c.offset = Some(5.0); + c.tone_mode = Some("TSQL".into()); + c.ctcss_uplink = Some(100.0); + c.ctcss_downlink = Some(100.0); + let m = encode_channel(0, &c).unwrap(); + assert_eq!( + m.to_line(), + "ME 000,0447275000,0,2,0,0,1,0,12,12,000,05000000,0,0000000000,0,0" + ); + } + + /// ★ An odd split is field 14 alone. The shift stays `0` and the offset + /// stays zero, because the radio refuses any combination of the two with a + /// TX frequency present — and refuses shift `3` outright. + #[test] + fn a_split_puts_the_tx_frequency_in_field_14_and_nothing_in_the_shift() { + let mut c = channel(146.520); + c.duplex = Some("split".into()); + c.tx_freq = Some(146.820); + let m = encode_channel(7, &c).unwrap(); + assert_eq!(m.shift, Shift::Simplex); + assert_eq!(m.offset_hz, 0); + assert_eq!(m.tx_hz, 146_820_000); + // 5 kHz divides 146.820, so the lowest listed step wins — the same + // rule field 3 follows, applied to field 14's frequency. + assert_eq!(m.tx_step, "0"); + assert_eq!( + m.to_line(), + "ME 007,0146520000,0,0,0,0,0,0,08,08,000,00000000,0,0146820000,0,0" + ); + } + + /// Nothing the encoder can be handed may produce field 4 = `3`. The variant + /// still exists so a line carrying one can be *read*, but the radio refused + /// it in every base tried and this driver must never emit it. + #[test] + fn no_channel_shape_encodes_the_split_shift_the_radio_refuses() { + for (duplex, tx) in [ + (Some("split"), Some(146.820)), + (Some("+"), None), + (Some("-"), None), + (None, None), + (Some("split"), Some(146.520)), + ] { + let mut c = channel(146.520); + c.duplex = duplex.map(str::to_string); + c.tx_freq = tx; + c.offset = Some(0.6); + let m = encode_channel(0, &c).unwrap(); + assert_ne!(m.shift, Shift::Split, "{duplex:?}/{tx:?} produced shift 3"); + assert!( + m.tx_hz == 0 || (m.offset_hz == 0 && m.shift == Shift::Simplex), + "{duplex:?}/{tx:?} set a TX frequency and a shift/offset together, which the \ + radio refuses: {}", + m.to_line() + ); + } + } + + /// The measured ceiling, and the reason it is an error rather than a clamp: + /// a clamped shift transmits on the wrong frequency. + #[test] + fn an_offset_past_the_radios_ceiling_is_refused_not_clamped() { + let mut c = channel(146.520); + c.duplex = Some("+".into()); + c.offset = Some(30.0); + let err = encode_channel(0, &c).unwrap_err(); + assert!(err.contains("29.95"), "{err}"); + + let mut ok = channel(146.520); + ok.duplex = Some("+".into()); + ok.offset = Some(29.95); + assert_eq!(encode_channel(0, &ok).unwrap().offset_hz, 29_950_000); + } + + /// A toneless memory still carries both tone fields, filled the way the + /// radio fills them. + #[test] + fn a_channel_with_no_tone_gets_the_radios_own_default_indices() { + let m = encode_channel(0, &channel(146.520)).unwrap(); + assert_eq!((m.tone_on.as_str(), m.ctcss_on.as_str(), m.dcs_on.as_str()), ("0", "0", "0")); + assert_eq!(m.tone_idx, "08"); + assert_eq!(m.ctcss_idx, "08"); + assert_eq!(m.dcs_idx, "000"); + } + + /// Exactly one flag, never a combination — the radio accepts all eight and + /// enforces none, so this is the encoder's job alone. + #[test] + fn exactly_one_tone_flag_is_ever_set() { + for mode in ["Tone", "TSQL", "DTCS", "Cross", "", "nonsense"] { + let mut c = channel(146.520); + c.tone_mode = Some(mode.into()); + c.ctcss_uplink = Some(100.0); + c.dcs_code = Some("023".into()); + let m = encode_channel(0, &c).unwrap(); + let on = [&m.tone_on, &m.ctcss_on, &m.dcs_on] + .iter() + .filter(|f| f.as_str() == "1") + .count(); + assert!(on <= 1, "{mode:?} set {on} flags: {}", m.to_line()); + } + } + + /// Cross falls back to the transmit tone rather than guessing a flag pair. + #[test] + fn cross_keeps_the_transmit_tone_instead_of_guessing() { + let mut c = channel(146.520); + c.tone_mode = Some("Cross".into()); + c.ctcss_uplink = Some(123.0); + c.ctcss_downlink = Some(100.0); + let m = encode_channel(0, &c).unwrap(); + assert_eq!(m.tone_on, "1"); + assert_eq!(m.tone_idx, tone::tone_field(123.0).unwrap()); + } + + /// A tone the radio does not have stops this channel and names it, so the + /// caller can skip it. It must not become the nearest tone it does have. + #[test] + fn a_tone_off_the_radios_table_stops_the_channel_with_a_reason() { + let mut c = channel(146.520); + c.tone_mode = Some("Tone".into()); + c.ctcss_uplink = Some(159.8); + let err = encode_channel(0, &c).unwrap_err(); + assert!(err.contains("159.8"), "{err}"); + } + + /// Both name limits, both measured: eight characters, and the one character + /// the radio rewrites instead of refusing. + #[test] + fn a_name_is_cut_and_the_comma_replaced_before_the_radio_does_it() { + let mut c = channel(146.520); + c.name_short = Some("DENVER, CO".into()); + assert_eq!(encode_name(3, &c).text, "DENVER+ "); + assert_eq!(encode_name(3, &c).to_line(), "MN 003,DENVER+ "); + + c.name_short = None; + c.name_long = None; + c.callsign = Some("W0UPS".into()); + assert_eq!(encode_name(3, &c).text, "W0UPS"); + + c.callsign = None; + assert_eq!(encode_name(3, &c).text, ""); + } + + /// Whatever the encoder builds must survive the round trip the radio's own + /// lines are held to — widths included, since `0` and `000` are different + /// lines. + #[test] + fn everything_encoded_re_parses_to_itself() { + for (rx, duplex, offset, mode, tone) in [ + (146.520, None, None, None, None), + (447.275, Some("-"), Some(5.0), Some("FM"), Some("TSQL")), + (145.006_25, Some("+"), Some(0.6), Some("NFM"), Some("Tone")), + (118.400, None, None, Some("AM"), None), + (146.520, Some("split"), None, None, Some("DTCS")), + ] { + let mut c = channel(rx); + c.duplex = duplex.map(str::to_string); + c.offset = offset; + c.mode = mode.map(str::to_string); + c.tone_mode = tone.map(str::to_string); + c.ctcss_uplink = Some(100.0); + c.dcs_code = Some("023".into()); + if duplex == Some("split") { + c.tx_freq = Some(146.820); + } + let m = encode_channel(42, &c).unwrap(); + let line = m.to_line(); + assert_eq!(Memory::parse(&line).unwrap().to_line(), line, "{line}"); + } + } +} + +#[cfg(test)] +mod round_trip { + use super::*; + use crate::radios::kenwood_tmd710::memory::Memory; + + /// ★ **The Phase 2 gate, in the direction that matters.** + /// + /// `memory.rs` proves a line the radio printed comes back out unchanged. + /// That tests the text, not the meaning. This decodes each real memory into + /// app terms and asks the *encoder* to rebuild it — so a field mapped the + /// wrong way round, a tone table off by one, or a shift encoded as an + /// offset all fail here rather than on the radio. + /// + /// Three fields are excluded, each named rather than smoothed over: + /// + /// - **field 3, the step.** The radio accepts any step that divides the + /// frequency, so the one it happens to hold is not the only right answer: + /// two of Tim's memories carry 25 kHz where the encoder picks 5 kHz, and + /// both are legal. Field 15 goes with it. + /// - **field 16, the lockout.** The app has no per-channel scan lockout to + /// round-trip through. + /// - **field 12 on a simplex memory.** ★ This one the gate found. Memory + /// 040 — 144.390, the APRS calling frequency — is shift `0` and still + /// carries a 600 kHz offset, so the radio keeps the offset field + /// independently of whether the shift uses it. It is residue from an + /// earlier edit, there is nothing in a channel record that could hold it, + /// and the encoder writing `00000000` there is correct: measured, the + /// radio accepts a zero offset with a zero shift. + fn rebuild_diff(me: &str) -> Option { + let original = Memory::parse(me).unwrap_or_else(|e| panic!("{me}: {e}")); + let rebuilt = match encode_channel(original.slot, &decode_channel(&original)) { + Ok(m) => m, + Err(e) => return Some(format!("{me}\n encoder refused it: {e}")), + }; + + let simplex = original.shift == Shift::Simplex && original.tx_hz == 0; + let normalise = |m: &Memory| Memory { + step: "-".into(), + tx_step: "-".into(), + lockout: "-".into(), + offset_hz: if simplex { 0 } else { m.offset_hz }, + ..m.clone() + }; + (normalise(&rebuilt).to_line() != normalise(&original).to_line()).then(|| { + format!("{me}\n rebuilt: {}", rebuilt.to_line()) + }) + } + + fn assert_all_rebuild(lines: impl Iterator) -> usize { + let (mut checked, mut bad) = (0, Vec::new()); + for me in lines { + checked += 1; + if let Some(d) = rebuild_diff(&me) { + bad.push(d); + } + } + assert!( + bad.is_empty(), + "{} of {checked} memories did not rebuild:\n {}", + bad.len(), + bad.join("\n ") + ); + checked + } + + /// The four lines that always run, matching `memory.rs`'s own sample: a + /// UHF minus, a VHF plus, a memory whose two tone fields differ, and a + /// 220 MHz repeater. + #[test] + fn the_sample_memories_rebuild_from_their_own_contents() { + let n = assert_all_rebuild( + [ + "ME 000,0447275000,0,2,0,0,1,0,12,12,000,05000000,0,0000000000,0,0", + "ME 007,0147360000,0,1,0,0,1,0,12,12,000,00600000,0,0000000000,0,0", + "ME 009,0145310000,0,2,0,0,1,0,08,18,000,00600000,0,0000000000,0,0", + "ME 005,0224840000,0,2,0,0,1,0,12,12,000,01600000,0,0000000000,0,0", + ] + .iter() + .map(|s| s.to_string()), + ); + assert_eq!(n, 4); + } + + /// The same, against every memory on the radio, when the gitignored capture + /// is on this machine — 38 real ones including the 118.400 MHz AM air-band + /// memory and the two on a 25 kHz step. A no-op in CI. + #[test] + fn every_captured_memory_rebuilds_from_its_own_contents() { + let Ok(text) = std::fs::read_to_string("../scratchpad/kenwood_tmd710/memories.txt") else { + return; + }; + let checked = + assert_all_rebuild(text.lines().filter(|l| l.starts_with("ME ")).map(str::to_string)); + assert!(checked >= 30, "only {checked} memories in the capture"); + } +} diff --git a/src-tauri/src/radios/kenwood_tmd710/image.rs b/src-tauri/src/radios/kenwood_tmd710/image.rs new file mode 100644 index 0000000..7a0f6ba --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/image.rs @@ -0,0 +1,489 @@ +//! The TM-D710's **second** transport: the memory image behind `0M PROGRAM`. +//! +//! The rest of this driver talks to the radio in ASCII, one command per memory. +//! That is not the whole radio. `MU` carries 42 menu parameters and reaches +//! none of the 600-series, which is 32 APRS and TNC menus — the largest group +//! on the radio and the feature it is named for. Those live in a binary image +//! that MCP-2A reads, and this module is how to get at it. +//! +//! ```text +//! 0M PROGRAM -> 0M the display shows PROG MCP +//! R -> W (len 0 = 256) +//! the HOST then sends 06 and the radio answers 06 +//! W +//! -> 06 the RADIO acknowledges; nothing goes back +//! E -> 06 0D 00 back to normal +//! ``` +//! +//! ## Three things that each cost a session to find +//! +//! 1. **The address is big-endian.** Published notes for this mode say little. +//! `0x0000` is the same two bytes either way, so the error survives being +//! tested and the whole dump comes back drifting one byte per block. +//! 2. **A read is acknowledged by the host; a write is acknowledged by the +//! radio.** Nothing published mentions the read acknowledgement, and without +//! it only the first `R` of a session ever answers. Getting the asymmetry +//! backwards leaves the stream one byte out of step from then on. +//! 3. **`0x7F00` is a hole, not the end.** The radio answers nothing there and +//! answers again at `0x8000`. A reader that walks forward until a request +//! fails reports 32 512 bytes as "the image" and loses the 7 KB above it — +//! which is exactly where the APRS settings are. CHIRP's clone-mode driver +//! skips the same block with the comment `# Skip block 7f !!??`. +//! +//! ## ⚠ Entering this mode is not free +//! +//! Nothing here writes unless asked to, but `0M PROGRAM` puts `PROG MCP` on the +//! radio's display and **leaving it there strands the operator** — the radio +//! stops answering `ID`, which looks exactly like a dead cable, and only a +//! power cycle gets it back. [`ProgramMode`] therefore sends `E` from `Drop`, +//! so an early return or a panic still leaves the radio usable. +//! +//! ## A narrow write commits +//! +//! CHIRP uploads all 156 blocks wrapped in an invalidate/revalidate ritual — +//! `FF` over the first byte of the headers at `0x0000` and `0x8000`, every +//! block, then the saved headers back — so that an interrupted upload leaves an +//! image explicitly marked bad rather than a plausible mixture. That is the +//! right shape for a full upload and **it is not required to change one field**: +//! measured on Tim's radio, 42 bytes written to one status text survived leaving +//! program mode and re-entering, and showed up on the radio's own menu. + +use serialport::SerialPort; +use std::time::{Duration, Instant}; + +/// The image is addressed by a 16-bit word, so this is its whole extent. A +/// buffer of this size means **a file offset is a radio address**, which is the +/// only convention worth measuring in: CHIRP concatenates the blocks it read, +/// which silently shifts everything above the hole down by `0x100`. +pub(crate) const IMAGE_SPAN: usize = 0x1_0000; + +/// The one block in `0x00`-`0x9B` the radio does not answer. +pub(crate) const HOLE: u16 = 0x7F00; + +/// The live APRS/TNC settings. Five more copies follow at `+ n * 0x480`, one +/// per PM profile; those are the operator's saved configurations and this +/// driver has no business writing them. +pub(crate) const APRS_LIVE: u16 = 0x8100; + +/// Bytes per APRS/TNC config block. +pub(crate) const APRS_BLOCK_LEN: usize = 0x480; + +/// Long enough for a 256-byte block at 57 600 baud with room to spare, short +/// enough that the hole at `0x7F00` is diagnosed rather than waited on. +const BLOCK_TIMEOUT: Duration = Duration::from_millis(1200); + +/// Every request MCP-2A makes, in its order: 256-byte blocks `0x00`-`0x9B` +/// except the hole, then two odd tails. +/// +/// `len` of `0` means 256 — the radio's own convention, not ours. +pub(crate) fn read_plan() -> Vec<(u16, u8)> { + let mut plan: Vec<(u16, u8)> = (0u16..0x9C) + .map(|b| b << 8) + .filter(|addr| *addr != HOLE) + .map(|addr| (addr, 0u8)) + .collect(); + plan.push((0xFEF0, 0x10)); + plan.push((0xFF00, 0x90)); + plan +} + +/// What came back, laid out at the addresses it came from. +pub(crate) struct Image { + bytes: Vec, + /// Address ranges actually answered, so a caller cannot mistake the `FF` + /// filler for a region the radio really holds `FF` in. + read: Vec<(u32, u32)>, +} + +impl Image { + /// The bytes at `addr`, or an error naming what was not read. + /// + /// ⚠ The distinction matters more here than on a clone radio: this image is + /// mostly holes, and `FF` is also a perfectly ordinary stored value — an + /// empty status text is 42 of them. Reading unread filler as data would put + /// "the field is empty" and "the field was never fetched" into the same + /// answer. + pub(crate) fn slice(&self, addr: u16, len: usize) -> Result<&[u8], String> { + let start = addr as u32; + let end = start + len as u32; + if end as usize > IMAGE_SPAN { + return Err(format!("0x{addr:04X}+{len} runs past the end of the image")); + } + if !self.read.iter().any(|(a, b)| *a <= start && end <= *b) { + return Err(format!( + "0x{addr:04X}..0x{:04X} was never read from the radio", + end.saturating_sub(1) + )); + } + Ok(&self.bytes[start as usize..end as usize]) + } + + /// Total bytes the radio answered with. + pub(crate) fn bytes_read(&self) -> usize { + self.read.iter().map(|(a, b)| (b - a) as usize).sum() + } + + /// The whole buffer, `FF` where nothing was read — for writing a dump file + /// whose offsets are addresses. + pub(crate) fn as_addressed_bytes(&self) -> &[u8] { + &self.bytes + } +} + +/// A program-mode session. Exits on drop. +pub(crate) struct ProgramMode<'a> { + port: &'a mut dyn SerialPort, + inside: bool, +} + +impl<'a> ProgramMode<'a> { + /// `0M PROGRAM`, tolerating the one `?` this radio can answer when the + /// previous command left its parser mid-line. + pub(crate) fn enter(port: &'a mut dyn SerialPort) -> Result { + let reply = super::ask_settling(port, "0M PROGRAM")?; + if !reply.starts_with("0M") { + return Err(format!( + "the radio refused program mode, answering {reply:?}. It has to be on and idle — \ + not already in PROG MCP from an earlier run." + )); + } + Ok(Self { port, inside: true }) + } + + /// One block. `len` of `0` asks for 256, which is the radio's convention. + pub(crate) fn read(&mut self, addr: u16, len: u8) -> Result, String> { + let req = [b'R', (addr >> 8) as u8, (addr & 0xFF) as u8, len]; + self.send(&req)?; + + let mut head = [0u8; 4]; + self.fill(&mut head).map_err(|e| format!("reading 0x{addr:04X}: {e}"))?; + if head[0] != b'W' { + return Err(format!( + "reading 0x{addr:04X}: expected a W header, got {head:02X?}. \ + A stream one byte out of step looks exactly like this." + )); + } + let n = if head[3] == 0 { 256 } else { head[3] as usize }; + let mut data = vec![0u8; n]; + self.fill(&mut data).map_err(|e| format!("reading 0x{addr:04X}: {e}"))?; + + // ★ The host acknowledges a read. Skip it and the next request is never + // answered — which reads like a refusal and is not. + self.send(&[0x06])?; + let mut status = [0u8; 1]; + self.fill(&mut status).map_err(|e| format!("acknowledging 0x{addr:04X}: {e}"))?; + check_status(status[0])?; + Ok(data) + } + + /// One block, written. 1..=256 bytes at any address — this radio does not + /// require block alignment and does not require the header dance. + pub(crate) fn write(&mut self, addr: u16, data: &[u8]) -> Result<(), String> { + if data.is_empty() || data.len() > 256 { + return Err(format!("a block is 1..=256 bytes, not {}", data.len())); + } + let len = if data.len() == 256 { 0u8 } else { data.len() as u8 }; + let mut req = vec![b'W', (addr >> 8) as u8, (addr & 0xFF) as u8, len]; + req.extend_from_slice(data); + self.send(&req)?; + + // ★ And here the RADIO acknowledges, with nothing to send back. The + // asymmetry with `read` is the whole framing trap. + let mut status = [0u8; 1]; + self.fill(&mut status).map_err(|e| format!("writing 0x{addr:04X}: {e}"))?; + check_status(status[0]).map_err(|e| format!("writing 0x{addr:04X}: {e}")) + } + + /// The whole image, every request in [`read_plan`]. + pub(crate) fn read_image(&mut self) -> Result { + let mut bytes = vec![0xFFu8; IMAGE_SPAN]; + let mut read = Vec::new(); + for (addr, len) in read_plan() { + let data = self.read(addr, len)?; + let start = addr as u32; + let end = start + data.len() as u32; + bytes[start as usize..end as usize].copy_from_slice(&data); + read.push((start, end)); + } + Ok(Image { bytes, read }) + } + + /// `E`, checked. Prefer this to letting the session drop, which cannot + /// report a failure. + pub(crate) fn leave(mut self) -> Result<(), String> { + self.exit() + } + + fn exit(&mut self) -> Result<(), String> { + if !self.inside { + return Ok(()); + } + self.inside = false; + self.send(b"E")?; + let mut ack = [0u8; 3]; + // The radio answers `06 0D 00`. A short read here is worth reporting + // but not worth failing an otherwise good session over. + match self.fill(&mut ack) { + Ok(()) if ack[0] == 0x06 => Ok(()), + Ok(()) => Err(format!("leaving program mode: the radio answered {ack:02X?}")), + Err(e) => Err(format!("leaving program mode: {e}")), + } + } + + fn send(&mut self, bytes: &[u8]) -> Result<(), String> { + self.port.write_all(bytes).map_err(|e| e.to_string())?; + self.port.flush().map_err(|e| e.to_string()) + } + + fn fill(&mut self, buf: &mut [u8]) -> Result<(), String> { + let deadline = Instant::now() + BLOCK_TIMEOUT; + let mut got = 0; + while got < buf.len() { + if Instant::now() >= deadline { + return Err(format!("timed out after {got} of {} bytes", buf.len())); + } + match self.port.read(&mut buf[got..]) { + Ok(0) => continue, + Ok(n) => got += n, + Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => continue, + Err(e) => return Err(e.to_string()), + } + } + Ok(()) + } +} + +impl Drop for ProgramMode<'_> { + fn drop(&mut self) { + // ⚠ Not tidiness. A radio left in PROG MCP answers nothing at all, and + // the operator's next move is to start unplugging the cable. + let _ = self.exit(); + } +} + +/// The one-byte status this mode answers with. +fn check_status(b: u8) -> Result<(), String> { + match b { + 0x06 => Ok(()), + // Published, and worth naming rather than reporting as a refusal: the + // radio drops into this when the host leaves it idle in program mode + // and the display changes to PROG ERR. It says nothing about the + // command in hand, so looking for a validation rule here is a dead end. + 0x0F => Err("the radio is in the program-mode error state (PROG ERR); \ + leave and re-enter program mode" + .into()), + other => Err(format!("the radio answered with status {other:02X}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::radios::fake_port::{FakePort, FakeRadio}; + + /// A TM-D710 that speaks both halves: ASCII until `0M PROGRAM`, binary + /// after it. + /// + /// ⚠ What this cannot prove is that the wire format is right — it replays + /// the format measured on Tim's radio. What it proves is the SEQUENCING: + /// that the hole is skipped, that a read is acknowledged and a write is + /// not, that `E` goes out even when the caller never asks, and that an + /// unread region is refused rather than served as `FF`. + struct FakeProgD710 { + prog: bool, + mem: Vec, + /// Requests seen in program mode, for asserting the sequence. + pub seen: Vec, + /// Answer nothing at the hole, exactly as the radio does. + pub hole_is_silent: bool, + /// Acknowledge a write and keep the old bytes — the failure that + /// "0x06 came back" cannot distinguish from success. + pub stubborn: bool, + } + + impl FakeProgD710 { + fn new() -> Self { + let mut mem = vec![0xFFu8; IMAGE_SPAN]; + // Something recognisable in the APRS block and in block 0. + mem[0..4].copy_from_slice(&[0x03, 0x4B, 0x01, 0xFF]); + mem[APRS_LIVE as usize..APRS_LIVE as usize + 6].copy_from_slice(b"WW8L-1"); + Self { prog: false, mem, seen: Vec::new(), hole_is_silent: true, stubborn: false } + } + } + + impl FakeRadio for FakeProgD710 { + fn step(&mut self, req: &[u8], out: &mut Vec) -> usize { + if !self.prog { + let Some(end) = req.iter().position(|&b| b == b'\r') else { + return 0; + }; + let cmd = String::from_utf8_lossy(&req[..end]).into_owned(); + let reply = if cmd == "ID" { + "ID TM-D710".to_string() + } else if cmd == "0M PROGRAM" { + self.prog = true; + "0M".to_string() + } else { + "?".to_string() + }; + out.extend_from_slice(reply.as_bytes()); + out.push(b'\r'); + return end + 1; + } + + match req.first() { + None => 0, + Some(b'E') => { + self.prog = false; + self.seen.push("E".into()); + out.extend_from_slice(&[0x06, 0x0D, 0x00]); + 1 + } + Some(0x06) => { + // The host acknowledging a read; the radio answers in kind. + out.push(0x06); + 1 + } + Some(b'R') => { + if req.len() < 4 { + return 0; + } + let addr = u16::from_be_bytes([req[1], req[2]]); + let n = if req[3] == 0 { 256 } else { req[3] as usize }; + self.seen.push(format!("R {addr:04X} {n}")); + if self.hole_is_silent && addr == HOLE { + return 4; // consumed, and answered with nothing at all + } + out.extend_from_slice(&[b'W', req[1], req[2], req[3]]); + out.extend_from_slice(&self.mem[addr as usize..addr as usize + n]); + 4 + } + Some(b'W') => { + if req.len() < 4 { + return 0; + } + let n = if req[3] == 0 { 256 } else { req[3] as usize }; + if req.len() < 4 + n { + return 0; + } + let addr = u16::from_be_bytes([req[1], req[2]]); + self.seen.push(format!("W {addr:04X} {n}")); + if !self.stubborn { + self.mem[addr as usize..addr as usize + n] + .copy_from_slice(&req[4..4 + n]); + } + out.push(0x06); + 4 + n + } + Some(other) => panic!("the fake cannot classify {other:02X} in program mode"), + } + } + } + + #[test] + fn a_full_read_skips_the_hole_and_lands_every_block_at_its_own_address() { + let mut port = FakePort::new(FakeProgD710::new()); + let image = { + let mut prog = ProgramMode::enter(&mut port).expect("enter"); + let image = prog.read_image().expect("read the image"); + prog.leave().expect("leave"); + image + }; + + assert_eq!(image.bytes_read(), 39_840, "MCP's own ritual returns this many bytes"); + assert_eq!(image.slice(APRS_LIVE, 6).unwrap(), b"WW8L-1"); + assert_eq!(image.slice(0, 4).unwrap(), &[0x03, 0x4B, 0x01, 0xFF]); + + // The hole was never asked for, and its bytes are refused rather than + // handed back as the FF they are filled with. + assert!(!port.radio.seen.iter().any(|s| s.starts_with("R 7F00"))); + let err = image.slice(HOLE, 4).unwrap_err(); + assert!(err.contains("never read"), "{err}"); + } + + #[test] + fn an_unread_region_is_refused_because_ff_is_also_a_real_value() { + let mut port = FakePort::new(FakeProgD710::new()); + let mut prog = ProgramMode::enter(&mut port).expect("enter"); + let one = prog.read(APRS_LIVE, 16).expect("one block"); + assert_eq!(&one[..6], b"WW8L-1"); + // Reading one block does not make the rest of the image available. + let image = Image { bytes: vec![0xFF; IMAGE_SPAN], read: vec![] }; + assert!(image.slice(APRS_LIVE, 1).is_err()); + } + + #[test] + fn a_read_is_acknowledged_by_the_host_and_a_write_is_not() { + let mut port = FakePort::new(FakeProgD710::new()); + { + let mut prog = ProgramMode::enter(&mut port).expect("enter"); + prog.read(APRS_LIVE, 4).expect("read"); + prog.write(APRS_LIVE, b"K0AA").expect("write"); + prog.leave().expect("leave"); + } + // If the driver had acknowledged the write too, the fake would have + // answered that stray 0x06 and the next request would be one byte out. + assert_eq!( + port.radio.seen, + vec!["R 8100 4".to_string(), "W 8100 4".to_string(), "E".to_string()] + ); + assert_eq!(&port.radio.mem[APRS_LIVE as usize..APRS_LIVE as usize + 4], b"K0AA"); + } + + #[test] + fn leaving_program_mode_happens_even_when_the_caller_never_asks() { + let mut port = FakePort::new(FakeProgD710::new()); + { + let mut prog = ProgramMode::enter(&mut port).expect("enter"); + let _ = prog.read(0, 4); + // No `leave`. A caller that returns early, or panics, must not + // strand the radio in PROG MCP — it stops answering ID there and + // only a power cycle brings it back. + } + assert_eq!(port.radio.seen.last().map(String::as_str), Some("E")); + assert!(!port.radio.prog, "the radio is still in program mode"); + } + + #[test] + fn the_hole_is_reported_as_a_timeout_rather_than_hanging_the_read() { + let mut port = FakePort::new(FakeProgD710::new()); + let mut prog = ProgramMode::enter(&mut port).expect("enter"); + let err = prog.read(HOLE, 0).unwrap_err(); + assert!(err.contains("timed out"), "{err}"); + assert!(err.contains("7F00"), "the address is what makes it diagnosable: {err}"); + } + + #[test] + fn a_write_that_is_acknowledged_but_not_stored_is_only_visible_on_read_back() { + let mut port = FakePort::new(FakeProgD710::new()); + port.radio.stubborn = true; + let mut prog = ProgramMode::enter(&mut port).expect("enter"); + // ⚠ The write itself SUCCEEDS. 0x06 came back, which is all the + // protocol offers. On the BT-9000 a segment behaved exactly like this + // four times over, and only reading the bytes back showed it. + prog.write(APRS_LIVE, b"K0AA").expect("the radio acknowledges"); + let back = prog.read(APRS_LIVE, 4).expect("read back"); + assert_eq!(back, b"WW8L".to_vec(), "the old value is still there"); + } + + #[test] + fn the_program_mode_error_state_is_named_rather_than_reported_as_a_refusal() { + let err = check_status(0x0F).unwrap_err(); + assert!(err.contains("PROG ERR"), "{err}"); + assert!(check_status(0x06).is_ok()); + assert!(check_status(0x15).unwrap_err().contains("15")); + } + + #[test] + fn the_read_plan_is_mcps_own_ritual() { + let plan = read_plan(); + assert_eq!(plan.len(), 157, "155 blocks plus the two tails"); + assert!(!plan.iter().any(|(a, _)| *a == HOLE)); + assert_eq!(plan[0], (0x0000, 0)); + assert_eq!(plan[plan.len() - 2], (0xFEF0, 0x10)); + assert_eq!(plan[plan.len() - 1], (0xFF00, 0x90)); + let bytes: usize = + plan.iter().map(|(_, l)| if *l == 0 { 256 } else { *l as usize }).sum(); + assert_eq!(bytes, 39_840); + } +} 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..6c95ddf --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/mod.rs @@ -0,0 +1,451 @@ +//! 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**. A capability trait here +//! would put a "Program radio" button in front of an operator for a path no one +//! has proven. +//! +//! The tone and DCS tables are no longer unmeasured — see [`tone`], where the +//! radio's own refusal of an out-of-range index settled that fields 9-11 are +//! indices and fixed their lengths at 42 and 104. + +use serialport::SerialPort; +use std::time::{Duration, Instant}; + +use super::driver::{RadioDriver, RadioIdentity}; + +pub(crate) mod encode; +// ⚠ Reachable only from the measurement harness until a capability trait calls +// it — the same stance `memory.rs` and `write_memory` are under, and for the +// same reason: the transport is proven by the campaign that uses it before it +// is put in front of an operator. The APRS field table that will call it is +// still being measured (issue #113). +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) mod image; +pub(crate) mod memory; +pub(crate) mod program; +pub(crate) mod settings; +pub(crate) mod tone; + +/// Menu 528 on this radio sets it. 57600 is what Tim's is on and what the +/// capture ran at; the driver does not sweep, because a rate mismatch here is +/// 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 + } + + // Both halves, for the same reason the TH-D72 claims both: `MU` reads and + // writes the radio's menu over one ASCII command, with no clone session + // involved. Claimed only as of Phase 4 (#113) — writing every one of the 42 + // parameters was proven on Tim's radio first. + fn as_settings_reader(&self) -> Option<&dyn crate::radios::driver::SettingsReader> { + Some(self) + } + + fn as_settings_writer(&self) -> Option<&dyn crate::radios::driver::SettingsWriter> { + Some(self) + } + + // Live mode is a `CodeplugProgrammer`, not an `ImageProgrammer`: this radio + // is written record by record from the database, the way the AnyTone is, + // and there is no image to clone. See `program.rs` for the consequence — + // the write is not atomic, and this is the only driver here that isn't. + fn as_codeplug_programmer(&self) -> Option<&dyn crate::radios::driver::CodeplugProgrammer> { + Some(self) + } + + /// 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/program.rs b/src-tauri/src/radios/kenwood_tmd710/program.rs new file mode 100644 index 0000000..949bcc3 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/program.rs @@ -0,0 +1,412 @@ +//! Programming a codeplug into a TM-D710, one `ME` line at a time (#113). +//! +//! ## ⚠ This is the only non-atomic write in the app +//! +//! Every other radio here commits a whole image, or patches a file the radio +//! reads at its leisure. This one sends a memory, waits for the radio to take +//! it, and sends the next. Seventeen milliseconds each, a thousand of them. +//! +//! So a failure halfway leaves the radio **half-programmed** — some slots new, +//! some still the operator's — and no other driver in this repo can do that. +//! The consequence is not a caveat in a doc comment, it is a requirement on the +//! error path: when a write fails, the error says **which slot it stopped at and +//! how many landed**, because "programming failed" on a radio in that state is +//! not enough for anyone to act on. The backup transcript is what puts it back. +//! +//! ## The backup is a transcript +//! +//! There is no image to save. The pre-write backup is the radio's own +//! `ME`/`MN` lines for all 1000 slots, in the format `d710_restore` already +//! reads, so a bad program is undone by the harness that has been putting Tim's +//! radio back all campaign. It is taken **before the first byte goes out**, and +//! a failure to take it aborts the program rather than proceeding unprotected. +//! +//! ## What it does not write +//! +//! No zones, no scan lists, no contacts. The radio has ten memory groups and +//! program-scan limit pairs, and neither has been measured — the seed row says +//! `banks_supported: false` for that reason, so a codeplug's channel lists flow +//! into one flat pool of 1000 memories. See `channels-are-radio-agnostic`: this +//! is the "neither zones nor banks" flattening the resolver already does. + +use std::path::Path; + +use super::encode::{encode_channel, encode_name}; +use super::memory::{Memory, MemoryName, EMPTY_REPLY, MAX_NAME}; +use super::{ask, ask_settling, open_port, write_memory, write_name}; +use crate::commands::export::{exclusion_reason, expanded_name}; +use crate::radios::driver::{ + CodeplugPayload, CodeplugPreview, CodeplugProgrammer, ProgramReport, SkippedChannel, +}; + +/// What a program run will do, resolved without touching the port. +#[derive(Debug)] +pub(crate) struct Plan { + radio: String, + /// Memory and name per slot, packed contiguously from slot 0. + memories: Vec<(Memory, MemoryName)>, + skipped: Vec, + warnings: Vec, +} + +impl Plan { + fn preview(&self) -> CodeplugPreview { + CodeplugPreview { + radio: self.radio.clone(), + channels: self.memories.len(), + zones: 0, + scan_lists: 0, + contacts: 0, + zone_names: Vec::new(), + scan_list_names: Vec::new(), + skipped: self.skipped.clone(), + warnings: self.warnings.clone(), + } + } +} + +/// Resolve the payload into lines, with no hardware and no side effects. +/// +/// Per-channel problems become `skipped` entries carrying the reason, never +/// errors: a codeplug with one 159.8 Hz tone in it should still program the +/// other sixty-one channels, and the operator should be told which one did not +/// go. Only structural problems — the wrong model, more channels than the radio +/// has slots — stop the run. +pub(crate) fn plan(payload: &CodeplugPayload) -> Result { + let model = payload.model; + if model.model != "TM-D710" { + return Err(format!( + "live-mode programming is only wired up for the TM-D710 (codeplug targets {})", + model.display_name + )); + } + let max_slots = model.memory_channels.unwrap_or(1000) as usize; + + let mut memories = Vec::new(); + let mut skipped = Vec::new(); + for ec in payload.channels { + let name = expanded_name(ec, model); + // Band and mode fit first — the same verdict the export preview shows, + // so the two cannot disagree about which channels are in. + if let Some(reason) = exclusion_reason(&ec.channel, model) { + skipped.push(SkippedChannel { name, reason }); + continue; + } + let slot = memories.len(); + if slot >= max_slots { + return Err(format!( + "codeplug expands to more than the {max_slots} memories a TM-D710 has — trim the \ + channel lists" + )); + } + // Then whether this radio can express the channel at all. The encoder + // refuses rather than substituting a near value, and its message names + // the reason — a tone the radio does not have, an offset past 29.95 MHz. + match encode_channel(slot as u16, &ec.channel) { + Ok(m) => { + let mut n = encode_name(slot as u16, &ec.channel); + // `expanded_name` is what the rest of the app calls this channel + // (it disambiguates duplicates and appends talkgroup labels), so + // it wins over the raw column the encoder reached for. + n.text = super::encode::sanitize_name(&name); + memories.push((m, n)); + } + Err(reason) => skipped.push(SkippedChannel { name, reason }), + } + } + + let mut warnings = Vec::new(); + if memories.len() > 1 { + warnings.push(format!( + "The TM-D710 is programmed one memory at a time, so this write is not atomic: {} \ + memories go out individually and a failure partway leaves the radio holding some of \ + each. A full transcript of the radio is saved first and can be restored.", + memories.len() + )); + } + if name_is_truncated(&memories) { + warnings.push(format!( + "Some channel names are longer than the {MAX_NAME} characters this radio keeps and \ + have been shortened." + )); + } + + Ok(Plan { + radio: model.display_name.clone(), + memories, + skipped, + warnings, + }) +} + +fn name_is_truncated(memories: &[(Memory, MemoryName)]) -> bool { + memories.iter().any(|(_, n)| n.text.chars().count() == MAX_NAME) +} + +impl CodeplugProgrammer for super::KenwoodTmD710 { + fn preview(&self, payload: &CodeplugPayload) -> Result { + Ok(plan(payload)?.preview()) + } + + fn program( + &self, + port: &str, + payload: &CodeplugPayload, + backup_dir: &Path, + ) -> Result { + let plan = plan(payload)?; + let mut p = open_port(port)?; + + // Identity first. Every command below is a write, and sending `ME` lines + // at a radio that turns out to be a TM-V71 would program the wrong set. + let id = ask_settling(&mut *p, "ID")?; + if !id.contains("TM-D710") { + return Err(format!( + "the radio on {port} identifies as {id:?}, not a TM-D710. Nothing was written." + )); + } + + // ── The backup, before anything goes out ─────────────────────────── + std::fs::create_dir_all(backup_dir).map_err(|e| e.to_string())?; + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let backup_path = backup_dir.join(format!("kenwood_tmd710-{stamp}.txt")); + let mut transcript = String::new(); + let mut occupied: Vec = Vec::new(); + for slot in 0..1000u16 { + let line = ask(&mut *p, &format!("ME {slot:03}"))?; + if line == EMPTY_REPLY { + continue; + } + occupied.push(slot); + transcript.push_str(&line); + transcript.push('\n'); + transcript.push_str(&ask(&mut *p, &format!("MN {slot:03}"))?); + transcript.push('\n'); + } + std::fs::write(&backup_path, &transcript).map_err(|e| { + format!("could not save the pre-write backup, so nothing was written: {e}") + })?; + + // ── The write ────────────────────────────────────────────────────── + let mut channels_written = 0usize; + for (m, n) in &plan.memories { + // Both of these read the slot back and compare the whole line, which + // on a protocol with no checksum is the only evidence there is. + write_memory(&mut *p, m).map_err(|e| stopped_at(m.slot, channels_written, &backup_path, &e))?; + write_name(&mut *p, n).map_err(|e| stopped_at(m.slot, channels_written, &backup_path, &e))?; + channels_written += 1; + } + + // Slots the radio held that this codeplug does not fill. Cleared so a + // program is a full replace and not a merge with whatever was there. + let mut slots_cleared = 0usize; + for slot in occupied.iter().copied().filter(|s| (*s as usize) >= plan.memories.len()) { + ask(&mut *p, &format!("ME {slot:03},C")) + .map_err(|e| stopped_at(slot, channels_written, &backup_path, &e))?; + slots_cleared += 1; + } + + Ok(ProgramReport { + channels_written, + slots_cleared, + zones_written: 0, + zones_cleared: 0, + scan_lists_written: 0, + scan_lists_cleared: 0, + contacts_written: 0, + contacts_cleared: 0, + // No flash on a live-mode radio — there are no windows to name. + windows_written: Vec::new(), + backup_path: backup_path.to_string_lossy().into_owned(), + // Nothing to byte-verify against: the verification already happened, + // per memory, as the read-back inside every write. + expected_path: String::new(), + warnings: plan.warnings.clone(), + note: format!( + "Every memory was read back and matched. {} memories written, {slots_cleared} \ + cleared.", + channels_written + ), + }) + } +} + +/// The error a half-programmed radio needs. +/// +/// ⚠ This is the message that makes the non-atomic write survivable. "Writing +/// failed" tells an operator nothing when the radio now holds a mixture; the +/// slot it stopped at, the count that landed, and the path back are the three +/// things they need. +fn stopped_at(slot: u16, written: usize, backup: &Path, cause: &str) -> String { + format!( + "Programming stopped at memory {slot:03}. {written} memories were written before it, and \ + the radio is now holding a MIXTURE of the new codeplug and what it had. The radio's \ + original contents were saved to {} first and can be restored.\n\nCause: {cause}", + backup.display() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::export::ExpandedChannel; + use crate::models::{Channel, RadioModel}; + + fn model() -> RadioModel { + let mut m: RadioModel = serde_json::from_value(serde_json::json!({ + "id": 1, "manufacturer": "Kenwood", "model": "TM-D710", + "display_name": "Kenwood TM-D710", "analog_capable": true, + "dmr_capable": false, "dstar_capable": false, "ysf_capable": false, + "nxdn_capable": false, "p25_capable": false, "m17_capable": false, + "aprs_capable": true, "covers_hf": false, "covers_vhf": true, + "covers_uhf": true, "covers_220": true, "covers_900": false, + "freq_min": 144.0, "freq_max": 450.0, + "tx_bands": "[[144.0,148.0],[430.0,450.0]]", + "rx_bands": "[[118.0,523.995]]", "memory_channels": 1000, + "zones_supported": false, "scan_lists_supported": false, + "banks_supported": false, "max_name_length": 8, + "export_format": "chirp_csv", "connection_type": "Serial cable", + "non_channel_settings_schema": "[]", "driver_key": "kenwood_tmd710", + "programming_ui": "generic" + })) + .expect("model"); + m.memory_channels = Some(1000); + m + } + + fn ec(rx: f64, name: &str, tone: Option) -> ExpandedChannel { + ExpandedChannel { + channel: Channel { + rx_freq: rx, + name_short: Some(name.into()), + mode: Some("FM".into()), + tone_mode: tone.map(|_| "Tone".to_string()), + ctcss_uplink: tone, + ..Default::default() + }, + tg_label: None, + timeslot: None, + tg_number: None, + tg_call_type: None, + tg_inline: false, + } + } + + fn payload<'a>(model: &'a RadioModel, chans: &'a [ExpandedChannel]) -> CodeplugPayload<'a> { + CodeplugPayload { + model, + groups: &[], + channels: chans, + scan_lists: &[], + scan_list_overrides: &[], + } + } + + /// Channels pack from slot 0 in order, with the app's own name. + #[test] + fn channels_pack_contiguously_from_slot_zero() { + let m = model(); + let chans = [ec(146.520, "SIMPLEX", None), ec(446.000, "UHF", Some(100.0))]; + let plan = plan(&payload(&m, &chans)).unwrap(); + assert_eq!(plan.memories.len(), 2); + assert_eq!(plan.memories[0].0.slot, 0); + assert_eq!(plan.memories[1].0.slot, 1); + assert_eq!(plan.memories[0].1.text, "SIMPLEX"); + assert!(plan.skipped.is_empty()); + } + + /// ★ A channel this radio cannot express is SKIPPED with the encoder's own + /// reason — not substituted, and not fatal to the other sixty-one. 159.8 Hz + /// is a real tone on other radios in this library and not on this one. + #[test] + fn a_channel_the_radio_cannot_express_is_skipped_not_fatal() { + let m = model(); + let chans = [ + ec(146.520, "GOOD", None), + ec(146.940, "ODDTONE", Some(159.8)), + ec(147.000, "ALSOGOOD", None), + ]; + let plan = plan(&payload(&m, &chans)).unwrap(); + assert_eq!(plan.memories.len(), 2, "the other two still program"); + assert_eq!(plan.skipped.len(), 1); + assert_eq!(plan.skipped[0].name, "ODDTONE"); + assert!(plan.skipped[0].reason.contains("159.8"), "{:?}", plan.skipped[0]); + // And the survivors close up behind it, so no slot is left empty. + assert_eq!(plan.memories[1].0.slot, 1); + } + + /// Out of band is skipped by the same fit rule the export preview uses, so + /// the two screens cannot disagree about what is in the codeplug. + #[test] + fn an_out_of_coverage_channel_is_skipped_by_the_shared_fit_rule() { + let m = model(); + let chans = [ec(146.520, "IN", None), ec(800.0, "OUT", None)]; + let plan = plan(&payload(&m, &chans)).unwrap(); + assert_eq!(plan.memories.len(), 1); + assert_eq!(plan.skipped[0].name, "OUT"); + } + + /// A 220 MHz repeater is RECEIVE-ONLY on this radio, not excluded — it must + /// still get a memory. This is the case that separates the TM-D710 from the + /// TH-D72 beside it in the seed. + #[test] + fn a_220_repeater_still_gets_a_memory() { + let m = model(); + let chans = [ec(224.840, "220RPT", None)]; + let plan = plan(&payload(&m, &chans)).unwrap(); + assert_eq!(plan.memories.len(), 1, "{:?}", plan.skipped[0]); + assert_eq!(plan.memories[0].0.rx_hz, 224_840_000); + } + + /// The non-atomic warning is not decoration: it is the one thing about this + /// radio an operator cannot infer from any other radio's behaviour. + #[test] + fn the_preview_warns_that_the_write_is_not_atomic() { + let m = model(); + let chans = [ec(146.520, "A", None), ec(147.000, "B", None)]; + let preview = plan(&payload(&m, &chans)).unwrap().preview(); + assert!( + preview.warnings.iter().any(|w| w.contains("not atomic")), + "{:?}", + preview.warnings + ); + assert_eq!(preview.zones, 0, "the radio's grouping has not been measured"); + } + + /// Over capacity is structural and stops the run, rather than silently + /// dropping the tail. + #[test] + fn more_channels_than_the_radio_holds_is_an_error() { + let mut m = model(); + m.memory_channels = Some(2); + let chans = [ + ec(146.520, "A", None), + ec(147.000, "B", None), + ec(147.100, "C", None), + ]; + let err = plan(&payload(&m, &chans)).unwrap_err(); + assert!(err.contains("more than the 2 memories"), "{err}"); + } + + /// Programming a codeplug aimed at another radio must not reach the port. + #[test] + fn a_codeplug_for_another_radio_is_refused() { + let mut m = model(); + m.model = "TM-V71".into(); + m.display_name = "Kenwood TM-V71".into(); + let err = plan(&payload(&m, &[])).unwrap_err(); + assert!(err.contains("only wired up for the TM-D710"), "{err}"); + } + + /// ★ The message a half-programmed radio needs. Asserted because it is the + /// only mitigation this modality has, and a generic "write failed" would + /// leave an operator with no idea what state their radio is in. + #[test] + fn the_failure_message_names_the_slot_the_count_and_the_way_back() { + let msg = stopped_at(42, 41, Path::new("/tmp/backup.txt"), "no reply to \"ME 042\""); + assert!(msg.contains("042"), "{msg}"); + assert!(msg.contains("41 memories were written"), "{msg}"); + assert!(msg.contains("MIXTURE"), "{msg}"); + assert!(msg.contains("/tmp/backup.txt"), "{msg}"); + } +} diff --git a/src-tauri/src/radios/kenwood_tmd710/settings.rs b/src-tauri/src/radios/kenwood_tmd710/settings.rs new file mode 100644 index 0000000..30ffbf2 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/settings.rs @@ -0,0 +1,443 @@ +//! The TM-D710's menu settings, read and written as the `MU` command (#113). +//! +//! One ASCII line carries all **42** menu parameters, and setting any of them +//! means sending all 42 back. There is no image and no card file on this radio, +//! so this is the only place its settings live. +//! +//! ## Every range here was measured on the radio +//! +//! `d710_menu_bounds` swept each parameter and read the line back. The TM-D710 +//! answers an out-of-range menu value with an explicit `?`, so the first refused +//! value is the size of the enum behind that menu — all 42 in 131 seconds, with +//! the line restored exactly afterwards. +//! +//! That is stronger evidence than the sheet the last two Kenwoods were built +//! from, and it caught **five errors** in the published table. The two that +//! would have shipped wrong values: +//! +//! - **Beep volume and Voice volume are 7 levels, not 8.** The manual says "a +//! level from 1 to 7"; the radio takes `0..=6` and refuses `7`. So the display +//! is the stored value **plus one**, and a driver mapping them directly would +//! have been off by one across the whole range. +//! - **The panel PF keys accept a non-contiguous set** — `0x00`–`0x0A` and then +//! `0x16`. A contiguous `0..=16` enum, which is what the published table +//! implies, would offer six values the radio refuses and still miss `0x16`. +//! +//! ## What is deliberately missing +//! +//! Seven of the 42 are **not** exposed: the six PF-key assignments and p25, +//! which no source names. Their sizes are measured and their meanings are not, +//! and an enum whose labels are guesses is the failure mode that writes a wrong +//! value to a real radio. `scratchpad/kenwood_tmd710/MEASURED.md` grades every +//! row and says which are still owed a look at the radio's own screen. +//! +//! ## Grading +//! +//! Sizes are measured. **Orders are mostly inferred** — from the manual and from +//! LA3QMA's table, which agree with each other and now with the radio on 37 of +//! 42 counts. A printed option list is display order, not necessarily the stored +//! index; that distinction cost the TH-D75 a shipped wrong meaning. Two rows are +//! better than inferred: p1 (key beep) and p26 (brightness) were each pinned by +//! a single-change diff on the radio in session 120. + +use serde_json::{json, Map, Value}; +use std::path::Path; + +use super::memory::Menu; +use super::{ask_settling, open_port, write_menu}; +use crate::radios::driver::{SettingsCapture, SettingsReader, SettingsWriteReport, SettingsWriter}; + +/// One menu parameter, as the generated table states it. +pub(crate) struct TF { + pub key: &'static str, + pub label: &'static str, + /// 0-based index into the 42 `MU` parameters. + pub mu: usize, + /// The radio's own menu number, for the form's label. Documentation only — + /// a wrong one mislabels a control, it does not write a wrong value. + pub menu: Option<&'static str>, + pub kind: TK, +} + +impl TF { + /// How this field should be named to an operator — the form's label, with + /// the radio's own menu number when there is one, so a rejected value points + /// at the menu to go and look at. This is also the only non-test reader of + /// `label` and `menu`; a `never used` warning on either would mean the + /// generated table had drifted out of use. + fn display(&self) -> String { + match self.menu { + Some(m) => format!("{} (Menu {})", self.label, m.trim_end_matches('?')), + None => self.label.to_string(), + } + } +} + +pub(crate) enum TK { + Bool, + Enum { labels: &'static [(u8, &'static str)] }, + Uint { min: u8, max: u8 }, +} + +include!("tmd710_settings_table.rs"); + +/// Decode a menu line into the profile form's shape. +fn decode(menu: &Menu) -> Value { + let mut out = Map::new(); + for f in TMD710_SETTINGS_FIELDS { + let Ok(text) = menu.field(f.mu + 1) else { continue }; + let Ok(v) = text.parse::() else { continue }; + let value = match &f.kind { + TK::Bool => json!(v != 0), + TK::Uint { .. } => json!(v), + TK::Enum { labels } => match labels.iter().find(|(raw, _)| *raw == v) { + Some((_, label)) => json!(label), + // Reported as the number rather than dropped or clamped: an + // honest "your radio holds something this table cannot name", + // which is a measurement gap and not a corrupt radio. + None => json!(v), + }, + }; + out.insert(f.key.to_string(), value); + } + Value::Object(out) +} + +/// One form value as the number the radio stores. +fn encode_one(f: &TF, v: &Value) -> Result { + Ok(match &f.kind { + TK::Bool => match v.as_bool() { + Some(b) => u8::from(b), + None => return Err(format!("{} expects true or false, got {v}", f.display())), + }, + TK::Uint { min, max } => { + let n = v + .as_u64() + .ok_or_else(|| format!("{} expects a number, got {v}", f.display()))?; + if n < u64::from(*min) || n > u64::from(*max) { + return Err(format!("{} is {n}, outside the radio's {min}..={max}", f.display())); + } + n as u8 + } + TK::Enum { labels } => match v { + // ⚠ A raw NUMBER is valid, and refusing it bricks the driver. + // `decode` hands back the number for a stored value this table + // cannot label; that number is saved into the profile, and if only + // a label were accepted every later settings write and every + // program run carrying settings would fail with "has no option 64". + // The TH-D72 shipped exactly that bug and it was found in review. + Value::Number(n) => n + .as_u64() + .filter(|n| *n <= u64::from(u8::MAX)) + .ok_or_else(|| format!("{} cannot store {v}", f.display()))? as u8, + _ => { + let s = v + .as_str() + .ok_or_else(|| format!("{} expects one of its options, got {v}", f.display()))?; + labels + .iter() + .find(|(_, label)| *label == s) + .map(|(raw, _)| *raw) + // A string that is not an option is a stale label, not a + // measurement gap, so it stays an error. + .ok_or_else(|| format!("{} has no option {s:?}", f.display()))? + } + }, + }) +} + +/// Patch the profile's fields over the line the radio currently holds. +/// +/// A **patch, never a build from defaults.** `MU` sets all 42 parameters at +/// once, so any parameter the profile does not carry — including all seven this +/// table deliberately does not expose — has to go back exactly as it came. +/// Building the line from scratch would silently rewrite the operator's PF key +/// assignments every time they changed the beep volume. +fn patch(base: &Menu, settings: &Value) -> Result<(Menu, usize), String> { + let mut out = base.clone(); + let mut written = 0usize; + for f in TMD710_SETTINGS_FIELDS { + let Some(v) = settings.get(f.key) else { continue }; + if v.is_null() { + continue; + } + let encoded = encode_one(f, v)?; + let text = encoded.to_string(); + if base.field(f.mu + 1)? != format!("{text:0>width$}", width = base.field(f.mu + 1)?.len()) + { + written += 1; + } + out = out.with_field(f.mu + 1, &text)?; + } + Ok((out, written)) +} + +impl SettingsReader for super::KenwoodTmD710 { + fn read_settings(&self, port: &str, _schema_json: &str) -> Result { + let mut p = open_port(port)?; + let line = ask_settling(&mut *p, "MU")?; + let menu = Menu::parse(&line)?; + Ok(SettingsCapture { + settings: decode(&menu), + // The backup for a live-mode radio is a TRANSCRIPT. This one is the + // menu line itself, which is exactly what a settings write can + // clobber — `d710_restore` puts it back. + backup: line.into_bytes(), + backup_ext: "txt", + }) + } +} + +impl SettingsWriter for super::KenwoodTmD710 { + /// Read the current line, back it up, patch the profile's fields over it, + /// write, and read back to verify — one session, no clone mode. + /// + /// ⚠ **Not yet run on a real radio.** Reading `MU` is proven; writing one + /// field at a time is proven by `d710_set_menu` and by the 42-parameter + /// sweep, which wrote and restored every parameter. This path — a profile's + /// worth of fields patched in one go — has not been. In this repo a working + /// read path has twice hidden a dead write path, so it is stated rather + /// than assumed. + fn write_settings( + &self, + port: &str, + settings: &Value, + _schema_json: &str, + backup_dir: &Path, + ) -> Result { + let mut p = open_port(port)?; + let before = ask_settling(&mut *p, "MU")?; + let base = Menu::parse(&before)?; + + std::fs::create_dir_all(backup_dir).map_err(|e| e.to_string())?; + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let backup_path = backup_dir.join(format!("kenwood_tmd710-menu-{stamp}.txt")); + std::fs::write(&backup_path, &before).map_err(|e| e.to_string())?; + + let (wanted, fields_written) = patch(&base, settings)?; + let failed = write_menu(&mut *p, &wanted)?; + + Ok(SettingsWriteReport { + fields_written, + // `write_menu` re-reads the line and diffs it, so this is a real + // read-back and not the same buffer compared with itself — the + // mistake found in the TH-D72's review. + verified: Some(failed.is_empty()), + note: (!failed.is_empty()).then(|| { + let names: Vec = failed + .iter() + .map(|(p, mine, theirs)| format!("p{p}: sent {mine}, radio kept {theirs}")) + .collect(); + format!( + "{} menu parameter(s) did not take: {}", + failed.len(), + names.join("; ") + ) + }), + backup_path: backup_path.to_string_lossy().into_owned(), + expected_path: None, + windows_written: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::radios::kenwood_tmd710::memory::MENU_FIELDS; + + /// The real line off Tim's radio, as first read in session 120. + 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"; + + /// ★ Every emitted option list must be exactly as long as the range the + /// radio accepted. The sizes are measured (`d710_menu_bounds`), so a list + /// that has grown or shrunk is offering an operator a value the radio + /// refuses — or hiding one it has. The generator asserts this too; this is + /// the half that runs in CI. + #[test] + fn every_option_list_matches_the_range_the_radio_accepted() { + // p (1-based) -> values accepted, measured 2026-09-01. + const MEASURED: [(usize, usize); 42] = [ + (1, 2), (2, 7), (3, 2), (4, 3), (5, 2), (6, 7), (7, 5), (8, 2), (9, 61), (10, 2), + (11, 2), (12, 2), (13, 4), (14, 6), (15, 2), (16, 3), (17, 2), (18, 2), (19, 2), + (20, 2), (21, 7), (22, 2), (23, 2), (24, 2), (25, 3), (26, 9), (27, 2), (28, 2), + (29, 12), (30, 12), (31, 32), (32, 32), (33, 32), (34, 32), (35, 2), (36, 3), + (37, 6), (38, 4), (39, 2), (40, 6), (41, 2), (42, 2), + ]; + for f in TMD710_SETTINGS_FIELDS { + let p = f.mu + 1; + let (_, size) = MEASURED + .iter() + .find(|(mp, _)| *mp == p) + .unwrap_or_else(|| panic!("p{p} is not in the measured set")); + let emitted = match &f.kind { + TK::Bool => 2, + TK::Enum { labels } => labels.len(), + TK::Uint { min, max } => (max - min) as usize + 1, + }; + assert_eq!( + emitted, *size, + "{}: emits {emitted} options, the radio accepted {size}", + f.key + ); + } + } + + /// ★ The pairing the skill requires: **one sheet, both halves.** A table + /// entry with no form field is a setting nobody can reach; a form field with + /// no table entry silently does nothing when saved. Both are generated from + /// `MEASURED.md` by one script, and this is what stops them drifting after. + /// + /// It also checks the labels, which is the only thing that reads `TF::label` + /// and `TF::menu` — the schema is what the form renders, so a table label + /// that disagrees with it means the two were regenerated from different + /// sheets. + #[test] + fn the_table_and_the_profile_schema_describe_the_same_fields() { + let schema: Vec = + serde_json::from_str(crate::seed::TMD710_SETTINGS_SCHEMA).expect("schema parses"); + assert_eq!(schema.len(), TMD710_SETTINGS_FIELDS.len()); + + for f in TMD710_SETTINGS_FIELDS { + let entry = schema + .iter() + .find(|e| e["key"] == f.key) + .unwrap_or_else(|| panic!("{} has no form field", f.key)); + + assert_eq!(entry["label"], serde_json::json!(f.display()), "{}", f.key); + + match &f.kind { + TK::Bool => assert_eq!(entry["type"], "boolean", "{}", f.key), + TK::Uint { min, max } => { + assert_eq!(entry["type"], "integer", "{}", f.key); + assert_eq!(entry["min"], serde_json::json!(min), "{}", f.key); + assert_eq!(entry["max"], serde_json::json!(max), "{}", f.key); + } + TK::Enum { labels } => { + assert_eq!(entry["type"], "enum", "{}", f.key); + let opts: Vec<&str> = entry["options"] + .as_array() + .expect("options") + .iter() + .map(|o| o.as_str().expect("option string")) + .collect(); + let mine: Vec<&str> = labels.iter().map(|(_, l)| *l).collect(); + assert_eq!(opts, mine, "{} options disagree", f.key); + } + } + } + + for e in &schema { + let key = e["key"].as_str().expect("key"); + assert!( + TMD710_SETTINGS_FIELDS.iter().any(|f| f.key == key), + "the form offers {key:?}, which no table entry writes — saving it \ + would do nothing" + ); + } + } + + /// The seven that must stay out. Their sizes are known and their meanings + /// are not, and this is the assertion that stops someone filling them in + /// from a published table — the same table that was wrong about their + /// ranges in the first place. + #[test] + fn the_undetermined_parameters_are_not_exposed() { + for p in [25, 29, 30, 31, 32, 33, 34] { + assert!( + !TMD710_SETTINGS_FIELDS.iter().any(|f| f.mu + 1 == p), + "p{p}'s encoding has not been measured and must not be offered" + ); + } + assert_eq!(TMD710_SETTINGS_FIELDS.len(), 35); + } + + /// Keys are what a saved profile stores, so a duplicate would make one field + /// silently overwrite another on load. + #[test] + fn keys_and_indices_are_unique_and_inside_the_line() { + let mut keys: Vec<&str> = TMD710_SETTINGS_FIELDS.iter().map(|f| f.key).collect(); + let n = keys.len(); + keys.sort_unstable(); + keys.dedup(); + assert_eq!(keys.len(), n, "duplicate settings key"); + + let mut idx: Vec = TMD710_SETTINGS_FIELDS.iter().map(|f| f.mu).collect(); + idx.sort_unstable(); + idx.dedup(); + assert_eq!(idx.len(), n, "two fields claim one MU parameter"); + assert!( + TMD710_SETTINGS_FIELDS.iter().all(|f| f.mu < MENU_FIELDS), + "a field points past the {MENU_FIELDS} parameters an MU line has" + ); + } + + /// A real line decodes, and the two parameters that were pinned on the radio + /// itself decode to what the radio was showing. + #[test] + fn the_real_menu_line_decodes() { + let menu = Menu::parse(REAL_MU).unwrap(); + let v = decode(&menu); + assert_eq!(v["key-beep"], json!(false), "p1 was 0 and KEY BEEP was off"); + assert_eq!(v["display-brightness"], json!("Level 8"), "p26 was 8"); + // p2 = 4 and the display is stored + 1 — the off-by-one the sweep found. + assert_eq!(v["beep-volume"], json!("5")); + } + + /// ★ A settings write is a PATCH. The seven unexposed parameters — the PF + /// keys among them — must come back byte-identical, because `MU` writes all + /// 42 and an operator's key assignments are not this form's to touch. + #[test] + fn patching_leaves_every_unexposed_parameter_exactly_as_found() { + let base = Menu::parse(REAL_MU).unwrap(); + let (patched, written) = patch(&base, &json!({ "key-beep": true })).unwrap(); + assert_eq!(written, 1); + for p in [25, 29, 30, 31, 32, 33, 34] { + assert_eq!( + patched.field(p).unwrap(), + base.field(p).unwrap(), + "p{p} was rewritten by a patch that only set the key beep" + ); + } + // And the one field asked for did move, with the radio's own width. + assert_eq!(patched.field(1).unwrap(), "1"); + assert_eq!(base.diff(&patched).len(), 1); + } + + /// Widths are part of the line: p9 is two characters on this radio, so a + /// patched `0` has to go back as `00` or every field after it shifts. + #[test] + fn a_patched_value_keeps_the_radios_own_width() { + let base = Menu::parse(REAL_MU).unwrap(); + assert_eq!(base.field(9).unwrap(), "10"); + let (patched, _) = patch(&base, &json!({ "playback-repeat-interval": 0 })).unwrap(); + assert_eq!(patched.field(9).unwrap(), "00"); + assert_eq!(Menu::parse(&patched.to_line()).unwrap().to_line(), patched.to_line()); + } + + /// The numeric fallback. An unlabelled value decodes to a number, is saved + /// into the profile, and must survive the round trip — otherwise every later + /// write fails and the radio becomes unprogrammable from the app. + #[test] + fn an_unlabelled_value_round_trips_as_a_number() { + let f = TMD710_SETTINGS_FIELDS + .iter() + .find(|f| matches!(f.kind, TK::Enum { .. })) + .unwrap(); + assert_eq!(encode_one(f, &json!(64)).unwrap(), 64); + assert!(encode_one(f, &json!("not an option")).is_err()); + } + + /// Out-of-range numbers are refused rather than clamped — the radio would + /// answer `?` and the whole line would be rejected, so catching it here + /// names the field instead of failing the write. + #[test] + fn a_value_outside_the_measured_range_is_refused() { + let interval = TMD710_SETTINGS_FIELDS + .iter() + .find(|f| f.key == "playback-repeat-interval") + .unwrap(); + let err = encode_one(interval, &json!(61)).unwrap_err(); + assert!(err.contains("0..=60") && err.contains("Menu 008"), "{err}"); + } +} diff --git a/src-tauri/src/radios/kenwood_tmd710/tmd710_settings_table.rs b/src-tauri/src/radios/kenwood_tmd710/tmd710_settings_table.rs new file mode 100644 index 0000000..8505546 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/tmd710_settings_table.rs @@ -0,0 +1,64 @@ +// GENERATED by scratchpad/kenwood_tmd710/gen_tmd710_settings.py from +// scratchpad/kenwood_tmd710/MEASURED.md. Do not hand-edit; regenerate. +// +// `mu` is a 0-BASED INDEX into the 42 comma-separated parameters of an +// `MU` line, not a byte offset -- the TM-D710 is a live-mode radio and +// has no image to hold settings in. +// +// ⚠ Every option list here has been checked against a range MEASURED on +// the radio: `d710_menu_bounds` swept each parameter and the first value +// the radio answered `?` to is the size of the enum. The generator +// refuses to emit a list whose length disagrees. That caught five errors +// in the published table, including two volume fields that are 7 levels +// and not 8 -- writing an 8th is refused, and display = stored + 1. +// +// 7 of 42 parameters are deliberately NOT emitted: +// p25: **unknown** +// p29: Panel PF1 key +// p30: Panel PF2 key +// p31: Mic PF1 key +// p32: Mic PF2 key +// p33: Mic PF3 key +// p34: Mic PF4 key +// Their encoding is not determined, and guessing one is the failure +// mode that writes a wrong value to a real radio. +// +// A menu number ending in `?` is documentation only -- a wrong one +// mislabels a form field, it does not write a wrong value. +pub(crate) const TMD710_SETTINGS_FIELDS: &[TF] = &[ + TF { key: "key-beep", label: "Key beep", mu: 0, menu: Some("000"), kind: TK::Bool }, // MU p1; size MEASURED (2); measured (s120: turning it on moved p1 0→1) + TF { key: "beep-volume", label: "Beep volume", mu: 1, menu: Some("001"), kind: TK::Enum { labels: &[(0, "1"), (1, "2"), (2, "3"), (3, "4"), (4, "5"), (5, "6"), (6, "7")] } }, // MU p2; size MEASURED (7); inferred (manual states levels 1-7; size measured) + TF { key: "external-speaker-mode", label: "External speaker mode", mu: 2, menu: Some("002"), kind: TK::Enum { labels: &[(0, "Mode 1"), (1, "Mode 2")] } }, // MU p3; size MEASURED (2); inferred + TF { key: "announce", label: "Announce", mu: 3, menu: Some("003"), kind: TK::Enum { labels: &[(0, "Off"), (1, "Auto"), (2, "Manual")] } }, // MU p4; size MEASURED (3); inferred + TF { key: "language", label: "Language", mu: 4, menu: Some("004"), kind: TK::Enum { labels: &[(0, "English"), (1, "Japanese")] } }, // MU p5; size MEASURED (2); inferred + TF { key: "voice-volume", label: "Voice volume", mu: 5, menu: Some("005"), kind: TK::Enum { labels: &[(0, "1"), (1, "2"), (2, "3"), (3, "4"), (4, "5"), (5, "6"), (6, "7")] } }, // MU p6; size MEASURED (7); inferred + TF { key: "announce-speed", label: "Announce speed", mu: 6, menu: Some("006"), kind: TK::Uint { min: 0, max: 4 } }, // MU p7; size MEASURED (5); inferred + TF { key: "playback-repeat", label: "Playback repeat", mu: 7, menu: Some("007"), kind: TK::Bool }, // MU p8; size MEASURED (2); inferred + TF { key: "playback-repeat-interval", label: "Playback repeat interval", mu: 8, menu: Some("008"), kind: TK::Uint { min: 0, max: 60 } }, // MU p9; size MEASURED (61); inferred (value is the number) + TF { key: "continuous-recording", label: "Continuous recording", mu: 9, menu: Some("009"), kind: TK::Bool }, // MU p10; size MEASURED (2); inferred + TF { key: "vhf-aip", label: "VHF AIP", mu: 10, menu: Some("100"), kind: TK::Bool }, // MU p11; size MEASURED (2); inferred + TF { key: "uhf-aip", label: "UHF AIP", mu: 11, menu: Some("101"), kind: TK::Bool }, // MU p12; size MEASURED (2); inferred + TF { key: "squelch-hang-up-time", label: "Squelch hang-up time", mu: 12, menu: Some("106"), kind: TK::Enum { labels: &[(0, "Off"), (1, "125 ms"), (2, "250 ms"), (3, "500 ms")] } }, // MU p13; size MEASURED (4); inferred (manual, Menu 106, lists exactly these four) + TF { key: "mute-hang-up-time", label: "Mute hang-up time", mu: 13, menu: Some("107"), kind: TK::Enum { labels: &[(0, "Off"), (1, "125 ms"), (2, "250 ms"), (3, "500 ms"), (4, "750 ms"), (5, "1000 ms")] } }, // MU p14; size MEASURED (6); inferred + TF { key: "beat-shift", label: "Beat shift", mu: 14, menu: Some("108"), kind: TK::Bool }, // MU p15; size MEASURED (2); inferred + TF { key: "time-out-timer", label: "Time-out timer", mu: 15, menu: Some("109"), kind: TK::Enum { labels: &[(0, "3 min"), (1, "5 min"), (2, "10 min")] } }, // MU p16; size MEASURED (3); inferred (as-found value 2 = 10 min) + TF { key: "memory-recall-method", label: "Memory recall method", mu: 16, menu: Some("201"), kind: TK::Enum { labels: &[(0, "All bands"), (1, "Current band")] } }, // MU p17; size MEASURED (2); inferred + TF { key: "echolink-speed", label: "EchoLink speed", mu: 17, menu: Some("205"), kind: TK::Enum { labels: &[(0, "Fast"), (1, "Slow")] } }, // MU p18; size MEASURED (2); inferred + TF { key: "dtmf-hold", label: "DTMF hold", mu: 18, menu: Some("300"), kind: TK::Bool }, // MU p19; size MEASURED (2); inferred + TF { key: "dtmf-speed", label: "DTMF speed", mu: 19, menu: Some("302"), kind: TK::Enum { labels: &[(0, "Fast"), (1, "Slow")] } }, // MU p20; size MEASURED (2); inferred + TF { key: "dtmf-pause", label: "DTMF pause", mu: 20, menu: Some("303"), kind: TK::Enum { labels: &[(0, "100 ms"), (1, "250 ms"), (2, "500 ms"), (3, "750 ms"), (4, "1000 ms"), (5, "1500 ms"), (6, "2000 ms")] } }, // MU p21; size MEASURED (7); inferred — ★ the manual names Menu 303's seven values AND its 500 ms default, and the as-found value is 2, which is 500 ms under this order + TF { key: "dtmf-key-lock", label: "DTMF key lock", mu: 21, menu: Some("304"), kind: TK::Bool }, // MU p22; size MEASURED (2); inferred + TF { key: "automatic-repeater-offset", label: "Automatic repeater offset", mu: 22, menu: Some("401"), kind: TK::Bool }, // MU p23; size MEASURED (2); inferred + TF { key: "1750-hz-tx-hold", label: "1750 Hz TX hold", mu: 23, menu: Some("402"), kind: TK::Bool }, // MU p24; size MEASURED (2); inferred + TF { key: "display-brightness", label: "Display brightness", mu: 25, menu: Some("501"), kind: TK::Enum { labels: &[(0, "Off"), (1, "Level 1"), (2, "Level 2"), (3, "Level 3"), (4, "Level 4"), (5, "Level 5"), (6, "Level 6"), (7, "Level 7"), (8, "Level 8")] } }, // MU p26; size MEASURED (9); **measured** (s120 set LEVEL 3 and p26 alone moved 8→3, so index = level; size rules out Menu 504 CONTRAST, which has 16) + TF { key: "automatic-brightness", label: "Automatic brightness", mu: 26, menu: Some("502"), kind: TK::Bool }, // MU p27; size MEASURED (2); inferred + TF { key: "backlight-colour", label: "Backlight colour", mu: 27, menu: Some("503"), kind: TK::Enum { labels: &[(0, "Amber"), (1, "Green")] } }, // MU p28; size MEASURED (2); inferred + TF { key: "microphone-key-lock", label: "Microphone key lock", mu: 34, menu: Some("513?"), kind: TK::Bool }, // MU p35; size MEASURED (2); inferred + TF { key: "scan-resume-method", label: "Scan resume method", mu: 35, menu: Some("907?"), kind: TK::Enum { labels: &[(0, "Time-operated"), (1, "Carrier-operated"), (2, "Seek")] } }, // MU p36; size MEASURED (3); inferred (manual names the three modes, default Time-operated, and the as-found value is 0) + TF { key: "auto-power-off", label: "Auto power off", mu: 36, menu: Some("917?"), kind: TK::Enum { labels: &[(0, "Off"), (1, "30 min"), (2, "60 min"), (3, "90 min"), (4, "120 min"), (5, "180 min")] } }, // MU p37; size MEASURED (6); inferred + TF { key: "external-data-band", label: "External data band", mu: 37, menu: Some("918?"), kind: TK::Enum { labels: &[(0, "Band A"), (1, "Band B"), (2, "TX A-RX B"), (3, "TX B-RX A")] } }, // MU p38; size MEASURED (4); inferred + TF { key: "external-data-speed", label: "External data speed", mu: 38, menu: Some("919?"), kind: TK::Enum { labels: &[(0, "1200 bps"), (1, "9600 bps")] } }, // MU p39; size MEASURED (2); inferred (manual, "set the data speed to 1200 or 9600 bps") + TF { key: "sqc-output-source", label: "SQC output source", mu: 39, menu: Some("921?"), kind: TK::Enum { labels: &[(0, "Off"), (1, "Busy"), (2, "SQL"), (3, "TX"), (4, "Busy or TX"), (5, "SQL or TX")] } }, // MU p40; size MEASURED (6); inferred + TF { key: "auto-pm-store", label: "Auto PM store", mu: 40, menu: Some("922?"), kind: TK::Bool }, // MU p41; size MEASURED (2); inferred + TF { key: "display-partition-bar", label: "Display partition bar", mu: 41, menu: Some("928"), kind: TK::Bool }, // MU p42; size MEASURED (2); inferred (manual names Menu 928) +]; diff --git a/src-tauri/src/radios/kenwood_tmd710/tone.rs b/src-tauri/src/radios/kenwood_tmd710/tone.rs new file mode 100644 index 0000000..6801283 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/tone.rs @@ -0,0 +1,217 @@ +//! The TM-D710's CTCSS and DCS tables, and the conversions in and out of them. +//! +//! Fields 9, 10 and 11 of an `ME` line (`tone_idx`, `ctcss_idx`, `dcs_idx`) are +//! **indices**, not values. That was the open question at the end of the Phase 1 +//! campaign — the field is three characters wide and `023` is both a plausible +//! index and a real DCS code — and the radio settled it without anyone reading +//! its screen. +//! +//! ## How the index question was answered, over the cable +//! +//! This radio **validates a write and refuses it whole**: a rejected `ME` line +//! leaves the slot exactly as it was, which makes acceptance a measurement. +//! Written to a slot that was empty, and read back (session 126): +//! +//! | `dcs_idx` written | valid DCS code? | valid index? | radio | +//! |---|---|---|---| +//! | `754` | yes — the last one | no, 754 > 103 | **refused**, slot stayed `N` | +//! | `103` | no | yes | **accepted** | +//! | `104` | no | no | **refused**, slot kept `103` | +//! +//! A field that takes `103` and refuses `754` is an index. The same pair run on +//! fields 9 and 10 puts both at `0..=41`: `41` accepted, `42` refused, on each +//! independently. So the counts are exactly **42 tones and 104 DCS codes**, and +//! the indices are **0-based** — `00` is accepted, which a 1-based field could +//! not do. +//! +//! ## Where the tables themselves come from +//! +//! The lists below are the manual's own (TM-D710GA/GE Instruction Manual +//! V1.01, SIGNALING-1 and SIGNALING-2). ⚠ That manual covers the **G**; Tim's +//! radio is the non-G TM-D710A. The two share this table — the cable-measured +//! lengths above match it exactly, 42 and 104, which is the check that matters — +//! but see the `research-before-reverse-engineering` note: a manual for the +//! wrong model has bitten this project before. +//! +//! The manual prints the CTCSS list with keypad reference numbers `01`~`42`, +//! which is **display numbering, not the stored index** (the TH-D75 taught this +//! the hard way). The 0-based offset is not taken from the manual: session 120 +//! joined the radio's own 38 memories to Tim's channel library on frequency +//! **and** callsign and found field 9 predicting the library's TX tone **33 +//! right, 0 wrong** under "0-based index into this list". That pins the offset +//! and the interior of the table independently of anything printed. +//! +//! ## ⚠ Nothing here is called yet +//! +//! Every item below carries `allow(dead_code)` because the Phase 2 encoder — +//! the thing that builds an `ME` line from a library channel — does not exist +//! yet. The attribute goes away with it. It is spelled out because a +//! `never used` warning on an encoder is normally a **bug report**: on the +//! D890UV a whole settings write path sat unreferenced behind a working read +//! path and nobody noticed. See `read-path-working-hides-a-dead-write-path`. +//! +//! ## What is still unconfirmed +//! +//! Not one of the radio's 38 memories uses DCS, so **the DCS list has no +//! cross-check** — its order is the manual's reading order, and the fact that it +//! is byte-identical to the 104-code list the ID-52 driver already ships. The +//! decisive screen reading is memory 503, which holds `dcs_idx = 023`: as an +//! index that is the 24th code, **D134**. Any other displayed value means this +//! table is not the radio's. + +/// CTCSS tones in tenths of a hertz, in the order the radio indexes them. +/// +/// Index 0 is 67.0 Hz and index 41 is 254.1 Hz — the classic 42-tone list, with +/// none of the extra tones the ID-52's 50-entry table carries. +#[cfg_attr(not(test), allow(dead_code))] // see the module doc +pub(crate) const TONES_DHZ: [u16; 42] = [ + 670, 693, 719, 744, 770, 797, 825, 854, 885, 915, 948, 974, 1000, 1035, 1072, 1109, 1148, 1188, + 1230, 1273, 1318, 1365, 1413, 1462, 1514, 1567, 1622, 1679, 1738, 1799, 1862, 1928, 2035, 2065, + 2107, 2181, 2257, 2291, 2336, 2418, 2503, 2541, +]; + +/// DCS codes as the radio indexes them, written in octal the way the front +/// panel shows them — which is also how the channel database stores them. +/// +/// Index 0 is `023` and index 103 is `754`. +#[cfg_attr(not(test), allow(dead_code))] // see the module doc +pub(crate) const DCS_CODES: [u16; 104] = [ + 23, 25, 26, 31, 32, 36, 43, 47, 51, 53, 54, 65, 71, 72, 73, 74, 114, 115, 116, 122, 125, 131, + 132, 134, 143, 145, 152, 155, 156, 162, 165, 172, 174, 205, 212, 223, 225, 226, 243, 244, 245, + 246, 251, 252, 255, 261, 263, 265, 266, 271, 274, 306, 311, 315, 325, 331, 332, 343, 346, 351, + 356, 364, 365, 371, 411, 412, 413, 423, 431, 432, 445, 446, 452, 454, 455, 462, 464, 465, 466, + 503, 506, 516, 523, 526, 532, 546, 565, 606, 612, 624, 627, 631, 632, 654, 662, 664, 703, 712, + 723, 731, 732, 734, 743, 754, +]; + +/// The `ME` field for a tone this radio does not have. +/// +/// Deliberately an error rather than a nearest-match: the channel library is +/// radio-agnostic and holds tones from radios with longer tables, and silently +/// moving an operator's 159.8 Hz to 156.7 Hz would program a channel that +/// cannot open the repeater it names. The caller decides — drop the tone, or +/// refuse the channel — the way `ChannelFit` already decides about bands. +#[cfg_attr(not(test), allow(dead_code))] // see the module doc +pub(crate) fn tone_field(hz: f64) -> Result { + let dhz = (hz * 10.0).round() as u16; + let idx = TONES_DHZ + .iter() + .position(|&t| t == dhz) + .ok_or_else(|| format!("the TM-D710 has no CTCSS tone {hz:.1} Hz"))?; + Ok(format!("{idx:02}")) +} + +/// The tone an `ME` field names, in hertz. +#[cfg_attr(not(test), allow(dead_code))] // see the module doc +pub(crate) fn tone_hz(field: &str) -> Result { + let idx: usize = field + .parse() + .map_err(|_| format!("tone index {field:?} is not a number"))?; + TONES_DHZ + .get(idx) + .map(|&d| f64::from(d) / 10.0) + .ok_or_else(|| format!("tone index {idx} is past the radio's 42-tone table")) +} + +/// The `ME` field for a DCS code, given as the octal digits the database holds. +#[cfg_attr(not(test), allow(dead_code))] // see the module doc +pub(crate) fn dcs_field(code: &str) -> Result { + let n: u16 = code + .parse() + .map_err(|_| format!("DCS code {code:?} is not a number"))?; + let idx = DCS_CODES + .iter() + .position(|&c| c == n) + .ok_or_else(|| format!("{code} is not a DCS code the TM-D710 has"))?; + Ok(format!("{idx:03}")) +} + +/// The DCS code an `ME` field names, zero-padded the way the panel shows it. +#[cfg_attr(not(test), allow(dead_code))] // see the module doc +pub(crate) fn dcs_code(field: &str) -> Result { + let idx: usize = field + .parse() + .map_err(|_| format!("DCS index {field:?} is not a number"))?; + DCS_CODES + .get(idx) + .map(|&c| format!("{c:03}")) + .ok_or_else(|| format!("DCS index {idx} is past the radio's 104-code table")) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The lengths are not a style choice — they are what the radio accepted and + /// refused. `42` and `104` are the first index each field rejected, so a + /// table that grew or shrank would be writing a value the radio bounces. + #[test] + fn the_tables_are_exactly_as_long_as_the_radio_allows() { + assert_eq!(TONES_DHZ.len(), 42, "field 9 refused index 42 on hardware"); + assert_eq!(DCS_CODES.len(), 104, "field 11 refused index 104 on hardware"); + } + + /// Both lists ascend, and a duplicate would make `position()` unreachable + /// for the later copy — a value that encodes to one index and decodes to + /// another. + #[test] + fn both_tables_ascend_with_no_repeats() { + assert!( + TONES_DHZ.windows(2).all(|w| w[0] < w[1]), + "the CTCSS table is not strictly ascending" + ); + assert!( + DCS_CODES.windows(2).all(|w| w[0] < w[1]), + "the DCS table is not strictly ascending" + ); + } + + /// The four boundary values that were measured on the radio, written the way + /// an `ME` line carries them — two characters for a tone, three for DCS. + #[test] + fn the_measured_boundaries_encode_to_the_fields_the_radio_took() { + assert_eq!(tone_field(67.0).unwrap(), "00"); + assert_eq!(tone_field(254.1).unwrap(), "41"); + assert_eq!(dcs_field("023").unwrap(), "000"); + assert_eq!(dcs_field("754").unwrap(), "103"); + } + + /// ★ The one claim a cable cannot check. Memory 503 holds `dcs_idx = 023`; + /// read as an index that is the 24th code. If the radio's screen shows + /// anything but D134 for that memory, this table is wrong — see the module + /// doc. + #[test] + fn dcs_index_023_is_the_code_134() { + assert_eq!(dcs_code("023").unwrap(), "134"); + } + + /// Round-tripping is what the encoder relies on: a tone read off the radio + /// and written straight back must land on the same field. + #[test] + fn every_entry_round_trips_through_its_field() { + for (i, &dhz) in TONES_DHZ.iter().enumerate() { + let hz = f64::from(dhz) / 10.0; + let field = tone_field(hz).expect("encode"); + assert_eq!(field, format!("{i:02}")); + assert_eq!(tone_hz(&field).expect("decode"), hz); + } + for (i, &code) in DCS_CODES.iter().enumerate() { + let text = format!("{code:03}"); + let field = dcs_field(&text).expect("encode"); + assert_eq!(field, format!("{i:03}")); + assert_eq!(dcs_code(&field).expect("decode"), text); + } + } + + /// A tone this radio does not have is an error, not the nearest one it does. + /// 159.8 Hz is in the ID-52's table and not in this one, so it is exactly + /// the case a shared channel library produces. + #[test] + fn a_tone_the_radio_lacks_is_refused_rather_than_rounded() { + let err = tone_field(159.8).unwrap_err(); + assert!(err.contains("159.8"), "{err}"); + assert!(dcs_field("024").is_err(), "024 is not a DCS code"); + assert!(tone_hz("42").is_err(), "the radio refused index 42"); + assert!(dcs_code("104").is_err(), "the radio refused index 104"); + } +} diff --git a/src-tauri/src/radios/kenwood_tmd710_probe.rs b/src-tauri/src/radios/kenwood_tmd710_probe.rs new file mode 100644 index 0000000..5fbdfff --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710_probe.rs @@ -0,0 +1,1555 @@ +//! 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, cable into the COM port on the rear of the **operation panel** +//! (Kenwood manual §5.1.2). ⚠ Not the main unit — AG7GN's README says main +//! unit, but that is the TM-D710**G**; measured on this radio in session 120. +//! From `src-tauri/`: +//! +//! ```text +//! D710_PORT=/dev/cu.usbserial-XXXX cargo test --lib d710_find_the_radio -- --ignored --nocapture +//! 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 COM port on the rear of the OPERATION PANEL — not the main unit, \ + which is where the G's is — and does the port enumerate at all? An RT Systems cable's \ + FTDI carries their own VID/PID and may not bind a driver here." + ); +} + +/// 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"); +} + +/// Read a named list of slots and print the `ME` and `MN` lines verbatim. +/// +/// Read-only, and deliberately **not** `d710_dump_memories`: that one rewrites +/// `scratchpad/kenwood_tmd710/memories.txt`, which is the restore file holding +/// the radio's as-found state. Running it while a campaign has test values in +/// the radio would overwrite the only copy of what to put back. +/// +/// `D710_SLOTS=500,501,502,503` +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_read_slots() { + let slots: Vec = std::env::var("D710_SLOTS") + .expect("set D710_SLOTS to a comma-separated list, e.g. 500,501,502,503") + .split(',') + .map(|s| s.trim().parse().expect("slot number")) + .collect(); + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + println!(); + for slot in slots { + let (me, _) = ask(&mut *p, &format!("ME {slot:03}")).expect("ME"); + if me == crate::radios::kenwood_tmd710::memory::EMPTY_REPLY { + println!("{slot:03} (empty)"); + continue; + } + let (mn, _) = ask(&mut *p, &format!("MN {slot:03}")).expect("MN"); + println!("{me}\n{mn}"); + } + println!(); +} + +/// ★ **`0M PROGRAM` mode — the radio's OTHER transport, and where APRS lives.** +/// +/// `MU` carries 42 menu parameters and stops at the 500-series. The TM-D710's +/// APRS and TNC settings are the **600-series menus**, and there is no `MU` +/// parameter for any of them — which is why a settings read built on `MU` alone +/// comes back with no APRS at all. On an APRS radio that is most of the point of +/// the thing missing. +/// +/// MCP-2A does not use `MU`. It puts the radio into a block-transfer mode and +/// reads a **memory image**, so this radio is not purely live-mode after all: +/// it has a second transport, and everything `MU` cannot reach lives in there. +/// +/// ```text +/// "0M PROGRAM\r" -> "0M\r" the display shows PROG MCP +/// R -> W (len 0 = 256) +/// then the host sends 06 and the radio answers 06 +/// "E" -> 06 0D 00 back to normal +/// ``` +/// +/// ## ★ The handshake is the whole trick +/// +/// The first three attempts at this all showed the same shape — the first `R` +/// after entering the mode returned a block and every one after it timed out — +/// which read like a refusal and was not. **The host must acknowledge each +/// block with `0x06`, and the radio acknowledges that back**, so a reader that +/// skips it is left holding a stream one byte out of step. The giveaway was a +/// header that came back `06 57 00 00`: a status byte, then `W`, then the +/// address. Published notes for this mode do not mention it. +/// +/// ## This one is read-only and it still changes the radio's state +/// +/// Nothing here writes a byte of configuration. But entering the mode puts the +/// radio into `PROG MCP` on its own display, and **leaving it there strands the +/// operator** until they power-cycle. So the exit is not on the happy path: the +/// dump runs inside a closure and `E` is sent afterwards either way. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_program_mode_dump() { + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + let id = ask(&mut *p, "ID").expect("ID").0; + assert!(id.contains("TM-D710"), "not a TM-D710: {id:?}"); + + let entered = ask(&mut *p, "0M PROGRAM").expect("enter program mode").0; + println!("\n0M PROGRAM -> {entered:?}"); + assert!(entered.starts_with("0M"), "the radio refused program mode: {entered:?}"); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut image: Vec = Vec::new(); + let mut addr: u32 = 0; + while addr < 0x1_0000 { + match read_block(&mut *p, addr as u16, 0) { + Ok(data) => { + let n = data.len(); + image.extend_from_slice(&data); + addr += n as u32; + } + Err(e) => { + println!("stopped at 0x{addr:04X}: {e}"); + break; + } + } + } + image + })); + + // ⚠ Always. See the doc comment. + let _ = p.write_all(b"E"); + let _ = p.flush(); + std::thread::sleep(Duration::from_millis(300)); + let mut ack = [0u8; 3]; + let _ = read_exact_timeout(&mut *p, &mut ack); + println!("E -> {ack:02X?}"); + + let image = result.expect("the dump panicked; the radio was still taken out of program mode"); + println!("=== {} bytes ({:.1} KiB)", image.len(), image.len() as f64 / 1024.0); + assert!(image.len() > 256, "program mode gave back only {} bytes", image.len()); + + let out = format!("../scratchpad/kenwood_tmd710/progmode-{}.bin", std::process::id()); + std::fs::write(&out, &image).expect("write"); + println!("--- saved {out}\n"); +} + +/// Raw stream capture — no framing, no interpretation. +/// +/// The first full dump came back drifting **one byte per block**: the same +/// content, sliding. That is a reader bug, not radio data, and guessing at it +/// costs more than looking. This sends three small requests and prints every +/// byte that comes back with a gap-based split, so the actual framing is +/// visible rather than inferred. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_program_mode_raw() { + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + assert!(ask(&mut *p, "ID").expect("ID").0.contains("TM-D710")); + assert!(ask(&mut *p, "0M PROGRAM").expect("enter").0.starts_with("0M")); + + // Drain whatever is in flight, then send one request and read everything + // that arrives until the line goes quiet. + let drain = |p: &mut dyn SerialPort, label: &str, req: &[u8]| { + let _ = p.clear(serialport::ClearBuffer::Input); + let _ = p.write_all(req); + let _ = p.flush(); + let mut got = Vec::new(); + let deadline = Instant::now() + Duration::from_millis(900); + let mut b = [0u8; 1]; + while Instant::now() < deadline { + match p.read(&mut b) { + Ok(1) => got.push(b[0]), + _ => { + if !got.is_empty() { + break; + } + } + } + } + println!(" {label}: sent {req:02X?}\n got {got:02X?}"); + got + }; + + // ★ Address byte order. The 256-byte block at 0x0000 has `00 00 30 30` at + // offset 0x10, so whichever request returns those is the right way round — + // and 0x0000 itself cannot answer it, which is exactly what let the first + // dump walk one byte per block instead of 256. + drain(&mut *p, "hi-first 0x0010", &[b'R', 0x00, 0x10, 0x10]); + drain(&mut *p, " ack ", &[0x06]); + drain(&mut *p, "lo-first 0x0010", &[b'R', 0x10, 0x00, 0x10]); + drain(&mut *p, " ack ", &[0x06]); + + let _ = p.write_all(b"E"); + let _ = p.flush(); + std::thread::sleep(Duration::from_millis(300)); + let mut ack = [0u8; 3]; + let _ = read_exact_timeout(&mut *p, &mut ack); + println!(" E -> {ack:02X?}\n"); +} + +/// One block, with the acknowledgement the radio waits for. +/// +/// `len` of 0 means 256 bytes, which is what the radio's own header uses. +fn read_block(p: &mut dyn SerialPort, addr: u16, len: u8) -> Result, String> { + // ⚠ BIG-endian, high byte first. The published note for this mode says + // little-endian, and 0x0000 — the only address anyone checks first — reads + // the same either way, so the error survives. It cost this driver a 64 KiB + // dump that drifted exactly one byte per block: stepping to 0x0100 sent + // `00 01`, which the radio read as 0x0001. + let req = [b'R', (addr >> 8) as u8, (addr & 0xFF) as u8, len]; + p.write_all(&req).map_err(|e| e.to_string())?; + p.flush().map_err(|e| e.to_string())?; + + let mut head = [0u8; 4]; + read_exact_timeout(p, &mut head)?; + if head[0] != b'W' { + return Err(format!("expected a W header, got {head:02X?}")); + } + let n = if head[3] == 0 { 256 } else { head[3] as usize }; + let mut data = vec![0u8; n]; + read_exact_timeout(p, &mut data)?; + + // ★ The handshake. Without it the next request is never answered. + p.write_all(&[0x06]).map_err(|e| e.to_string())?; + p.flush().map_err(|e| e.to_string())?; + let mut status = [0u8; 1]; + read_exact_timeout(p, &mut status)?; + if status[0] != 0x06 { + return Err(format!("the radio answered the ack with {:02X}", status[0])); + } + Ok(data) +} + +/// Why the second block read in a session never answers. +/// +/// Both earlier probes show the same shape: the **first** `R` after entering +/// program mode returns a block, and every one after it times out. That is not +/// an addressing problem — it happened at four different addresses — so the +/// question is what the radio is waiting for between blocks. The obvious +/// candidate is the `0x06` the radio itself sends to acknowledge a write: a +/// host that never acknowledges a block may simply be left holding one. +/// +/// Also settles the address byte order as a side effect, which the first dump +/// could not: it read `0x0000`, where both orders are the same two bytes. +/// Offset `0x10` of that block is `00 00 30 30`, so whichever request returns +/// those is the right way round. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_program_mode_handshake() { + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + assert!(ask(&mut *p, "ID").expect("ID").0.contains("TM-D710")); + assert!(ask(&mut *p, "0M PROGRAM").expect("enter").0.starts_with("0M")); + + fn one(p: &mut dyn SerialPort, req: [u8; 4], ack_after: bool) -> String { + let _ = p.write_all(&req); + let _ = p.flush(); + let mut head = [0u8; 4]; + if read_exact_timeout(p, &mut head).is_err() { + return "no reply".into(); + } + let len = if head[3] == 0 { 256 } else { head[3] as usize }; + let mut data = vec![0u8; len]; + let body = match read_exact_timeout(p, &mut data) { + Ok(()) => format!("{:02X?}", &data[..len.min(4)]), + Err(e) => format!("(short: {e})"), + }; + if ack_after { + let _ = p.write_all(&[0x06]); + let _ = p.flush(); + } + format!("head {head:02X?} data {body}") + } + + // Four in a row, acknowledging each. If the ACK is what was missing, all + // four answer where previously only the first did. + println!(); + for (i, addr) in [0x0000u16, 0x0000, 0x0010, 0x0020].iter().enumerate() { + let req = [b'R', (addr & 0xFF) as u8, (addr >> 8) as u8, 0x04]; + println!(" {i}: LE 0x{addr:04X} (sent {req:02X?}) -> {}", one(&mut *p, req, true)); + } + let req = [b'R', 0x00, 0x10, 0x04]; + println!(" BE 0x0010 (sent {req:02X?}) -> {}", one(&mut *p, req, true)); + + let _ = p.write_all(b"E"); + let _ = p.flush(); + std::thread::sleep(Duration::from_millis(300)); + let mut ack = [0u8; 3]; + let _ = read_exact_timeout(&mut *p, &mut ack); + println!("E -> {ack:02X?}\n"); +} + +/// Read exactly `buf.len()` bytes, or give up. Block transfers are binary and +/// fixed-length, so the `\r`-terminated [`ask`] cannot be used for them. +fn read_exact_timeout(p: &mut dyn SerialPort, buf: &mut [u8]) -> Result<(), String> { + let deadline = Instant::now() + Duration::from_millis(2000); + let mut got = 0; + while got < buf.len() { + if Instant::now() > deadline { + return Err(format!("timed out after {got} of {} bytes", buf.len())); + } + match p.read(&mut buf[got..]) { + Ok(0) => continue, + Ok(n) => got += n, + Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => continue, + Err(e) => return Err(e.to_string()), + } + } + Ok(()) +} + +// ⚠ 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!(); +} + +/// Sweep one `ME` field through every value its width allows and record which +/// ones the radio takes. +/// +/// ## Why this works, and why it is the cheapest instrument here +/// +/// The TM-D710 **validates a write and refuses it whole** — a rejected line +/// leaves the slot exactly as it was. So acceptance is a measurement, and the +/// first refused value is the size of the enum behind the field. That is how +/// fields 9-11 were settled as indices with lengths 42 and 104 (see +/// `kenwood_tmd710::tone`) without anyone reading the radio's screen. +/// +/// It measures a **range**, never a meaning. Knowing field 13 accepts `0`, `1` +/// and `2` does not say which is AM; that still takes the manual, a cross-check +/// against real memories, or the radio's own display. +/// +/// `D710_SLOT=504 D710_FIELDS=3,4,13,16` — 1-based, counting the slot number as +/// field 1, the way the module doc numbers them. Text in, text out: the base +/// line is substituted as **characters**, so a value `Memory::parse` would +/// refuse (an unknown shift, say) still reaches the radio, which is the whole +/// point. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_field_bounds() { + let slot: u16 = std::env::var("D710_SLOT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(504); + let fields: Vec = std::env::var("D710_FIELDS") + .unwrap_or_else(|_| "3,4,5,6,7,8,13,15,16".into()) + .split(',') + .map(|s| s.trim().parse().expect("field number")) + .collect(); + + let captured = std::fs::read_to_string("../scratchpad/kenwood_tmd710/memories.txt") + .expect("no captured memories — refusing to probe without the as-found copy"); + assert!( + !captured.contains(&format!("ME {slot:03},")), + "slot {slot:03} held a memory when the radio was first read; probe an empty one" + ); + + // Everything off and zero, so a refusal is the field under test and not a + // combination. Widths are the radio's — see the `memory` module doc. + // + // ⚠ The base is not neutral for every field, and the first sweep proved it: + // field 3 accepted only the tuning steps that divide **this** frequency + // evenly, and fields 4 and 15 are constrained by the TX frequency in field + // 14. So `D710_BASE` overrides the whole line (minus the slot) — measuring + // a field means choosing a base that lets it move. + let base: Vec = format!( + "{slot:03},{}", + std::env::var("D710_BASE") + .unwrap_or_else(|_| "0146520000,0,0,0,0,0,0,00,00,000,00000000,0,0000000000,0,0".into()) + ) + .split(',') + .map(str::to_string) + .collect(); + assert_eq!(base.len(), 16, "D710_BASE must be the 15 fields after the slot"); + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + println!(); + for f in fields { + let width = base[f - 1].len(); + let limit = 10usize.pow(width as u32); + // A wide field cannot be swept — field 12 is eight digits — so an + // explicit candidate list stands in for the range. + let candidates: Vec = match std::env::var("D710_VALUES") { + Ok(list) => list + .split(',') + .map(|v| v.trim().parse().expect("D710_VALUES")) + .collect(), + Err(_) => (0..limit.min(120)).collect(), + }; + let mut taken = Vec::new(); + let mut first_refused = None; + for v in candidates { + let mut line = base.clone(); + line[f - 1] = format!("{v:0width$}"); + let sent = format!("ME {}", line.join(",")); + // A refused write is not an error reply — the radio acknowledges and + // simply does not apply it — so the read-back is what decides. + let _ = ask(&mut *p, &sent); + let back = ask(&mut *p, &format!("ME {slot:03}")).expect("re-read").0; + if back == sent { + taken.push(v); + } else if first_refused.is_none() { + first_refused = Some(v); + } + // Leave nothing behind between candidates. + let _ = ask(&mut *p, &format!("ME {slot:03},C")); + } + let contiguous = + std::env::var("D710_VALUES").is_err() && taken.iter().enumerate().all(|(i, &v)| i == v); + println!( + "field {f:>2} (width {width}): accepted {} value(s){}{}", + taken.len(), + if contiguous { + format!(" — 0..={}", taken.len().saturating_sub(1)) + } else { + format!(" — {taken:?} ⚠ NOT contiguous") + }, + match first_refused { + Some(v) => format!(", first refused {v}"), + None => ", nothing refused in range".into(), + } + ); + } + println!(); +} + +/// Which characters survive a memory name, one character at a time. +/// +/// A name is a **separate command** (`MN nnn,TEXT`) whose text runs to the end +/// of the line, so the failure this guards against is not cosmetic: the app's +/// channel names come from a database that has never been constrained to what a +/// 1990s Kenwood accepts, and a character the radio silently drops or rewrites +/// produces a memory labelled something other than what the operator asked for. +/// Same instrument as everywhere else here — write, read back, compare. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_name_charset() { + use crate::radios::kenwood_tmd710::{memory::Memory, write_memory}; + let slot: u16 = std::env::var("D710_SLOT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(504); + + let captured = std::fs::read_to_string("../scratchpad/kenwood_tmd710/memories.txt") + .expect("no captured memories — refusing to probe without the as-found copy"); + assert!( + !captured.contains(&format!("ME {slot:03},")), + "slot {slot:03} held a memory when the radio was first read; probe an empty one" + ); + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + // A name needs a memory to hang on, so put one there first. + let line = format!("ME {slot:03},0146520000,0,0,0,0,0,0,00,00,000,00000000,0,0000000000,0,0"); + write_memory(&mut *p, &Memory::parse(&line).expect("parse")).expect("seed the slot"); + + let (mut kept, mut changed) = (String::new(), Vec::new()); + for byte in 0x20u8..0x7F { + let ch = byte as char; + // Padded so a dropped character shows as a length change rather than + // shifting into a neighbour and reading as a match. + let wanted = format!("A{ch}B"); + let sent = format!("MN {slot:03},{wanted}"); + let _ = ask(&mut *p, &sent); + let back = ask(&mut *p, &format!("MN {slot:03}")).expect("re-read").0; + match back.strip_prefix(&format!("MN {slot:03},")) { + Some(got) if got == wanted => kept.push(ch), + other => changed.push((ch, other.unwrap_or(&back).to_string())), + } + } + + println!("\n=== kept verbatim ({}): {kept}", kept.len()); + println!("=== altered or refused ({}):", changed.len()); + for (ch, got) in &changed { + println!(" {ch:?} (0x{:02X}) -> {got:?}", *ch as u8); + } + println!(); + let _ = ask(&mut *p, &format!("ME {slot:03},C")); +} + +/// ★ **The Phase 2 hardware gate: does the encoder emit lines this radio takes?** +/// +/// Every unit test in `encode.rs` compares the encoder against text. None of +/// them can catch the failure that actually matters here, because a value the +/// TM-D710 dislikes is **not** an error — the radio acknowledges the line and +/// leaves the slot alone. A driver can therefore be entirely self-consistent +/// and still write nothing. +/// +/// So: take each of the 38 memories the radio itself holds, decode it into app +/// terms, re-encode it into a **spare slot**, write it, and read it back. It +/// covers every channel shape Tim actually has — VHF, UHF, 220, the AM air-band +/// memory, the two on a 25 kHz step, split tone and CTCSS indices — without +/// touching one of his memories. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_encoder_acceptance() { + use crate::radios::kenwood_tmd710::{ + encode::{decode_channel, encode_channel}, + memory::Memory, + write_memory, + }; + let slot: u16 = std::env::var("D710_SLOT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(504); + + let captured = std::fs::read_to_string("../scratchpad/kenwood_tmd710/memories.txt") + .expect("no captured memories to encode from"); + assert!( + !captured.contains(&format!("ME {slot:03},")), + "slot {slot:03} held a memory when the radio was first read; use an empty one" + ); + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + let (mut accepted, mut refused) = (0usize, Vec::new()); + for line in captured.lines().filter(|l| l.starts_with("ME ")) { + let original = Memory::parse(line).expect("parse"); + let built = encode_channel(slot, &decode_channel(&original)).expect("encode"); + match write_memory(&mut *p, &built) { + Ok(()) => accepted += 1, + Err(e) => refused.push(format!("from {line}\n {e}")), + } + let _ = ask(&mut *p, &format!("ME {slot:03},C")); + } + + println!("\n=== {accepted} encoded memories accepted by the radio"); + if !refused.is_empty() { + println!("=== {} REFUSED:", refused.len()); + for r in &refused { + println!(" {r}"); + } + } + println!(); + assert!(refused.is_empty(), "{} encoded lines were refused", refused.len()); +} + +/// ★ **What the memory will actually hold, swept off the radio.** +/// +/// `rx_bands` is the seed field with the worst failure mode in this project: an +/// out-of-coverage frequency does not error, it becomes a **silently empty +/// memory** while the app reports the channel written (three repeaters were lost +/// that way on the ID-52). The usual defence is to copy the band table out of +/// the manual and hope it matches the variant in front of you — and the manual +/// on hand covers the TM-D710**G**, not Tim's non-G. +/// +/// So measure it. The radio refuses an `ME` line it cannot hold, which turns +/// coverage into the same accept/refuse question every other field answered: +/// sweep at 1 MHz, then bisect each edge down to 5 kHz. +/// +/// `D710_SWEEP_LO=50 D710_SWEEP_HI=1400` (MHz). Reads out as a table of ranges +/// ready to become `rx_bands`. +/// +/// ⚠ This measures what the **memory** accepts. It says nothing about transmit: +/// the radio stores an out-of-band memory happily and refuses at `[PTT]` +/// (manual, REPEATER-1 note), which is exactly the receive-only case the app +/// already models. `tx_bands` cannot be measured this way and must not be +/// guessed from this output. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_rx_band_sweep() { + use crate::radios::kenwood_tmd710::encode::step_field; + let slot: u16 = std::env::var("D710_SLOT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(504); + let lo: u64 = std::env::var("D710_SWEEP_LO") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(50); + let hi: u64 = std::env::var("D710_SWEEP_HI") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1400); + + let captured = std::fs::read_to_string("../scratchpad/kenwood_tmd710/memories.txt") + .expect("no captured memories — refusing to sweep without the as-found copy"); + assert!( + !captured.contains(&format!("ME {slot:03},")), + "slot {slot:03} held a memory when the radio was first read; sweep into an empty one" + ); + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + // One closure, used by both the coarse sweep and the bisection, so an edge + // is never decided by a different test than the sweep that found it. + let mut holds = |p: &mut dyn SerialPort, hz: u64| -> bool { + let Ok(step) = step_field(hz) else { return false }; + let sent = format!( + "ME {slot:03},{hz:010},{step},0,0,0,0,0,08,08,000,00000000,0,0000000000,0,0" + ); + let _ = ask(p, &sent); + let back = ask(p, &format!("ME {slot:03}")).map(|(t, _)| t).unwrap_or_default(); + let _ = ask(p, &format!("ME {slot:03},C")); + back == sent + }; + + let started = Instant::now(); + let mut coarse = Vec::new(); + for mhz in lo..=hi { + coarse.push((mhz, holds(&mut *p, mhz * 1_000_000))); + } + + // Bisect every accepted/refused transition to the 5 kHz the field can express. + let refine = |p: &mut dyn SerialPort, + holds: &mut dyn FnMut(&mut dyn SerialPort, u64) -> bool, + mut good: u64, + mut bad: u64| { + while good.abs_diff(bad) > 5_000 { + let mid = (good + bad) / 2 / 5_000 * 5_000; + if mid == good || mid == bad { + break; + } + if holds(p, mid) { + good = mid; + } else { + bad = mid; + } + } + good + }; + + let mut ranges: Vec<(u64, u64)> = Vec::new(); + let mut open_at: Option = None; + for i in 0..coarse.len() { + let (mhz, ok) = coarse[i]; + let prev_ok = i > 0 && coarse[i - 1].1; + if ok && !prev_ok { + let start = if i == 0 { + mhz * 1_000_000 + } else { + refine(&mut *p, &mut holds, mhz * 1_000_000, (mhz - 1) * 1_000_000) + }; + open_at = Some(start); + } + if !ok && prev_ok { + let end = refine(&mut *p, &mut holds, (mhz - 1) * 1_000_000, mhz * 1_000_000); + if let Some(start) = open_at.take() { + ranges.push((start, end)); + } + } + } + if let (Some(start), Some(&(mhz, true))) = (open_at, coarse.last()) { + ranges.push((start, mhz * 1_000_000)); + } + + println!("\n=== what the TM-D710's memory accepts, {lo}-{hi} MHz"); + for (a, b) in &ranges { + println!( + " {:>11.5} .. {:>11.5} MHz", + *a as f64 / 1e6, + *b as f64 / 1e6 + ); + } + println!( + "=== {} range(s) in {:.0}s\n", + ranges.len(), + started.elapsed().as_secs_f64() + ); + assert!(!ranges.is_empty(), "the radio accepted no frequency at all"); +} + +/// ★ **Every `MU` menu parameter's range, swept off the radio (Phase 4).** +/// +/// The same instrument as `d710_field_bounds`, pointed at the menu instead of a +/// memory. `MU` sets all 42 parameters in one line and the radio refuses a +/// value it does not have, so the accepted count *is* the size of the enum +/// behind that menu — which is what turns the manual's menu list from a +/// suggestion into a match: a menu with eight options can only be a field that +/// takes `0..=7`. +/// +/// It measures a **size, never a meaning.** Which option is which still takes +/// the radio's own screen. That half is Tim's, and it is the cheap half once +/// the sizes have narrowed the candidates. +/// +/// ## Safety +/// +/// - **Nothing here changes the port speed.** Menu 920 (PC PORT SPEED) and the +/// COM port speed are not `MU` parameters — the line reaches menu 507 at p29 +/// and the published field list has no port speed in it. That was checked +/// before a byte was written, because sweeping a baud-rate field would drop +/// the connection mid-write with the radio on an unknown rate. +/// - **The original line is restored after every field**, not once at the end, +/// so an abort leaves at most one parameter moved. +/// - p37 is APO on the published list. Restoring per-field means it never +/// stays on a timeout long enough to power the radio down mid-sweep. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_menu_bounds() { + use crate::radios::kenwood_tmd710::{memory::Menu, write_menu}; + + // p29-p34 are two-digit HEX (the capture holds `0C` and `0E`), so their + // candidates have to be hex or the sweep measures the wrong alphabet. + const HEX_FIELDS: [usize; 6] = [29, 30, 31, 32, 33, 34]; + + let fields: Vec = match std::env::var("D710_FIELDS") { + Ok(list) => list.split(',').map(|v| v.trim().parse().expect("field")).collect(), + Err(_) => (1..=42).collect(), + }; + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + let original = Menu::parse(&ask(&mut *p, "MU").expect("MU").0).expect("parse"); + println!("\noriginal: {}\n", original.to_line()); + + for f in fields { + let width = original.field(f).expect("field").len(); + let hex = HEX_FIELDS.contains(&f); + let limit = match (width, hex) { + (1, _) => 10, + (_, true) => 0x40, + _ => 64, + }; + + let mut taken = Vec::new(); + let mut first_refused = None; + for v in 0..limit { + let text = if hex { + format!("{v:02X}") + } else { + format!("{v:0width$}") + }; + if text.len() > width { + break; + } + let wanted = original.with_field(f, &text).expect("with_field"); + // ★ `MU` refuses differently from `ME`. A memory the radio dislikes + // is acknowledged and quietly not stored; an out-of-range MENU value + // draws an explicit `?`, which `ask` turns into an error. Both are + // the same measurement — the value is not one this radio has — so + // an Err here is a refusal, not a failure of the probe. + let accepted = match write_menu(&mut *p, &wanted) { + Ok(failed) => failed.is_empty(), + Err(_) => false, + }; + if accepted { + taken.push(v); + } else if first_refused.is_none() { + first_refused = Some(v); + } + // Put it back before moving on — see the safety note. A `?` can + // leave the parser mid-line, so the restore gets the same one + // retry `ask_settling` gives every session. + let back = match write_menu(&mut *p, &original) { + Ok(diff) => diff, + Err(_) => write_menu(&mut *p, &original).expect("restore"), + }; + assert!(back.is_empty(), "p{f}: could not restore the menu line: {back:?}"); + } + + let contiguous = taken.iter().enumerate().all(|(i, &v)| i == v); + println!( + "p{f:<2} (width {width}{}) accepted {:>2}{}", + if hex { ", hex" } else { "" }, + taken.len(), + if contiguous { + format!(" 0..={}", taken.len().saturating_sub(1)) + } else { + format!(" {taken:?} ⚠ NOT contiguous") + } + ); + let _ = first_refused; + } + + let after = Menu::parse(&ask(&mut *p, "MU").expect("MU").0).expect("parse"); + assert_eq!( + after.to_line(), + original.to_line(), + "the sweep did not leave the menu as it found it" + ); + println!("\n--- menu restored exactly\n"); +} + +/// ★ **The settings path end to end, through the traits the app actually calls.** +/// +/// `d710_menu_bounds` proved the radio takes a menu write. This proves the +/// *driver* does — `SettingsReader::read_settings` and +/// `SettingsWriter::write_settings`, the same two methods the profile editor +/// reaches, rather than the raw command underneath them. +/// +/// That distinction is the whole point: in this repo a working read path has +/// twice hidden a dead write path, most expensively on the ID-52, where the +/// form filled correctly and the values simply never reached the radio. +/// +/// Changes one field, reads it back through the decoder, and puts it back — +/// asserting the whole 42-parameter line is byte-identical to how it started, +/// which is also what proves the write is a PATCH and not a rebuild. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_settings_roundtrip() { + use crate::radios::driver::{SettingsReader, SettingsWriter}; + use crate::radios::kenwood_tmd710::DRIVER; + use serde_json::json; + + let path = port_path(); + let dir = std::env::temp_dir().join("d710-settings-probe"); + + // Straight through the trait, exactly as `read_radio_settings` does. + let before = DRIVER.read_settings(&path, "[]").expect("read settings"); + let line_before = String::from_utf8(before.backup.clone()).expect("the backup is the MU line"); + println!("\nbefore: {line_before}"); + println!("beep volume reads {}", before.settings["beep-volume"]); + + // Something audible and harmless, and pick a value it is NOT already on so + // the write cannot pass by doing nothing — the trap that made a TH-D72 + // settings write look verified against its own unread buffer. + let was = before.settings["beep-volume"].as_str().expect("an option label"); + let target = if was == "2" { "6" } else { "2" }; + + let report = DRIVER + .write_settings(&path, &json!({ "beep-volume": target }), "[]", &dir) + .expect("write settings"); + println!( + "wrote {} field(s), verified={:?}{}", + report.fields_written, + report.verified, + report.note.as_deref().unwrap_or("") + ); + assert_eq!(report.fields_written, 1, "expected exactly one field to change"); + assert_eq!(report.verified, Some(true), "the radio did not take the write"); + + let mid = DRIVER.read_settings(&path, "[]").expect("re-read"); + assert_eq!( + mid.settings["beep-volume"], + json!(target), + "the decoder does not see the value the write claimed to make" + ); + + // Put it back, and require the WHOLE line to match — that is what says the + // write patched one parameter instead of rebuilding all 42. + DRIVER + .write_settings(&path, &json!({ "beep-volume": was }), "[]", &dir) + .expect("restore"); + let after = DRIVER.read_settings(&path, "[]").expect("final read"); + let line_after = String::from_utf8(after.backup).expect("utf8"); + println!("after: {line_after}"); + assert_eq!( + line_after, line_before, + "the settings round trip did not leave the menu as it found it" + ); + println!("\n--- settings read + write proven through the driver traits\n"); +} + +/// Clear slots back to empty — `ME nnn,C`, the documented form, tested here. +/// +/// `d710_restore` can overwrite a memory but cannot **un-write** one, so every +/// slot a campaign creates in previously-empty space stays created. This is the +/// other half of giving the radio back as found. +/// +/// `D710_SLOTS=505` — refuses to touch a slot that was populated before this +/// campaign, because those are the operator's and `memories.txt` is the only +/// copy of them. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_clear_slots() { + let slots: Vec = std::env::var("D710_SLOTS") + .expect("set D710_SLOTS to a comma-separated list") + .split(',') + .map(|s| s.trim().parse().expect("slot number")) + .collect(); + + let captured = std::fs::read_to_string("../scratchpad/kenwood_tmd710/memories.txt") + .expect("no captured memories — refusing to clear anything without the as-found copy"); + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + + println!(); + for slot in slots { + let was_populated = captured.contains(&format!("ME {slot:03},")); + assert!( + !was_populated, + "slot {slot:03} held a memory when the radio was first read. Clearing it would \ + destroy the operator's own channel; only slots this campaign created may be cleared." + ); + ask(&mut *p, &format!("ME {slot:03},C")).expect("clear"); + let after = ask(&mut *p, &format!("ME {slot:03}")).expect("re-read").0; + let empty = after == crate::radios::kenwood_tmd710::memory::EMPTY_REPLY; + println!("{slot:03} {} (read back: {after})", if empty { "cleared" } else { "⚠ NOT CLEARED" }); + assert!(empty, "slot {slot:03} did not clear"); + } + println!(); +} + +/// Put the radio back exactly as it was found, from the captured transcript. +/// +/// The reason writing to Tim's radio is a reasonable thing to do at all. +#[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"); + } +} + +/// ★★★ The whole image — the two thirds `d710_program_mode_dump` never saw. +/// +/// That dump walked forward until a read failed and stopped at `0x7F00`, +/// reporting 32 512 bytes as "the image". It is not. **`0x7F00` is a hole in +/// the middle, not the end.** CHIRP's clone-mode driver +/// (`chirp/drivers/tmd710.py`, `KenwoodTMD710Radio._read_mem`) reads blocks +/// `0x00`-`0x9B` and skips exactly one of them with the comment +/// `# Skip block 7f !!??`, then reads two odd tails at `0xFEF0` and `0xFF00`. +/// A reader that treats the hole as an end loses everything above it. +/// +/// What is up there matters: CHIRP maps SkyCommand around `0x8660`, and +/// **nothing anywhere in `0x0000`-`0x7EFF` looks like an APRS setting** — no +/// call sign but the power-on message, no path, no beacon text — on a radio +/// whose 600-series holds 32 APRS and TNC menus. This is where they have to be. +/// +/// ## Addresses here are RADIO addresses +/// +/// The output is a `0x1_0000`-byte file with `FF` for every address never read, +/// so a file offset *is* the address the radio answers to. CHIRP's own mmap +/// concatenates blocks instead, which shifts everything above the skipped block +/// down by 0x100 — that is why its `#seekto 0x08660` is really `0x8760` on the +/// wire. Not a convention to inherit while measuring. +/// +/// Read-only, and it still leaves `PROG MCP` on the display, so `E` is sent +/// outside the happy path exactly as in `d710_program_mode_dump`. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_program_mode_dump_full() { + use super::kenwood_tmd710::image::{ProgramMode, HOLE}; + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + let id = ask(&mut *p, "ID").expect("ID").0; + assert!(id.contains("TM-D710"), "not a TM-D710: {id:?}"); + + // `ProgramMode` sends `E` from Drop, so a panic in here still leaves the + // radio usable — which is the whole reason the transport owns the session + // rather than the harness. + let image = { + let mut prog = ProgramMode::enter(&mut *p).expect("enter program mode"); + let image = prog.read_image().expect("read the image"); + prog.leave().expect("leave program mode"); + image + }; + + println!("=== {} bytes read, hole at 0x{HOLE:04X} skipped", image.bytes_read()); + assert_eq!(image.bytes_read(), 39_840, "the ritual did not return the whole image"); + + let out = format!("../scratchpad/kenwood_tmd710/progfull-{}.bin", std::process::id()); + std::fs::write(&out, image.as_addressed_bytes()).expect("write"); + println!("--- saved {out} (0x10000 bytes, FF where nothing was read)\n"); +} + +/// Get the radio out of `PROG MCP` when a dump left it there. +/// +/// A probe that fails mid-block never reaches its own `E`, and the radio then +/// answers nothing at all — `ID` comes back empty, which looks exactly like a +/// dead cable. It is not: the radio is in program mode and only speaks the +/// binary protocol. Sending `E` on its own is the whole fix, and it is worth a +/// named instrument because the failure mode is indistinguishable from +/// hardware trouble at the point where someone would start unplugging things. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_leave_program_mode() { + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = p.clear(serialport::ClearBuffer::All); + p.write_all(b"E").expect("write E"); + p.flush().expect("flush"); + std::thread::sleep(Duration::from_millis(400)); + let mut ack = [0u8; 3]; + let _ = read_exact_timeout(&mut *p, &mut ack); + println!("E -> {ack:02X?}"); + let _ = p.clear(serialport::ClearBuffer::All); + + let _ = ask(&mut *p, "ID"); + let id = ask(&mut *p, "ID").expect("ID after E").0; + println!("ID -> {id:?}"); + assert!(id.contains("TM-D710"), "the radio is still not answering: {id:?}"); +} + +/// ★★★ The image WRITE path, climbed one rung at a time on the narrowest, +/// least destructive field this radio has. +/// +/// Nothing had ever been written to this radio's image before this test. The +/// rungs are the `new-radio` ladder adapted from a container to a transport: +/// +/// 1. **Identity write** — write a field back byte-for-byte and read it back. +/// Proves the verb, the framing and the acknowledgement with **nothing at +/// risk**: if every byte is the one already there, a total success and a +/// total no-op are the same outcome. +/// 2. **One field** — change it, read it back, leave the mode, come back and +/// read it again. The second read is the one that matters. This protocol has +/// no checksum and no commit step, so a value that survives a re-entry is +/// the only evidence anything was *stored* rather than echoed. +/// 3. **The screen** — and that one is not in here. ✅ Done on 2026-09-02: Tim +/// read `CPMAGIC TEST 129` off menu 608. A read-back proves the bytes are in +/// the image; only the radio's own display proves the image is what the +/// radio's settings are. +/// +/// ## Why status text 3 +/// +/// It is **empty on Tim's radio** (`FF` x 42), so there is no operator data to +/// lose, and it is directly visible on the radio's own screen. +/// +/// ## ⚠ This is a NARROW write and that is the thing being tested +/// +/// CHIRP writes the whole 156-block image wrapped in an invalidate/revalidate +/// dance. This writes **42 bytes** and touches no header at all. That could +/// simply not commit: the BT-9000 has a segment that acknowledges a partial +/// write and silently keeps the old contents. It does commit — measured — and +/// that is the difference between "to change a status text, rewrite the +/// operator's entire radio" and not. +#[test] +#[ignore = "requires a TM-D710 on the cable — WRITES to the radio"] +fn d710_status_text_write_ladder() { + use super::kenwood_tmd710::image::{ProgramMode, APRS_LIVE}; + + const TEXT_LEN: usize = 42; + let addr = APRS_LIVE + STATUS_TEXT_3; + let probe: Vec = { + let mut v = b"CPMAGIC TEST 129".to_vec(); + v.resize(TEXT_LEN, 0x00); + v + }; + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + assert!(ask(&mut *p, "ID").expect("ID").0.contains("TM-D710")); + + let before = { + let mut prog = ProgramMode::enter(&mut *p).expect("enter"); + let before = prog.read(addr, TEXT_LEN as u8).expect("read status text 3"); + println!("\nbefore {}", show(&before)); + + prog.write(addr, &before).expect("identity write"); + let same = prog.read(addr, TEXT_LEN as u8).expect("read back after identity"); + assert_eq!(same, before, "an identity write changed the field"); + println!("identity ok, unchanged"); + + prog.write(addr, &probe).expect("write the probe text"); + let after = prog.read(addr, TEXT_LEN as u8).expect("read back after write"); + println!("after write {}", show(&after)); + assert_eq!(after, probe, "the read-back does not match what was written"); + prog.leave().expect("leave"); + before + }; + + // Rung 2b — the one that separates a stored value from an echoed one. + std::thread::sleep(Duration::from_millis(500)); + let persisted = { + let mut prog = ProgramMode::enter(&mut *p).expect("re-enter"); + let got = prog.read(addr, TEXT_LEN as u8).expect("read after re-entering"); + prog.leave().expect("leave"); + got + }; + println!("after re-entry {}", show(&persisted)); + + assert_ne!( + persisted, before, + "the field is back to what it was: the narrow write was acknowledged and NOT committed" + ); + assert_eq!(persisted, probe, "the field changed, but not to what was written"); + println!("\n★ narrow image write PROVEN over a re-entry. Check menu 608 on the radio."); +} + +/// Status text 3 of 5, as an offset into the APRS block: `[1 flag][42 text]` +/// entries from `+0x089`. +const STATUS_TEXT_3: u16 = 0x089 + 2 * 44 + 1; + +/// Put status text 3 back to the `FF`-filled empty it was before the ladder. +#[test] +#[ignore = "requires a TM-D710 on the cable — WRITES to the radio"] +fn d710_restore_status_text_3() { + use super::kenwood_tmd710::image::{ProgramMode, APRS_LIVE}; + + const TEXT_LEN: usize = 42; + let addr = APRS_LIVE + STATUS_TEXT_3; + let empty = vec![0xFFu8; TEXT_LEN]; + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + assert!(ask(&mut *p, "ID").expect("ID").0.contains("TM-D710")); + + let mut prog = ProgramMode::enter(&mut *p).expect("enter"); + prog.write(addr, &empty).expect("restore"); + let back = prog.read(addr, TEXT_LEN as u8).expect("read back"); + prog.leave().expect("leave"); + + println!("restored to {}", show(&back)); + assert_eq!(back, empty, "status text 3 is not back to empty"); +} + +/// Put the whole live APRS block back from a saved dump. +/// +/// This is what makes a front-panel measurement pass reversible, and it is why +/// the write path was built before the campaign rather than after it: without +/// it, every setting changed to let a diff name an offset is a setting the +/// operator has to re-enter by hand from memory. +/// +/// `D710_IMAGE=` names a `d710_program_mode_dump_full` file — 64 KiB with +/// `FF` for anything unread, so a file offset is a radio address. Only the +/// 1152 bytes of the **live** block are written; PM1-5 are the operator's saved +/// profiles and nothing here has any business touching them. +#[test] +#[ignore = "requires a TM-D710 on the cable — WRITES to the radio"] +fn d710_restore_aprs_block() { + use super::kenwood_tmd710::image::{ProgramMode, APRS_BLOCK_LEN, APRS_LIVE, IMAGE_SPAN}; + + let src = std::env::var("D710_IMAGE").expect("set D710_IMAGE to a full-dump file"); + let image = std::fs::read(&src).expect("read the dump"); + assert_eq!(image.len(), IMAGE_SPAN, "{src} is not a 64 KiB full dump"); + let want = &image[APRS_LIVE as usize..APRS_LIVE as usize + APRS_BLOCK_LEN]; + assert!( + want.iter().any(|b| *b != 0xFF), + "the APRS block in {src} is all FF — that dump never read this region" + ); + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + assert!(ask(&mut *p, "ID").expect("ID").0.contains("TM-D710")); + + let mut prog = ProgramMode::enter(&mut *p).expect("enter"); + let mut written = 0usize; + for off in (0..APRS_BLOCK_LEN).step_by(256) { + let n = 256.min(APRS_BLOCK_LEN - off); + let addr = APRS_LIVE + off as u16; + let chunk = &want[off..off + n]; + prog.write(addr, chunk).unwrap_or_else(|e| panic!("write 0x{addr:04X}: {e}")); + // ⚠ Read back every block before moving on: an acknowledged write that did + // not commit is a failure this protocol can produce and would otherwise + // report as success. + let back = prog + .read(addr, if n == 256 { 0 } else { n as u8 }) + .unwrap_or_else(|e| panic!("read back 0x{addr:04X}: {e}")); + assert_eq!(back, chunk, "0x{addr:04X} did not take the write"); + written += n; + } + prog.leave().expect("leave"); + println!("restored {written} bytes of the live APRS block from {src}"); +} + +/// Bytes as hex plus their printable reading, which is how every field in this +/// block has to be looked at: half of them are text and half are not. +fn show(b: &[u8]) -> String { + let t: String = b.iter().map(|c| if (0x20..0x7F).contains(c) { *c as char } else { '.' }).collect(); + format!("{} |{}|", b.iter().map(|c| format!("{c:02X}")).collect::>().join(""), t) +} + diff --git a/src-tauri/src/radios/mod.rs b/src-tauri/src/radios/mod.rs index ca5876e..c80d420 100644 --- a/src-tauri/src/radios/mod.rs +++ b/src-tauri/src/radios/mod.rs @@ -34,6 +34,12 @@ pub(crate) mod fake_port; pub(crate) mod icom_id52; pub(crate) mod kenwood_thd72; pub(crate) mod kenwood_thd75; +pub(crate) mod kenwood_tmd710; +/// Phase 1 capture harness for the TM-D710 (#113). Throwaway, like the FT5D's +/// `hw_probe`: it measures, it is never linked into the app, and it goes when +/// the driver it is informing exists. +#[cfg(test)] +mod kenwood_tmd710_probe; pub(crate) mod port_lock; pub(crate) mod registry; pub(crate) mod settings_bounds; diff --git a/src-tauri/src/radios/registry.rs b/src-tauri/src/radios/registry.rs index 1d6c18b..dd55c35 100644 --- a/src-tauri/src/radios/registry.rs +++ b/src-tauri/src/radios/registry.rs @@ -23,7 +23,7 @@ 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; 8] = [ +static DRIVERS: [&dyn RadioDriver; 9] = [ &super::baofeng_uv5r::DRIVER, &super::binteradio_bt9000::DRIVER, &super::tidradio_tdh3::DRIVER, @@ -32,6 +32,7 @@ static DRIVERS: [&dyn RadioDriver; 8] = [ &super::icom_id52::DRIVER, &super::kenwood_thd75::DRIVER, &super::kenwood_thd72::DRIVER, + &super::kenwood_tmd710::DRIVER, ]; pub(crate) fn all_drivers() -> &'static [&'static dyn RadioDriver] { @@ -82,6 +83,8 @@ mod tests { "yaesu_ft5d", "icom_id52", "kenwood_thd75", + "kenwood_thd72", + "kenwood_tmd710", ] { let d = driver_for_key(key).unwrap_or_else(|| panic!("no driver for '{key}'")); assert_eq!(d.key(), key); @@ -129,6 +132,17 @@ mod tests { // caught anywhere downstream — this radio stored 127 in fields // whose maxima are 9, 2, 3 and 1 (issue #43). "binteradio_bt9000" => (true, true), + // TM-D710: reads and writes its 42 menu parameters over the + // same `MU` ASCII command as the TH-D72 above, and for the same + // reason — a live-mode radio has no image for settings to live + // in. It claimed NEITHER half until Phase 4 (#113), because + // "nothing has ever been written to this radio" was true and a + // capability flag would have put a settings write in front of an + // operator. Writing is now proven: `d710_menu_bounds` wrote and + // restored every one of the 42 parameters on Tim's radio, and + // every exposed range was measured there rather than read off + // the published sheet that was wrong about five of them. + "kenwood_tmd710" => (true, true), _ => (true, true), }; assert_eq!( diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index 2a831f7..acd0901 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -189,6 +189,16 @@ pub const THD72_SETTINGS_SCHEMA: &str = include_str!("thd72_settings_schema.json pub const BT9000_SETTINGS_SCHEMA: &str = include_str!("bt9000_settings_schema.json"); +/// The TM-D710 profile-settings schema, GENERATED alongside the Rust field +/// table by `scratchpad/kenwood_tmd710/gen_tmd710_settings.py` from +/// `scratchpad/kenwood_tmd710/MEASURED.md`. One parse emits both, and a test in +/// `radios/kenwood_tmd710/settings.rs` asserts they still agree. +/// +/// 35 of the radio's 42 `MU` menu parameters. The other seven — the six PF-key +/// assignments and p25, which no source names — have MEASURED ranges and +/// undetermined meanings, so they are deliberately absent rather than guessed. +pub const TMD710_SETTINGS_SCHEMA: &str = include_str!("tmd710_settings_schema.json"); + fn models() -> Vec { vec![ @@ -919,6 +929,91 @@ fn models() -> Vec { connection_type: "USB cable (built-in mini-USB)", non_channel_settings_schema: THD72_SETTINGS_SCHEMA, }, + // -------------------------------------------------------- + // 8. Kenwood TM-D710A — analog FM dual-band MOBILE with APRS + // and a built-in TNC, 1000 memories, 8-char names, 50 W. + // The non-G A model; the G and the E differ. Programmed in + // LIVE MODE (issue #113) — one ASCII `ME` command per + // memory over the operation panel's COM port, which is a + // fourth modality here: no clone image, no card file, and + // a write that is NOT atomic. See radios/kenwood_tmd710/. + // NOTES: + // (a) ★ rx_bands is MEASURED, not read out of a manual. + // The radio refuses an `ME` line it cannot hold, so + // coverage is an accept/refuse question: `d710_rx_band_sweep` + // wrote 1350 frequencies at 1 MHz and bisected both edges + // to 5 kHz. The answer is ONE contiguous span, + // 118.000-523.995, with no interior gap at 1 MHz + // resolution. That matters twice over — the manual on hand + // covers the D710**G** and lists an 800-1300 MHz group + // this radio does not accept at all, and a band table + // copied from it would have promised memories that cannot + // exist. + // (b) ⚠ tx_bands is NOT measured and must not be: the only + // way to ask this radio what it will transmit on is to key + // it. 144-148 / 430-450 is the K-type allocation, and `TY` + // answered `K,0,3,1,0` on Tim's. A TX-modified radio is + // under-served by this row, which is the safe direction. + // (c) covers_220 is TRUE where the TH-D72 above is false. + // This radio genuinely hears 220 — Tim's own memory 005 is + // a 224.840 repeater — so a 220 channel must land as + // RECEIVE-ONLY rather than be dropped. See the test below. + // (d) banks_supported is FALSE. The radio has ten memory + // groups (Menu 203, Memory Group Link) and they are almost + // certainly the D72's positional hundreds, but "almost + // certainly" is an inference from a sibling radio and this + // project has a rule about those. A flat 1000-slot pool is + // what the encoder actually writes today. + // (e) max_name_length 8 is measured on the wire, not read + // off Menu 200: a ninth character comes back SILENTLY + // TRUNCATED rather than refused. + // (f) ★ settings are the radio's own 42 `MU` menu + // parameters, and every RANGE in them was measured on the + // radio rather than read off a sheet: the TM-D710 answers + // an out-of-range menu value with `?`, so a sweep gives the + // exact size of each enum. That caught five errors in the + // published table, two of which would have shipped wrong + // values — the beep and voice volumes are 7 levels, not 8, + // so the display is the stored value PLUS ONE. 35 of the 42 + // are exposed; the six PF-key assignments and p25 have + // measured ranges and undetermined meanings and are left + // out rather than guessed. + // -------------------------------------------------------- + ModelSeed { + manufacturer: "Kenwood", + model: "TM-D710", + driver_key: Some("kenwood_tmd710"), + programming_ui: Some("generic"), + display_name: "Kenwood TM-D710", + analog_capable: true, + dmr_capable: false, + dstar_capable: false, + ysf_capable: false, + nxdn_capable: false, + p25_capable: false, + m17_capable: false, + aprs_capable: true, + covers_hf: false, + covers_vhf: true, + covers_uhf: true, + covers_220: true, + covers_900: false, + freq_min: 144.0, + freq_max: 450.0, + tx_bands: Some("[[144.0,148.0],[430.0,450.0]]"), + rx_bands: Some("[[118.0,523.995]]"), + memory_channels: 1000, + zones_supported: false, + max_zones: None, + channels_per_zone: None, + scan_lists_supported: false, + max_scan_lists: None, + banks_supported: false, + max_name_length: 8, + export_format: "chirp_csv", + connection_type: "Serial cable (COM port, rear of the operation panel)", + non_channel_settings_schema: TMD710_SETTINGS_SCHEMA, + }, ] } @@ -1157,6 +1252,133 @@ mod tests { /// leniently — a typo would not fail loudly, it would quietly fall back to /// the old contiguous-span rules and drop channels again. Check the shape /// here, where it is cheap. + /// ★ The TM-D710's coverage, checked against the sweep that measured it. + /// + /// `d710_rx_band_sweep` wrote 1350 frequencies into a spare memory slot and + /// bisected both edges: the radio holds **one contiguous span, + /// 118.000-523.995**, and nothing outside it. These assertions are that + /// result, so a band table later "corrected" from the D710**G** manual — + /// which lists an 800-1300 MHz group this radio refuses outright — fails + /// here instead of on the radio. + /// + /// The 220 MHz case is the one that matters most. Unlike the TH-D72 above, + /// this radio really does hear 220: Tim's own memory 005 is a 224.840 + /// repeater. It must therefore land as RECEIVE-ONLY, never be dropped. + #[test] + fn the_tmd710_hears_one_span_and_keys_only_two_ham_bands() { + let d710 = models() + .into_iter() + .find(|m| m.model == "TM-D710") + .expect("the TM-D710 is seeded"); + + let bands = |json: &str| -> Vec> { serde_json::from_str(json).unwrap() }; + let tx = bands(d710.tx_bands.expect("TM-D710 has tx_bands")); + let rx = bands(d710.rx_bands.expect("TM-D710 has rx_bands")); + let covers = |bs: &[Vec], mhz: f64| bs.iter().any(|b| mhz >= b[0] && mhz <= b[1]); + + // Heard, and only heard — the receive-only case, not the excluded one. + assert!(d710.covers_220, "the TM-D710 receives the 220 MHz band"); + assert!( + covers(&rx, 224.840) && !covers(&tx, 224.840), + "224.840 is a real memory on Tim's radio and must land receive-only" + ); + assert!(covers(&rx, 118.400) && !covers(&tx, 118.400), "air band is receive-only"); + assert!(covers(&rx, 462.6) && !covers(&tx, 462.6), "GMRS is receive-only"); + + // Both measured edges, and one step past each. + assert!(covers(&rx, 118.0), "118.000 was accepted"); + assert!(!covers(&rx, 117.995), "117.995 was refused"); + assert!(covers(&rx, 523.995), "523.995 was accepted"); + assert!(!covers(&rx, 524.0), "524.000 was refused"); + + // The span the G's manual lists and this radio will not hold. If a + // future edit re-adds it, a 1.2 GHz channel would be reported written + // into a memory that cannot exist. + assert!(!covers(&rx, 800.0), "800 MHz was refused by the radio"); + assert!(!covers(&rx, 1290.0), "1290 MHz was refused by the radio"); + + // No interior gap: the sweep found every megahertz between the edges + // acceptable, which is why this is one span and not five. + for mhz in [136.0, 174.0, 200.0, 250.0, 300.0, 350.0, 400.0] { + assert!(covers(&rx, mhz), "{mhz} MHz is inside the measured span"); + } + + // The two it keys on. Not measured — measuring would mean transmitting. + assert!(covers(&tx, 146.52) && covers(&tx, 446.0)); + } + + /// ★ **The Phase 3 gate: the seeded row, through the app's own decision.** + /// + /// Everything above tests the band *lists*. This seeds a real database and + /// runs the four channel shapes through `channel_fit` — the same function + /// the export preview and every program dialog call — so a row that is + /// well-formed but wrong in the pipeline fails here. + /// + /// The verdicts are the point. `ReceiveOnly` and `Excluded` are different + /// answers: one programs the memory and says so, the other drops the + /// channel. Getting 224.840 into the second bucket would silently lose a + /// repeater Tim actually has in his radio. + #[tokio::test] + async fn the_seeded_tmd710_gives_the_right_verdict_on_real_channels() { + use crate::commands::export::{channel_fit, ChannelFit}; + use crate::models::{Channel, RadioModel}; + + let dir = std::env::temp_dir().join(format!("cpm_seed_{}", std::process::id())); + let db_path = dir.join("d710.sqlite3"); + let _ = std::fs::remove_file(&db_path); + let pool = crate::db::init_pool(&db_path).await.expect("init_pool"); + + let model: RadioModel = sqlx::query_as( + "SELECT id, manufacturer, model, display_name, analog_capable, dmr_capable, \ + dstar_capable, ysf_capable, nxdn_capable, p25_capable, m17_capable, aprs_capable, \ + covers_hf, covers_vhf, covers_uhf, covers_220, covers_900, freq_min, freq_max, \ + tx_bands, rx_bands, memory_channels, zones_supported, max_zones, channels_per_zone, \ + scan_lists_supported, max_scan_lists, banks_supported, max_name_length, \ + export_format, connection_type, non_channel_settings_schema, driver_key, \ + programming_ui FROM radio_models WHERE model = 'TM-D710'", + ) + .fetch_one(&pool) + .await + .expect("the TM-D710 reached the database"); + + let fit = |rx: f64, mode: &str| { + channel_fit( + &Channel { + rx_freq: rx, + mode: Some(mode.into()), + ..Default::default() + }, + &model, + ) + }; + + // Both ham bands: programmed outright. + assert!(matches!(fit(146.520, "FM"), ChannelFit::Included)); + assert!(matches!(fit(446.000, "FM"), ChannelFit::Included)); + + // ★ Heard but not keyed — programmed WITH a reason, never dropped. + for (mhz, what) in [(224.840, "220 MHz"), (118.400, "air band"), (462.6, "GMRS")] { + assert!( + matches!(fit(mhz, "FM"), ChannelFit::ReceiveOnly(_)), + "{mhz} MHz ({what}) must be receive-only, not excluded: {:?}", + fit(mhz, "FM") + ); + } + + // Outside the measured span: genuinely no use on this radio. + for mhz in [117.995, 524.0, 800.0, 1290.0] { + assert!( + matches!(fit(mhz, "FM"), ChannelFit::Excluded(_)), + "{mhz} MHz was refused by the radio and must be excluded" + ); + } + + // Analog-only: a DMR repeater is a hard exclusion, not receive-only. + assert!(matches!(fit(446.000, "DMR"), ChannelFit::Excluded(_))); + + let _ = std::fs::remove_file(&db_path); + } + #[test] fn seeded_band_lists_are_well_formed() { let parse = |json: &str| -> Vec> { diff --git a/src-tauri/src/tmd710_settings_schema.json b/src-tauri/src/tmd710_settings_schema.json new file mode 100644 index 0000000..f4d2bc7 --- /dev/null +++ b/src-tauri/src/tmd710_settings_schema.json @@ -0,0 +1,298 @@ +[ + { + "key": "key-beep", + "label": "Key beep (Menu 000)", + "type": "boolean" + }, + { + "key": "beep-volume", + "label": "Beep volume (Menu 001)", + "type": "enum", + "options": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7" + ] + }, + { + "key": "external-speaker-mode", + "label": "External speaker mode (Menu 002)", + "type": "enum", + "options": [ + "Mode 1", + "Mode 2" + ] + }, + { + "key": "announce", + "label": "Announce (Menu 003)", + "type": "enum", + "options": [ + "Off", + "Auto", + "Manual" + ] + }, + { + "key": "language", + "label": "Language (Menu 004)", + "type": "enum", + "options": [ + "English", + "Japanese" + ] + }, + { + "key": "voice-volume", + "label": "Voice volume (Menu 005)", + "type": "enum", + "options": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7" + ] + }, + { + "key": "announce-speed", + "label": "Announce speed (Menu 006)", + "type": "integer", + "min": 0, + "max": 4 + }, + { + "key": "playback-repeat", + "label": "Playback repeat (Menu 007)", + "type": "boolean" + }, + { + "key": "playback-repeat-interval", + "label": "Playback repeat interval (Menu 008)", + "type": "integer", + "min": 0, + "max": 60 + }, + { + "key": "continuous-recording", + "label": "Continuous recording (Menu 009)", + "type": "boolean" + }, + { + "key": "vhf-aip", + "label": "VHF AIP (Menu 100)", + "type": "boolean" + }, + { + "key": "uhf-aip", + "label": "UHF AIP (Menu 101)", + "type": "boolean" + }, + { + "key": "squelch-hang-up-time", + "label": "Squelch hang-up time (Menu 106)", + "type": "enum", + "options": [ + "Off", + "125 ms", + "250 ms", + "500 ms" + ] + }, + { + "key": "mute-hang-up-time", + "label": "Mute hang-up time (Menu 107)", + "type": "enum", + "options": [ + "Off", + "125 ms", + "250 ms", + "500 ms", + "750 ms", + "1000 ms" + ] + }, + { + "key": "beat-shift", + "label": "Beat shift (Menu 108)", + "type": "boolean" + }, + { + "key": "time-out-timer", + "label": "Time-out timer (Menu 109)", + "type": "enum", + "options": [ + "3 min", + "5 min", + "10 min" + ] + }, + { + "key": "memory-recall-method", + "label": "Memory recall method (Menu 201)", + "type": "enum", + "options": [ + "All bands", + "Current band" + ] + }, + { + "key": "echolink-speed", + "label": "EchoLink speed (Menu 205)", + "type": "enum", + "options": [ + "Fast", + "Slow" + ] + }, + { + "key": "dtmf-hold", + "label": "DTMF hold (Menu 300)", + "type": "boolean" + }, + { + "key": "dtmf-speed", + "label": "DTMF speed (Menu 302)", + "type": "enum", + "options": [ + "Fast", + "Slow" + ] + }, + { + "key": "dtmf-pause", + "label": "DTMF pause (Menu 303)", + "type": "enum", + "options": [ + "100 ms", + "250 ms", + "500 ms", + "750 ms", + "1000 ms", + "1500 ms", + "2000 ms" + ] + }, + { + "key": "dtmf-key-lock", + "label": "DTMF key lock (Menu 304)", + "type": "boolean" + }, + { + "key": "automatic-repeater-offset", + "label": "Automatic repeater offset (Menu 401)", + "type": "boolean" + }, + { + "key": "1750-hz-tx-hold", + "label": "1750 Hz TX hold (Menu 402)", + "type": "boolean" + }, + { + "key": "display-brightness", + "label": "Display brightness (Menu 501)", + "type": "enum", + "options": [ + "Off", + "Level 1", + "Level 2", + "Level 3", + "Level 4", + "Level 5", + "Level 6", + "Level 7", + "Level 8" + ] + }, + { + "key": "automatic-brightness", + "label": "Automatic brightness (Menu 502)", + "type": "boolean" + }, + { + "key": "backlight-colour", + "label": "Backlight colour (Menu 503)", + "type": "enum", + "options": [ + "Amber", + "Green" + ] + }, + { + "key": "microphone-key-lock", + "label": "Microphone key lock (Menu 513)", + "type": "boolean" + }, + { + "key": "scan-resume-method", + "label": "Scan resume method (Menu 907)", + "type": "enum", + "options": [ + "Time-operated", + "Carrier-operated", + "Seek" + ] + }, + { + "key": "auto-power-off", + "label": "Auto power off (Menu 917)", + "type": "enum", + "options": [ + "Off", + "30 min", + "60 min", + "90 min", + "120 min", + "180 min" + ] + }, + { + "key": "external-data-band", + "label": "External data band (Menu 918)", + "type": "enum", + "options": [ + "Band A", + "Band B", + "TX A-RX B", + "TX B-RX A" + ] + }, + { + "key": "external-data-speed", + "label": "External data speed (Menu 919)", + "type": "enum", + "options": [ + "1200 bps", + "9600 bps" + ] + }, + { + "key": "sqc-output-source", + "label": "SQC output source (Menu 921)", + "type": "enum", + "options": [ + "Off", + "Busy", + "SQL", + "TX", + "Busy or TX", + "SQL or TX" + ] + }, + { + "key": "auto-pm-store", + "label": "Auto PM store (Menu 922)", + "type": "boolean" + }, + { + "key": "display-partition-bar", + "label": "Display partition bar (Menu 928)", + "type": "boolean" + } +] diff --git a/src/components/codeplugs/ProgramRadioDialog.tsx b/src/components/codeplugs/ProgramRadioDialog.tsx index 4539e40..9a34c10 100644 --- a/src/components/codeplugs/ProgramRadioDialog.tsx +++ b/src/components/codeplugs/ProgramRadioDialog.tsx @@ -83,6 +83,22 @@ export function ProgramRadioDialog({ // way the cable UI never flashes on for a radio that will hide it once the // capabilities land, and a normal radio never waits on them. const showCable = isProgrammable(model) && (media == null || cableCapable); + // ⚠ `showCable` says a PORT section belongs here; it does NOT say anything + // may be written. Every registered driver implements `identify`, so a cable + // radio always earns the port picker — but the write affordances have to ask + // `cableCapable` separately. + // + // The two were one flag until the TM-D710 (#113): the first driver registered + // with a cable modality and NO capability trait, deliberately, because + // nothing had been written to that radio yet. `media == null` was standing in + // for "has a cable path", which had been true of every non-media radio until + // then — so seeding the model turned "Program radio" into a button that + // confirmed a destructive write and then failed at the backend with "cannot + // be programmed over the cable". Same shape as #65: the gate existed, the + // button just never consulted it. + const canWriteOverCable = showCable && cableCapable; + // Cable or card — anything at all that ends with a codeplug on the radio. + const canWriteAnything = canWriteOverCable || media != null; const [ports, setPorts] = useState([]); const [port, setPort] = useState(""); const [preview, setPreview] = useState(null); @@ -383,7 +399,7 @@ export function ProgramRadioDialog({ )} - {showCable && ( + {canWriteOverCable && (