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..ece2f92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,21 @@ substantial work under `src-tauri/src/radios/`. ## Standing workflow +- **⚠⚠ Build. Do not commit, do not push, do not run `/code-review`. Stop and + say the work is ready.** Tim, 2026-09-06: *"Just build the thing and when I'm + ready to push I'll ask for code review."* Committing and pushing are **his + calls, on his timing** — `/code-review` is a command he invokes when he is ready + to push, not a step for this agent to run on its own. + + ⚠ The failure this replaces is real: in s134 this agent finished each piece of + work, ran `npm run ci`, committed and pushed — five times, unasked — and Tim's + objection ("you just haul off and start doing CI") was about being moved past, + not about which checks ran. A green `npm run ci` is not permission to commit and + is not a review; it runs the tests that already exist, so it cannot find the bug + nobody thought to test for. + + So: finish the work, leave it in the working tree, and report what changed and + what is unverified. Commit only when asked, and push only when asked. - **Verify in dev, then commit, then push.** `npm run tauri:dev` runs against a separate `.dev` app identifier, so dev never shares the production database. - **CI is free and unmetered.** The repo went public on 2026-08-22, so standard @@ -31,6 +46,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..0d89713 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 | Serial cable — read, write, settings | 1000 memories; 95 settings across **two transports** — 35 over the radio's `MU` command and 60 more, including the APRS position, status texts and station icon, out of the settings image `MU` cannot reach. Programmed live, one memory at a time over the COM port on the rear of the **operation panel** (not the main unit), so there is no image file and 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..6ceea37 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/encode.rs @@ -0,0 +1,663 @@ +//! 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 }) + // ⚠ Non-ASCII is replaced, not passed through. `MAX_NAME` is 8 BYTES on + // the radio, and this used to bound `chars()` — so a RepeaterBook or CSV + // name carrying an en dash, an accent or a smart quote produced an `MN` + // line longer than 8 bytes. The radio keeps its 8, `write_memory`'s + // read-back compare then fails on a line that was otherwise fine, and + // because this program path is not atomic the run stops there and leaves + // the radio holding a mixture. `image_settings::encode_text` refuses + // out-of-range characters for the same reason. + .map(|ch| if ch.is_ascii_graphic() || ch == ' ' { ch } else { '?' }) + .take(MAX_NAME) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// ⚠ `MAX_NAME` is a count of BYTES on the radio, so a name is only safe if + /// every character it can emit is one byte. A multibyte character used to get + /// through and overrun the field, which fails `write_memory`'s read-back and + /// stops a non-atomic program run partway. + #[test] + fn a_sanitized_name_never_exceeds_max_name_bytes() { + for src in [ + "W0QEY Fort Collins", + "Cañon City", + "Rocky \u{2013} Mtn", // en dash + "\u{201c}Quoted\u{201d} Rptr", // smart quotes + "\u{1F4FB} radio", // an emoji, 4 bytes + "A,B,C,D,E,F,G,H,I", + "", + ] { + let out = sanitize_name(src); + assert!(out.len() <= MAX_NAME, "{src:?} -> {out:?} is {} bytes", out.len()); + assert!(out.chars().count() <= MAX_NAME, "{src:?} -> {out:?}"); + assert!(out.is_ascii(), "{src:?} -> {out:?} is not ASCII"); + assert!(!out.contains(','), "a comma would split the MN line: {out:?}"); + } + } + + 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..270da91 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/image.rs @@ -0,0 +1,568 @@ +//! 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); + +/// How long the radio needs after `E` before it answers a live-mode command. +/// Measured on the radio; see [`ProgramMode::exit`]. +const SETTLE_AFTER_EXIT: Duration = Duration::from_millis(500); + +/// 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` — but ⚠ **not always at offset 0.** One + // session in this campaign got `F6 06 0D`: a leftover byte from the + // preceding exchange arrived first and pushed the whole reply along. + // Every poked byte in that session was verified present afterwards by a + // full dump, so the writes had committed and only the goodbye was out of + // step. Requiring `06` in first position therefore turns a *successful* + // settings write into a reported failure, which is the worse error: an + // operator told a write failed will run it again. + // + // Accept the ack anywhere in the window and say so in the error only + // when it is absent entirely. This is safe precisely because `E` is the + // last thing in the session — a stray byte here cannot corrupt anything + // that follows it. + let r = match self.fill(&mut ack) { + Ok(()) if ack.contains(&0x06) => Ok(()), + Ok(()) => Err(format!( + "leaving program mode: the radio answered {ack:02X?}, with no 06 in it" + )), + Err(e) => Err(format!("leaving program mode: {e}")), + }; + // ★★★ MEASURED, not defensive. A live-mode command sent immediately + // after `E` draws **silence** — and silence, not the `?` that + // `ask_settling` exists to absorb. `d710_settling_after_program_mode` + // separated the two possible causes: with a long enough pause the very + // first command is answered, so the radio needs *time* and is not + // discarding a command. The threshold measured between 100 ms (fails) + // and 250 ms (works), every value above it clean; this is 2x the worst + // failing value. + // + // It lives here rather than at the call sites because it is a property + // of the transition, and the two-transport settings write is only the + // first caller that will cross it. + std::thread::sleep(SETTLE_AFTER_EXIT); + r + } + + 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, + /// Put one leftover byte in front of the exit ack, as the radio did + /// once during the s133 poke rounds. + pub stray_before_exit: bool, + /// Answer the goodbye with no `06` anywhere. + pub bad_exit_ack: 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, + stray_before_exit: false, + bad_exit_ack: 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()); + if self.bad_exit_ack { + out.extend_from_slice(&[0xAA, 0xBB, 0xCC]); + return 1; + } + if self.stray_before_exit { + // The `F6 06 0D` seen once on the real radio. + out.push(0xF6); + } + 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"); + } + + /// ⚠ Measured on the radio, once, in the middle of a poke round: the exit + /// ack came back as `F6 06 0D`. A full dump afterwards proved every written + /// byte had committed, so only the goodbye was out of step — and rejecting + /// it would have reported a **successful** settings write as a failure, which + /// is the error that gets an operator to run the write a second time. + #[test] + fn a_stray_byte_before_the_exit_ack_does_not_fail_a_session_that_worked() { + let mut radio = FakeProgD710::new(); + radio.stray_before_exit = true; + let mut port = FakePort::new(radio); + let mut prog = ProgramMode::enter(&mut port).expect("enter"); + prog.write(APRS_LIVE, b"K0AA").expect("write"); + prog.leave().expect("a stray byte before the ack is not a failed session"); + } + + /// But a window with no `06` in it at all still fails — the tolerance is for + /// a shifted ack, not for a radio that never acknowledged. + #[test] + fn an_exit_with_no_acknowledgement_anywhere_is_still_an_error() { + let mut radio = FakeProgD710::new(); + radio.bad_exit_ack = true; + let mut port = FakePort::new(radio); + let prog = ProgramMode::enter(&mut port).expect("enter"); + let err = prog.leave().unwrap_err(); + assert!(err.contains("no 06 in it"), "{err}"); + } + + #[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/image_settings.rs b/src-tauri/src/radios/kenwood_tmd710/image_settings.rs new file mode 100644 index 0000000..f3a3753 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/image_settings.rs @@ -0,0 +1,1320 @@ +//! The 600-series (APRS/TNC) settings, which live in the image and not in `MU`. +//! +//! ## Why this radio needs two transports for one settings form +//! +//! `MU` carries 42 menu parameters and **none of the 6xx group** — the feature +//! the radio is named for. Those live in six `0x480`-byte blocks at +//! `0x8100 + n * 0x480` behind `0M PROGRAM`: the live copy first, then PM1-5. +//! So one settings read is an `MU` exchange *and* a program-mode block read, +//! and one settings write is both again. [`super::settings`] joins them; this +//! module owns the image half. +//! +//! ## What is here, and what deliberately is not +//! +//! `scratchpad/kenwood_tmd710/APRS-MEASURED.md` grades every located field and +//! `gen_tmd710_image.py` emits this table and the profile schema from that one +//! sheet. **39 of the 66 individual settings in the 6xx/7xx range ship.** A row +//! ships only when its whole encoding is *anchored*: every index is measured on +//! the radio, is the factory default confirmed against the A manual, or is the +//! single remaining printed entry. +//! +//! ⚠ That bar exists because this radio's manual has printed a **short** option +//! list twice — menu 611's intervals (8 printed, at least 10 real) and menu +//! 625's display area (3 printed, the factory byte is `03`). "The rest of the +//! manual's list, in order" is therefore not an anchor here, and eleven located +//! fields are held back on exactly that ground. The sheet names the check that +//! settles each one. +//! +//! ⚠⚠ **SmartBeaconing is not on this radio.** The 7 bytes at `+0x3D7` match the +//! published SmartBeaconing defaults, but the word appears zero times in the +//! TM-D710**A** manual and there is no menu 630/631/632. They were graded +//! against the **G**'s manual, which this project used for two sessions before +//! noticing. No menu reaches them and they must never ship as settings. +//! +//! ## The one free check that validated the whole set +//! +//! The five PM copies are untouched factory defaults and the A manual states +//! the default of every menu, so each offset can be refuted at the desk. All +//! **19 checkable rows match**, including the five non-zero ones — `+0x00F`=`02` +//! =200 ms, `+0x011`=`01`=4800 bps, `+0x016`=`06`=6-char, `+0x1E0`=`0C`=100.0 Hz, +//! `+0x1E9`=`1C`=28 s — which are the identifying ones, since most defaults are +//! zero. +//! +//! ## The count, stated rather than implied +//! +//! "22 fields" is not a result. The reconciliation the `new-radio` skill asks +//! for, for this radio: +//! +//! | | menus | individual settings | +//! |---|---|---| +//! | the radio has | ~115 | | +//! | `MU` reaches | 42 | 35 shipped, 7 held (6 PF keys + p25, meanings unmeasured) | +//! | the 6xx/7xx image block holds | 34 | 66, of which **39 ship** | +//! | reached by neither | ~39 | the 1xx-5xx menus with no `MU` parameter | +//! +//! So the form is **95 controls**, which is not the same number as 39 settings: +//! menus 605 and 608 hold **five records each**, so three position settings +//! become fifteen controls and two status-text settings become ten. The census +//! counts settings; the form counts controls; `the_census_is_stated_rather_than_implied` +//! asserts the arithmetic between them so "95 fields" can never be reported as +//! coverage it is not. +//! +//! The 27 unshipped 6xx/7xx settings each have a row in the sheet's `## Owed` +//! table naming the check that settles it: three belong to menu 612's packet +//! path (see below), nine are located but seen at a single value, and the rest +//! are unlocated. +//! +//! ## WHERE THIS STOPS — read before adding anything (s134) +//! +//! **Tim called this done on 2026-09-06**, after the three fields below landed: +//! *"close enough mark it all as good, we'll deal with those unlikely bugs if +//! they occur."* That lifts the earlier "no PR until APRS is usable" gate and +//! settles the two checks listed under *Accepted unverified* — they are a +//! deliberate risk, not an oversight, and each still names the one command that +//! would close it. +//! +//! ### What s134 closed +//! +//! The gap that defined "usable" was: *you can set your call sign and not your +//! position, status text, symbol or path.* Three of those four are now measured +//! on the radio and shipped. +//! +//! | menu | setting | how it is anchored | +//! |---|---|---| +//! | 605 | MY POSITION ×5 | 8-byte `FF`-padded name, then `[deg][min][frac16 LE][hemisphere]` **twice**. The fraction is thousandths of a minute; latitude is `0`=N/`1`=S and longitude `0`=E/`1`=W, **all four read on the front panel**. Two slots poked with disjoint digits (12/34/321 + 98/12/654, then 56/7/890 + 123/45/670) | +//! | 608 | STATUS TEXT ×5 | array base `+0x08A`, record = `[42 text][1 unknown][1 TX rate]`. Padding measured off **three texts the radio itself wrote**; rate stores the DENOMINATOR (`00`=Off, `03` read `1/3`) | +//! | 610 | STATION ICON | the raw APRS symbol table + code, anchored twice (`/-`→House, `/>`→Car) and agreeing with the **published APRS spec** rather than any list in the manual | +//! | 624 | RX BEEP | all five indices, the manual's list **reversed** | +//! +//! ★★★ **`+0x165` is resolved: it is status text record 5's TX rate.** Three +//! hypotheses died on that byte — position comment (s129), TX rate (s131), +//! position limit (s132) — and every one failed for the same reason: **the record +//! boundary was off by one**, not the encoding. s131's "`05` reads `1/5`" was +//! right about the encoding and wrong about which record owned it. When a byte +//! resists three guesses, suspect the array around it, not the byte. +//! +//! ### ⚠ Menu 612 PACKET PATH is located and deliberately NOT shipped +//! +//! It is not one setting. The manual lists four types, each with its own +//! sub-fields, and the menu shows all four with a marker on the one in use: +//! +//! - `+0x421` type index — `00`=New-N and `01`=Relay measured. `02` and `03` +//! **both fell back to New-N** while their string field was empty, which is the +//! manual's documented behaviour and not a bad offset. Once a path string +//! existed the byte held `03` through a front-panel `USE`, but the marker was +//! never read afterwards, so index 3 has **indirect evidence only**. +//! - `+0x172` TOTAL HOPS — one value (`03`). ⚠⚠ poking `07` left menu 612 with +//! **nothing selectable** until the block was restored, so 7 is out of range. +//! - `+0x184` the OTHERS path string — NUL-padded, and the radio **uppercases** +//! it (`0vt` typed on the panel stored as `0VT`). Width unmeasured. +//! - `+0x174` WIDE 1-1 — went `01`→`02` when set ON, so it is **not** a 0/1 +//! boolean and the OFF value is unconfirmed. +//! +//! One short round settles all four: poke `+0x174` at `01`/`02`, `+0x172` at +//! `01`/`02`/`04`, set an ABBR for State/Section/Region, and read menu 612. +//! +//! ### ⚠ Accepted unverified — a decision, not an oversight +//! +//! **`d710_record_fields_write` has never run on a radio.** It exercises menu +//! 605's and 608's records through `write_settings` — the same call the profile +//! screen makes — into slot 3 of each, which is unused on this operator's radio, +//! and puts the as-found bytes back raw afterwards (the form cannot express +//! "FF-filled", because an empty field means *leave it alone*). It was written +//! after the cable came off the Mac, and Tim chose to ship without it. +//! +//! So what IS and IS NOT established for the 27 fields s134 added: every +//! **encoding** was measured on the radio, and the **codecs** are tested only +//! against a buffer built from the radio's own bytes. The offsets are guarded by +//! `the_record_strides_land_where_the_radio_puts_them` and by a whole-span +//! overlap check, which is why the residual risk was judged small — but a buffer +//! cannot prove the driver writes where it means to. If a position or status text +//! ever comes back wrong, run this FIRST; it is one command: +//! +//! ```text +//! D710_PORT=… cargo test --lib d710_record_fields_write -- --ignored --nocapture +//! ``` +//! +//! Everything else in the settings path IS hardware-proven, including — as of +//! s134 — the `W::Config` window read, which had never been done by the driver: +//! it decoded `power-on-message` to `WW8L` and the call sign to `WW8L-1` off the +//! real radio, and the restore left both transports byte-identical. +//! +//! ### ⚠⚠ A hazard this module now defends against, and one it does not +//! +//! `patch` treats an **empty string as "not set"** and leaves the radio's bytes +//! alone. That is load-bearing: a profile the operator has never downloaded into +//! seeds every text field to `""` (`seedValues` → `fieldDefault`), and +//! `write_radio_settings` sends the profile as *saved* — so treating `""` as a +//! value would let a fresh profile blank the call sign, all five status texts and +//! all five position records in one write. Same shape as #90. +//! +//! ⚠ **The same seeding still pushes every `select` and `boolean` default**, and +//! that is NOT fixed here. A fresh D710 profile written to a radio would set ~60 +//! settings to a schema default the operator never chose. It is a form-layer +//! problem, not this module's, and it is not specific to this radio. +//! +//! ### Not shipped, and not planned — each still names its check +//! +//! None of these blocks the model; they are here so a later session does not +//! rediscover them from scratch. +//! +//! - **`MU` p25 is menu 403 or 406** — change menu **403** on the front panel and +//! read `MU`. ⚠ 403 is cross-band repeat; do not guess it. +//! - **A second tranche sits in `W::Config`**, which this driver already reads. +//! CHIRP names contrast (504), PC port baud (519), visual scan (515), group +//! link (203), S-meter squelch (105), WX alert (110) and repeater mode (403) +//! inside the `0x0200` block. ⚠ CHIRP's *field* claims for this radio have +//! never been checked, so each needs the factory-default cross-check first. +//! - Single-valued or unexplained: `+0x35D`, `+0x35E`, `+0x360`, `+0x361`, +//! `+0x363`, `+0x00B`, `+0x35F`=`82`, and each record's own unknown byte — +//! position idx8/idx19 and status text's 43rd. +//! - The ten group **names** and menu **203** itself are settings and unlocated. +//! +//! ### ★ How to work on this without wasting a radio session +//! +//! Batch **4-6 pokes across different menus in one pass**, then one walk of the +//! front panel. Always: distinct values, a control read, leave-and-re-enter +//! before believing a screen, and a step-aligned negative control at an edge. +//! +//! ★★ And **look for the A manual before asking the operator anything.** Menu +//! 612's four-field shape is in `TM-D710A_manual.txt` plus the G's PACKET PATH +//! section; a question was put to Tim that the manual on disk already answered. +//! +//! ## Writing +//! +//! A settings write is a **patch of differing runs**, never a whole-block write. +//! The block holds 44 bytes of status text, five 20-byte position records and +//! the operator's own call sign, none of which this form exposes; rewriting them +//! from a decoded-and-re-encoded block would put every one of them at risk of a +//! round-trip bug. Only bytes that actually change are sent, and the block is +//! read back afterwards — on this protocol an `0x06` is not a commit. + +use serde_json::{json, Map, Value}; + +use super::image::{ProgramMode, APRS_BLOCK_LEN, APRS_LIVE}; +use super::tone::TONES_DHZ; + +/// The image regions this form reads and writes. +/// +/// ⚠ Two, not one. The 600-series settings are in the APRS block, but menu 500's +/// POWER ON MESSAGE is in the **PM0 config block** at `0x0200` — a different +/// region entirely, and the reason this module is no longer called `aprs`. +/// A window is read whole, patched, and written back only where it differs. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum W { + /// PM0 config, `0x0200`. ⚠ Holds the five volatile operating-state bytes. + Config, + /// The live APRS/TNC block, `0x8100`. + Aprs, +} + +/// `0x0200` block 0; PM1-5 follow at a `0x200` stride, and this form never +/// touches those — they are the operator's saved profiles. +const CONFIG_BASE: u16 = 0x0200; +const CONFIG_LEN: usize = 0x200; + +impl W { + pub(crate) fn base(self) -> u16 { + match self { + W::Config => CONFIG_BASE, + W::Aprs => APRS_LIVE, + } + } + pub(crate) fn len(self) -> usize { + match self { + W::Config => CONFIG_LEN, + W::Aprs => APRS_BLOCK_LEN, + } + } + pub(crate) const ALL: [W; 2] = [W::Config, W::Aprs]; +} + +/// The windows as read off the radio, in [`W::ALL`] order. +/// +/// Carried as a list of `(window, bytes)` rather than a struct with a named +/// field per window so that adding a third region is a table entry, not a new +/// type — the config window was itself a late addition. +pub(crate) type Windows = Vec<(W, Vec)>; + +/// One image-backed setting, as the generated table states it. +pub(crate) struct AF { + pub key: &'static str, + pub label: &'static str, + /// The radio's own menu number, for the form's label. + pub menu: &'static str, + /// Which image window the offset is in. + pub win: W, + /// Byte offset from that window's base. + pub off: usize, + pub kind: AK, +} + +impl AF { + /// The form's label, with the menu number so a rejected value points at the + /// menu to go and look at. + pub(crate) fn display(&self) -> String { + format!("{} (Menu {})", self.label, self.menu) + } + + /// How many bytes this field occupies. + /// + /// Derived from the kind rather than stored, so a new multi-byte kind cannot + /// be added while some caller goes on assuming one byte — which is what the + /// overlap and volatile-state guards below both depend on. + pub(crate) fn span(&self) -> usize { + match self.kind { + AK::Text { bytes, .. } => bytes, + AK::LatLon { .. } => 5, + AK::Symbol => 2, + AK::Bool | AK::Bit { .. } | AK::Enum { .. } | AK::Uint { .. } | AK::Ctcss => 1, + } + } +} + +pub(crate) enum AK { + Bool, + /// One bit of a shared mask byte. Menu 609's packet filter is six of these + /// in `+0x167`, and **neither half of its packing is in the manual**: the + /// screen's 2×3 grid is read down each column, and that list is packed + /// MSB-first. Measured with `01`, `04` and `2A`; the factory `3F` is + /// invariant under every rival ordering and could not have caught any of it. + Bit { bit: u8 }, + Enum { labels: &'static [(u8, &'static str)] }, + Uint { min: u8, max: u8 }, + /// A fixed-width text field. `bytes` is the space it occupies, `chars` the + /// most the radio will show, and `pad` the byte the RADIO ITSELF writes + /// after the text — measured, and ⚠ **not the same for both text fields on + /// this radio**: the APRS call sign pads with `00` and the power-on message + /// with `FF`. Assuming one from the other would have been wrong. + Text { bytes: usize, chars: usize, pad: u8 }, + /// A 0-based index into the driver's own 42-tone CTCSS table — the same one + /// the channel encoder uses, read rather than re-typed so the two cannot + /// drift. Measured at `08` = 88.5 Hz and `0C` = 100.0 Hz. + Ctcss, + /// Half of a menu 605 position: `[deg][min][frac lo][frac hi][hemisphere]`, + /// five bytes, the fraction a 16-bit LITTLE-endian count of **thousandths of + /// a minute**. + /// + /// Measured by poking a slot the operator was not using and reading the + /// front panel: 12/34/321 came back as `12 34.32` and 98/12/654 as + /// `098 12.65`, so the panel shows two decimals of a value stored with + /// three. A second slot (56/7/890 and 123/45/670) confirmed it. + /// + /// ⚠ The hemisphere byte sits **after** its value, not before it, and the + /// two hemispheres do not share a convention: latitude is `0`=N/`1`=S and + /// longitude is `0`=E/`1`=W. Both were read on the screen for both fields. + /// The record is symmetric — `[value][hemisphere]` twice — which is what + /// made an earlier split that put both flags up front fit the operator's own + /// data perfectly and predict the wrong thing. + LatLon { lon: bool }, + /// A station icon: the raw APRS symbol **table** byte then **code** byte, + /// exactly two printable characters. + /// + /// Not an index into the menu's icon list. `2F 2D` = `/-` reads House on the + /// radio and a poked `2F 3E` = `/>` read Car — two anchors, and both agree + /// with the published APRS symbol spec rather than with any list in the + /// manual, so the encoding rests on a standard instead of on a printed + /// order this radio's manual has already got wrong three different ways. + Symbol, +} + +include!("tmd710_image_table.rs"); + +/// `88.5 Hz` and friends, in table order. +pub(crate) fn ctcss_labels() -> Vec { + TONES_DHZ + .iter() + .map(|d| format!("{}.{} Hz", d / 10, d % 10)) + .collect() +} + +/// Read one field's bytes out of the window it lives in. +fn bytes_of<'a>(wins: &'a [(W, Vec)], f: &AF, n: usize) -> Option<&'a [u8]> { + let (_, buf) = wins.iter().find(|(w, _)| *w == f.win)?; + buf.get(f.off..f.off + n) +} + +/// Decode the windows into the profile form's shape. +pub(crate) fn decode(wins: &[(W, Vec)], out: &mut Map) { + for f in TMD710_IMAGE_FIELDS { + if f.span() > 1 { + let Some(raw) = bytes_of(wins, f, f.span()) else { continue }; + let value = match f.kind { + AK::Text { .. } | AK::Symbol => json!(trim_text(raw)), + AK::LatLon { lon } => json!(decode_latlon(raw, lon)), + _ => unreachable!("{} spans {} bytes but is not a multi-byte kind", f.key, f.span()), + }; + out.insert(f.key.to_string(), value); + continue; + } + let Some(&b) = bytes_of(wins, f, 1).map(|s| &s[0]) else { continue }; + let value = match &f.kind { + // Handled above; the `continue` there is what makes these arms dead. + AK::Text { .. } | AK::Symbol | AK::LatLon { .. } => { + unreachable!("multi-byte kinds decode before this match") + } + AK::Bool => json!(b != 0), + AK::Bit { bit } => json!(b & (1 << bit) != 0), + AK::Uint { .. } => json!(b), + AK::Ctcss => match ctcss_labels().get(b as usize) { + Some(l) => json!(l), + None => json!(b), + }, + AK::Enum { labels } => match labels.iter().find(|(raw, _)| *raw == b) { + Some((_, l)) => json!(l), + // The same honest fallback the `MU` half uses: "your radio holds + // something this table cannot name" is a measurement gap, not a + // corrupt radio, and the number has to survive the round trip or + // every later write fails. + None => json!(b), + }, + }; + out.insert(f.key.to_string(), value); + } +} + +/// Text up to the first byte that is not printable ASCII. +/// +/// ⚠ **Not up to `pad`.** `pad` is what the radio writes after text *it* wrote, +/// and that is not what fills a record the radio has never written: a status text +/// slot the operator has never used is `FF`-filled while a used one is +/// NUL-padded, and the power-on message pads with `FF` where the call sign pads +/// with `00`. Every one of those terminates here. [`encode_text`] refuses +/// non-printable input, so a non-printable byte inside one of these fields is +/// always padding or space the radio has never touched. +fn trim_text(raw: &[u8]) -> String { + let end = raw.iter().position(|b| !(0x20..0x7F).contains(b)).unwrap_or(raw.len()); + raw[..end].iter().map(|b| *b as char).collect() +} + +/// One position half as the form shows it — `"N 40 29.240"` — or `""` for a slot +/// the radio is not using. +/// +/// An all-zero record is the radio's own empty slot; four of this operator's five +/// hold exactly that. It decodes to the empty string so the form shows a blank +/// rather than a spurious position on the equator, and [`encode_latlon`] writes +/// the zeros back for an empty string, so the round trip is exact. +fn decode_latlon(raw: &[u8], lon: bool) -> String { + if raw.iter().all(|b| *b == 0) { + return String::new(); + } + let hemi = match (lon, raw[4]) { + (false, 0) => 'N', + (false, _) => 'S', + (true, 0) => 'E', + (true, _) => 'W', + }; + // ⚠ Thousandths of a minute, LITTLE-endian, and the panel shows only two of + // the three digits — poking 321 read back as `.32`. So the third digit is + // real storage the radio will not display, and rounding it away here would + // change a position the operator never edited. + let frac = u16::from_le_bytes([raw[2], raw[3]]); + // Zero-padded exactly as the radio's own screen shows it — three degree + // digits for a longitude, two for a latitude, two minute digits for both. + // Tim read `098 12.65` and `56 07.89` off the panel, and a form that renders + // the same position differently from the radio is a form you cannot check + // against the radio. + let deg = if lon { format!("{:03}", raw[0]) } else { format!("{:02}", raw[0]) }; + format!("{hemi} {deg} {:02}.{frac:03}", raw[1]) +} + +/// `"N 40 29.240"` -> `[deg, min, frac lo, frac hi, hemisphere]`. +fn encode_latlon(f: &AF, v: &Value, lon: bool) -> Result, String> { + let raw = v + .as_str() + .ok_or_else(|| format!("{} expects text, got {v}", f.display()))?; + let s = raw.trim(); + if s.is_empty() { + // The radio's own "unused slot". Reached only from a value that was + // explicitly cleared, since `patch` skips an untouched empty field. + return Ok(vec![0; 5]); + } + let shape = if lon { "W 104 55.840" } else { "N 40 29.240" }; + let bad = || format!("{} should look like \"{shape}\"; got {s:?}", f.display()); + + // The hemisphere letter is taken from either end: "40 29.240 N" is how a lot + // of people write it, and refusing that teaches an operator nothing. + let mut body = s.to_ascii_uppercase(); + let letters = if lon { ['E', 'W'] } else { ['N', 'S'] }; + let hemi = if body.starts_with(letters) { + body.remove(0) + } else if body.ends_with(letters) { + body.pop().expect("non-empty") + } else { + return Err(format!( + "{} needs {} or {} for the hemisphere; got {s:?}", + f.display(), + letters[0], + letters[1] + )); + }; + + let mut parts = body.split_whitespace(); + let (Some(d), Some(m), None) = (parts.next(), parts.next(), parts.next()) else { + return Err(bad()); + }; + let (whole, frac) = match m.split_once('.') { + Some((whole, fr)) => { + if fr.is_empty() || fr.len() > 3 || !fr.bytes().all(|b| b.is_ascii_digit()) { + return Err(bad()); + } + // Left-aligned, because it is a decimal fraction: ".5" is 500 + // thousandths of a minute, not 5. + (whole, format!("{fr:0<3}").parse::().map_err(|_| bad())?) + } + None => (m, 0), + }; + let deg: u16 = d.parse().map_err(|_| bad())?; + let min: u16 = whole.parse().map_err(|_| bad())?; + + let deg_max = if lon { 180 } else { 90 }; + if deg > deg_max || (deg == deg_max && (min > 0 || frac > 0)) { + return Err(format!("{} is past {deg_max}\u{b0}; got {s:?}", f.display())); + } + if min > 59 { + return Err(format!("{} has {min} minutes; the radio stores 0-59", f.display())); + } + let [lo, hi] = frac.to_le_bytes(); + Ok(vec![deg as u8, min as u8, lo, hi, u8::from(hemi == 'S' || hemi == 'W')]) +} + +/// `"/-"` -> the two raw APRS symbol bytes, table then code. +fn encode_symbol(f: &AF, v: &Value) -> Result, String> { + let s = v + .as_str() + .ok_or_else(|| format!("{} expects text, got {v}", f.display()))?; + let c: Vec = s.chars().collect(); + // Exactly two, not "at most two": a symbol is a table byte AND a code byte, + // and half of one is not a lesser symbol, it is a different one. + if c.len() != 2 || c.iter().any(|c| !(' '..='~').contains(c)) { + return Err(format!( + "{} is an APRS symbol table and code \u{2014} exactly two characters, \ + like \"/-\" for a house or \"/>\" for a car; got {s:?}", + f.display() + )); + } + Ok(vec![c[0] as u8, c[1] as u8]) +} + +/// One text value as the bytes the radio stores, padded as the radio pads. +fn encode_text(f: &AF, v: &Value) -> Result, String> { + let AK::Text { bytes, chars, pad } = f.kind else { + unreachable!("encode_text on a non-text field") + }; + let s = v + .as_str() + .ok_or_else(|| format!("{} expects text, got {v}", f.display()))?; + if s.chars().count() > chars { + return Err(format!( + "{} is {} characters; the radio holds {chars}", + f.display(), + s.chars().count() + )); + } + // ⚠ Refused rather than silently dropped. A call sign quietly stripped of a + // character is worse than a rejected write: it goes on the air. + if let Some(bad) = s.chars().find(|c| !(' '..='~').contains(c)) { + return Err(format!("{} cannot store {bad:?}", f.display())); + } + let mut out = vec![pad; bytes]; + for (i, c) in s.chars().enumerate() { + out[i] = c as u8; + } + Ok(out) +} + +/// One multi-byte field's value as the bytes the radio stores, or `None` when +/// the field is a single byte and belongs to [`encode_one`]. +fn encode_multi(f: &AF, v: &Value) -> Option, String>> { + match f.kind { + AK::Text { .. } => Some(encode_text(f, v)), + AK::Symbol => Some(encode_symbol(f, v)), + AK::LatLon { lon } => Some(encode_latlon(f, v, lon)), + _ => None, + } +} + +/// One form value as the byte the radio stores. +fn encode_one(f: &AF, v: &Value) -> Result { + Ok(match &f.kind { + AK::Text { .. } | AK::Symbol | AK::LatLon { .. } => { + unreachable!("multi-byte kinds go through encode_multi") + } + AK::Bool | AK::Bit { .. } => match v.as_bool() { + Some(b) => u8::from(b), + None => return Err(format!("{} expects true or false, got {v}", f.display())), + }, + AK::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 + } + AK::Ctcss => match v { + 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 a CTCSS tone, got {v}", f.display()))?; + ctcss_labels() + .iter() + .position(|l| l == s) + .ok_or_else(|| format!("{} has no tone {s:?}", f.display()))? as u8 + } + }, + AK::Enum { labels } => match v { + 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(|(_, l)| *l == s) + .map(|(raw, _)| *raw) + .ok_or_else(|| format!("{} has no option {s:?}", f.display()))? + } + }, + }) +} + +/// Patch the profile's fields over the windows the radio currently holds. +/// +/// ⚠ A **patch of the radio's own bytes**, exactly like the `MU` half. Every +/// byte this form does not expose — five position records, five 44-byte status +/// texts, the whole Sky Command tail, and in the config window the operator's +/// VFO settings and the five volatile operating-state bytes — goes back as it +/// came, because it is copied rather than re-encoded. +/// +/// Returns the patched windows and the number of form fields whose value moved. +/// A masked byte counts once per field, which is what an operator changed. +pub(crate) fn patch(base: &[(W, Vec)], settings: &Value) -> Result<(Windows, usize), String> { + for (w, buf) in base { + if buf.len() != w.len() { + return Err(format!("{w:?} is {} bytes, got {}", w.len(), buf.len())); + } + } + let mut out: Vec<(W, Vec)> = base.to_vec(); + let mut changed = 0usize; + for f in TMD710_IMAGE_FIELDS { + let Some(v) = settings.get(f.key) else { continue }; + if v.is_null() { + continue; + } + // ⚠⚠ An empty string means **"not set"**, and the radio's own bytes are + // left exactly as they came. It does NOT mean "erase this field". + // + // This is not tidiness. A profile the operator has never downloaded into + // seeds every text field to `""` (`seedValues` -> `fieldDefault`), and + // `write_radio_settings` sends the profile as SAVED — so treating `""` as + // a value would let a fresh profile blank the operator's call sign, all + // five status texts and all five position records in one write, none of + // which this form had ever shown them. Same shape as #90. + // + // The cost is that the form cannot clear one of these fields, which is + // the far cheaper half of the trade: nothing here has a useful empty + // value on the air, and an unused position slot is already unused. + if v.as_str() == Some("") { + continue; + } + let Some((_, buf)) = out.iter_mut().find(|(w, _)| *w == f.win) else { continue }; + + if let Some(encoded) = encode_multi(f, v) { + let encoded = encoded?; + let n = f.span(); + debug_assert_eq!(encoded.len(), n, "{} encoded {} bytes", f.key, encoded.len()); + if buf[f.off..f.off + n] != encoded[..] { + buf[f.off..f.off + n].copy_from_slice(&encoded); + changed += 1; + } + continue; + } + + let encoded = encode_one(f, v)?; + let before = buf[f.off]; + buf[f.off] = match &f.kind { + AK::Bit { bit } => { + let m = 1u8 << bit; + if encoded != 0 { before | m } else { before & !m } + } + _ => encoded, + }; + if buf[f.off] != before { + changed += 1; + } + } + Ok((out, changed)) +} + +/// The spans that actually differ, coalesced. +/// +/// Gaps shorter than [`STITCH`] are swallowed: two three-byte runs a byte apart +/// are one seven-byte write, and a write costs a whole request either way. Every +/// run is capped at 256, which is the largest block this protocol carries. +const STITCH: usize = 8; + +pub(crate) fn differing_runs(a: &[u8], b: &[u8]) -> Vec<(usize, usize)> { + let mut runs: Vec<(usize, usize)> = Vec::new(); + for i in 0..a.len().min(b.len()) { + if a[i] == b[i] { + continue; + } + match runs.last_mut() { + Some(last) if i - last.1 <= STITCH && i + 1 - last.0 <= 256 => last.1 = i + 1, + _ => runs.push((i, i + 1)), + } + } + runs +} + +/// Read one window. +pub(crate) fn read_window(pm: &mut ProgramMode<'_>, w: W) -> Result, String> { + let mut out = Vec::with_capacity(w.len()); + while out.len() < w.len() { + let want = (w.len() - out.len()).min(256); + let addr = w.base() + out.len() as u16; + let got = pm.read(addr, if want == 256 { 0 } else { want as u8 })?; + if got.len() != want { + return Err(format!( + "reading {w:?} at 0x{addr:04X}: asked for {want} bytes, got {}", + got.len() + )); + } + out.extend_from_slice(&got); + } + Ok(out) +} + +/// Every window this form covers. +pub(crate) fn read_all(pm: &mut ProgramMode<'_>) -> Result { + W::ALL.iter().map(|w| Ok((*w, read_window(pm, *w)?))).collect() +} + +/// Write only what changed, then **read the written spans back**. +/// +/// ⚠ The read-back is not belt and braces. This protocol answers a write with +/// `0x06` whether or not the radio kept it — an APRS block once answered `06` +/// four times running and never changed a byte. +/// +/// ⚠⚠ Only the spans that were **written** are compared, not the whole window. +/// The config window holds five operating-state bytes that drift as the operator +/// walks menus, so a whole-window compare would report a perfectly good write as +/// unverified whenever someone touched the front panel mid-write. +pub(crate) fn write_narrow( + pm: &mut ProgramMode<'_>, + base: &[(W, Vec)], + wanted: &[(W, Vec)], +) -> Result<(Vec, bool), String> { + let mut written = Vec::new(); + let mut verified = true; + for (w, want) in wanted { + let Some((_, have)) = base.iter().find(|(bw, _)| bw == w) else { continue }; + for (start, end) in differing_runs(have, want) { + let addr = w.base() + start as u16; + pm.write(addr, &want[start..end])?; + written.push(format!("0x{addr:04X}+{}", end - start)); + + let back = pm.read(addr, if end - start == 256 { 0 } else { (end - start) as u8 })?; + if back != want[start..end] { + verified = false; + } + } + } + Ok((written, verified)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The two windows as the radio served them, from `progfull-71022.bin`. + fn sample() -> Vec<(W, Vec)> { + let mut cfg = vec![0u8; CONFIG_LEN]; + cfg[0x0E0..0x0E8].copy_from_slice(b"WW8L\xff\xff\xff\xff"); + + let mut aprs = vec![0u8; APRS_BLOCK_LEN]; + aprs[0x000..0x00A].copy_from_slice(b"WW8L-1\0\0\0\0"); + for (off, v) in [ + (0x00C, 0x00), (0x00D, 0x00), (0x00E, 0x00), (0x011, 0x01), (0x016, 0x06), + (0x083, 0x01), (0x084, 0x01), (0x085, 0x00), (0x087, 0x00), (0x167, 0x3F), + (0x16D, 0x02), (0x16F, 0x01), (0x170, 0x01), (0x1DF, 0x01), (0x1E0, 0x0C), + (0x1E9, 0x1C), (0x362, 0x00), + ] { + aprs[off] = v; + } + // The operator's own two position records and three of his status texts, + // byte for byte out of `progfull-54397.bin`. Synthetic values would test + // the parser against itself; these are what the radio holds and what its + // screen was showing while they were read. + aprs[0x01C..0x030].copy_from_slice( + b"THESHACK\x00\x28\x1d\xf0\x00\x00\x68\x37\x48\x03\x01\x00", + ); + aprs[0x030..0x044].copy_from_slice( + b"RancH\xff\xff\xff\x00\x28\x1a\x08\x02\x00\x68\x3b\x96\x00\x01\x00", + ); + let one = b"IN THE SHACK ON 447.275, 3171 DMR, "; + aprs[0x08A..0x08A + one.len()].copy_from_slice(one); + aprs[0x0B5] = 0x01; // status text 1 TX rate = 1/1 + // ⚠ Record 3 as the radio leaves a slot it has NEVER written: FF-filled, + // not NUL-padded. That is the case the decoder's terminator has to cover + // and the reason it stops at the first non-printable byte instead of at + // this field's `pad`. + aprs[0x0E2..0x0E2 + 42].fill(0xFF); + aprs[0x169..0x16B].copy_from_slice(b"/-"); // station icon: a house + aprs[0x350] = 0x03; // 624 RX beep = Message only + vec![(W::Config, cfg), (W::Aprs, aprs)] + } + + /// One field by key, so a codec test names the field the form names. + fn field(key: &str) -> &'static AF { + TMD710_IMAGE_FIELDS + .iter() + .find(|f| f.key == key) + .unwrap_or_else(|| panic!("no field {key:?}")) + } + + fn decoded() -> Map { + let mut m = Map::new(); + decode(&sample(), &mut m); + m + } + + /// ★ The pairing the `new-radio` skill requires, for the image transport: + /// **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 come out of `APRS-MEASURED.md` by one script, and this + /// is what stops them drifting afterwards. + #[test] + fn the_image_table_and_the_profile_schema_describe_the_same_fields() { + let schema: Vec = + serde_json::from_str(crate::seed::TMD710_SETTINGS_SCHEMA).expect("schema parses"); + for f in TMD710_IMAGE_FIELDS { + let e = schema + .iter() + .find(|e| e["key"] == f.key) + .unwrap_or_else(|| panic!("{} has no form field", f.key)); + assert_eq!(e["label"], json!(f.display()), "{}", f.key); + match &f.kind { + AK::Bool | AK::Bit { .. } => assert_eq!(e["type"], "boolean", "{}", f.key), + AK::Text { chars, .. } => { + assert_eq!(e["type"], "text", "{}", f.key); + assert_eq!(e["max_length"], json!(chars), "{}", f.key); + } + // Both render as text, and both are unguessable without the + // example the schema carries as a placeholder — so the + // placeholder is part of what has to agree, not decoration. + AK::Symbol | AK::LatLon { .. } => { + assert_eq!(e["type"], "text", "{}", f.key); + assert!( + e["placeholder"].as_str().is_some_and(|s| !s.is_empty()), + "{} has no placeholder, so its format is unguessable", + f.key + ); + // ⚠ A symbol is exactly two characters and `encode_symbol` + // refuses anything else, so the form must refuse it too — it + // shipped as 12, which meant the operator learned that at write + // time. A coordinate IS parsed, so its width is the canonical + // form's ("W 180 59.999"). + let want = if matches!(f.kind, AK::Symbol) { 2 } else { 12 }; + assert_eq!(e["max_length"], json!(want), "{}", f.key); + } + AK::Uint { min, max } => { + assert_eq!(e["type"], "integer", "{}", f.key); + assert_eq!(e["min"], json!(min), "{}", f.key); + assert_eq!(e["max"], json!(max), "{}", f.key); + } + AK::Ctcss => { + assert_eq!(e["type"], "select", "{}", f.key); + let opts: Vec = e["options"] + .as_array() + .expect("options") + .iter() + .map(|o| o.as_str().expect("string").to_string()) + .collect(); + assert_eq!(opts, ctcss_labels(), "{} options disagree", f.key); + } + AK::Enum { labels } => { + assert_eq!(e["type"], "select", "{}", f.key); + let opts: Vec<&str> = e["options"] + .as_array() + .expect("options") + .iter() + .map(|o| o.as_str().expect("string")) + .collect(); + let mine: Vec<&str> = labels.iter().map(|(_, l)| *l).collect(); + assert_eq!(opts, mine, "{} options disagree", f.key); + } + } + } + let mine: Vec<&str> = TMD710_IMAGE_FIELDS.iter().map(|f| f.key).collect(); + for e in &schema { + if e["type"] == "section" { + continue; + } + let key = e["key"].as_str().expect("key"); + if key.starts_with("aprs-") || key == "power-on-message" { + assert!( + mine.contains(&key), + "the form offers {key:?}, which no table entry writes" + ); + } + } + } + + /// ★ The census, as an assertion rather than a sentence in a doc comment. + #[test] + fn the_census_is_stated_rather_than_implied() { + assert_eq!( + TMD710_IMAGE_FIELDS.len(), + 60, + "controls, not settings — the five-record menus contribute five rows each. \ + If this moved, update the census in the module doc and the ## Owed rows \ + in APRS-MEASURED.md." + ); + + // ⚠ The 66 denominator counts each menu's DISTINCT settings, which is how + // CENSUS.md itemised it: menu 605 contributes NAME / LATITUDE / LONGITUDE + // and menu 608 contributes TEXT / TX RATE — once each, not once per + // record. So the per-record menus have to collapse before the two numbers + // can honestly be compared, and stating that here is what stops "60 + // fields" from being quietly reported as coverage it is not. + let per_record = + TMD710_IMAGE_FIELDS.iter().filter(|f| matches!(f.menu, "605" | "608")).count(); + assert_eq!(per_record, 25, "five records of 3 position and 2 status-text settings"); + let distinct = TMD710_IMAGE_FIELDS.len() - per_record + 5; + assert_eq!( + distinct - 1, + 39, + "39 of the 66 individual settings in the radio's 6xx/7xx menus. The -1 is \ + menu 500's power-on message, which comes from the config window and is \ + not an APRS setting at all." + ); + + let schema: Vec = + serde_json::from_str(crate::seed::TMD710_SETTINGS_SCHEMA).expect("schema parses"); + let fields = schema.iter().filter(|e| e["type"] != "section").count(); + assert_eq!(fields, 60 + 35, "the form is both transports"); + // ★ And it is GROUPED, like every other radio here. The TM-D710 was the + // only one shipping a flat list, which is what made 68 controls + // unreadable. + let sections = schema.len() - fields; + assert!(sections >= 10, "only {sections} section headings for {fields} fields"); + } + + /// ⚠⚠ SmartBeaconing is not on this radio — no menu 630/631/632 exists on + /// the A, and the bytes at `+0x3D7` that match the published defaults were + /// graded against the **G**'s manual. + #[test] + fn nothing_reaches_the_smartbeaconing_bytes() { + for f in TMD710_IMAGE_FIELDS { + assert!( + !(f.win == W::Aprs && (0x3D7..0x3DE).contains(&f.off)), + "{} points into the SmartBeaconing bytes, which no menu on the A reaches", + f.key + ); + } + } + + /// ⚠ Nothing may reach the five operating-state bytes in the config window. + /// They drift as the operator walks menus and are not settings; writing one + /// would fight the radio, and reading one into a profile would make every + /// saved profile differ from every other for no reason. + #[test] + fn nothing_reaches_the_volatile_operating_state() { + for f in TMD710_IMAGE_FIELDS { + if f.win != W::Config { + continue; + } + for off in f.off..f.off + f.span() { + let addr = CONFIG_BASE as usize + off; + assert!( + ![0x0216, 0x0222, 0x0224, 0x0228, 0x022E].contains(&addr), + "{} covers 0x{addr:04X}, which is volatile operating state", + f.key + ); + } + } + } + + /// Keys are what a saved profile stores, and a byte may be shared only when + /// each field owns a distinct bit of it. + #[test] + fn keys_are_unique_and_no_two_fields_own_one_byte() { + let mut keys: Vec<&str> = TMD710_IMAGE_FIELDS.iter().map(|f| f.key).collect(); + let n = keys.len(); + keys.sort_unstable(); + keys.dedup(); + assert_eq!(keys.len(), n, "duplicate settings key"); + + // ⚠ Every byte of every field, not just the first. Twenty of these + // fields are multi-byte records laid out at a fixed stride, so the + // failure this has to catch is a record whose offset arithmetic is off + // and which therefore runs into its neighbour — invisible to a check + // that only compares starting offsets, and it would corrupt the field + // next door on the first write. + let mut owner: std::collections::HashMap<(u16, usize, i16), &str> = + std::collections::HashMap::new(); + for f in TMD710_IMAGE_FIELDS { + let bit = match f.kind { + AK::Bit { bit } => i16::from(bit), + _ => -1, + }; + for off in f.off..f.off + f.span() { + if let Some(prev) = owner.insert((f.win.base(), off, bit), f.key) { + panic!("{} and {} both claim {:?} +0x{off:03X}", f.key, prev, f.win); + } + } + } + assert!( + TMD710_IMAGE_FIELDS.iter().all(|f| f.off + f.span() <= f.win.len()), + "a field runs past the end of its window" + ); + let _ = n; + } + + /// The radio's own bytes decode to what its screen was showing. + #[test] + fn the_real_windows_decode() { + let v = Value::Object(decoded()); + assert_eq!(v["aprs-gps-baud"], json!("4800 bps")); + assert_eq!(v["aprs-beacon-method"], json!("Auto"), "+0x16D was 02"); + assert_eq!(v["aprs-voice-alert"], json!(true), "Tim read VOICE ALERT as On"); + assert_eq!(v["aprs-voice-alert-ctcss"], json!("100.0 Hz"), "+0x1E0 was 0C"); + assert_eq!(v["aprs-ui-check-time"], json!(28), "+0x1E9 is literal seconds"); + for k in ["weather", "mobile", "navitra", "digi", "object", "others"] { + assert_eq!(v[format!("aprs-filter-{k}")], json!(true), "{k}"); + } + } + + /// ★ The two text fields, which pad with **different bytes** — measured, not + /// assumed. Decoding must stop at each field's own pad or the call sign + /// picks up NULs and the power-on message picks up `0xFF`s. + #[test] + fn the_two_text_fields_decode_to_what_the_radio_shows() { + let v = Value::Object(decoded()); + assert_eq!(v["aprs-my-callsign"], json!("WW8L-1"), "10 bytes, NUL-padded"); + assert_eq!(v["power-on-message"], json!("WW8L"), "8 bytes, 0xFF-padded"); + } + + /// And they re-encode with their own pad byte, filling the field. + #[test] + fn text_is_written_back_with_the_pad_byte_the_radio_uses() { + let base = sample(); + let (out, changed) = patch( + &base, + &json!({ "aprs-my-callsign": "W1AW", "power-on-message": "HI" }), + ) + .unwrap(); + assert_eq!(changed, 2); + let aprs = &out.iter().find(|(w, _)| *w == W::Aprs).unwrap().1; + let cfg = &out.iter().find(|(w, _)| *w == W::Config).unwrap().1; + assert_eq!(&aprs[0x000..0x00A], b"W1AW\0\0\0\0\0\0"); + assert_eq!(&cfg[0x0E0..0x0E8], b"HI\xff\xff\xff\xff\xff\xff"); + } + + /// A call sign one character too long is REFUSED, not truncated. A silently + /// shortened call sign goes on the air. + #[test] + fn text_too_long_for_the_field_is_refused() { + let base = sample(); + let err = patch(&base, &json!({ "aprs-my-callsign": "WW8L-12345" })).unwrap_err(); + assert!(err.contains("10 characters") && err.contains("Menu 600"), "{err}"); + let err = patch(&base, &json!({ "power-on-message": "TOO LONG!" })).unwrap_err(); + assert!(err.contains("Menu 500"), "{err}"); + // Non-printable is refused too, rather than written as a control byte. + assert!(patch(&base, &json!({ "power-on-message": "A\u{7}B" })).is_err()); + } + + /// ★ The packet filter as it was actually measured: `04` marked Digi (which + /// killed the "printed list reversed" reading, that predicts Object), and + /// `2A` marked Weather, Navitra and Object. `3F` is invariant under every + /// rival ordering and proves nothing. + #[test] + fn the_packet_filter_bits_are_the_ones_measured_on_the_radio() { + for (mask, on) in [ + (0x01u8, vec!["others"]), + (0x04, vec!["digi"]), + (0x2A, vec!["weather", "navitra", "object"]), + ] { + let mut wins = sample(); + wins.iter_mut().find(|(w, _)| *w == W::Aprs).unwrap().1[0x167] = mask; + let mut m = Map::new(); + decode(&wins, &mut m); + for k in ["weather", "mobile", "navitra", "digi", "object", "others"] { + assert_eq!( + m[&format!("aprs-filter-{k}")], + json!(on.contains(&k)), + "mask {mask:02X}, {k}" + ); + } + } + } + + /// ★ A patch touches the bytes it was asked for and **nothing else** — the + /// position records, the status texts and the config window's VFO settings + /// are not this form's to rewrite. + #[test] + fn patching_leaves_every_byte_the_form_does_not_expose_alone() { + let mut base = sample(); + { + let aprs = &mut base.iter_mut().find(|(w, _)| *w == W::Aprs).unwrap().1; + aprs[0x089] = b'H'; // status text 1 + aprs[0x474] = 0x08; // Sky Command tone + } + let cfg_before = base.iter().find(|(w, _)| *w == W::Config).unwrap().1.clone(); + + let (out, changed) = patch(&base, &json!({ "aprs-temperature-unit": "Celsius" })).unwrap(); + assert_eq!(changed, 1); + let aprs = &out.iter().find(|(w, _)| *w == W::Aprs).unwrap().1; + assert_eq!(aprs[0x362], 0x01); + assert_eq!(aprs[0x089], b'H'); + assert_eq!(aprs[0x474], 0x08); + // ⚠ And the OTHER window was not touched at all. + assert_eq!( + out.iter().find(|(w, _)| *w == W::Config).unwrap().1, + cfg_before, + "an APRS-only change wrote into the config window" + ); + } + + /// Six form fields share one byte, so clearing one must leave the other five. + #[test] + fn a_masked_field_changes_only_its_own_bit() { + let base = sample(); + let (out, changed) = patch(&base, &json!({ "aprs-filter-digi": false })).unwrap(); + assert_eq!(changed, 1); + assert_eq!( + out.iter().find(|(w, _)| *w == W::Aprs).unwrap().1[0x167], + 0x3B, + "only bit 2 should have cleared" + ); + } + + /// A value round-trips through the form's labels and back to the same bytes. + #[test] + fn every_field_round_trips_through_its_labels() { + let base = sample(); + let (out, changed) = patch(&base, &Value::Object(decoded())).unwrap(); + assert_eq!(changed, 0, "decoding and re-encoding must move nothing"); + assert_eq!(out, base); + } + + /// An unlabelled stored value survives the round trip as a number. Refuse it + /// and every later write fails, which is how the TH-D72 became unwritable. + #[test] + fn an_unlabelled_value_round_trips_as_a_number() { + let mut base = sample(); + base.iter_mut().find(|(w, _)| *w == W::Aprs).unwrap().1[0x087] = 64; + let mut m = Map::new(); + decode(&base, &mut m); + assert_eq!(m["aprs-position-comment"], json!(64)); + let (out, _) = patch(&base, &Value::Object(m)).unwrap(); + assert_eq!(out.iter().find(|(w, _)| *w == W::Aprs).unwrap().1[0x087], 64); + } + + #[test] + fn a_value_outside_the_measured_range_is_refused() { + let f = TMD710_IMAGE_FIELDS + .iter() + .find(|f| f.key == "aprs-ui-check-time") + .unwrap(); + let err = encode_one(f, &json!(251)).unwrap_err(); + assert!(err.contains("0..=250") && err.contains("Menu 617"), "{err}"); + let band = TMD710_IMAGE_FIELDS + .iter() + .find(|f| f.key == "aprs-data-band") + .unwrap(); + assert!(encode_one(band, &json!("C band")).is_err()); + } + + /// Runs are coalesced across small gaps and capped at one block. + #[test] + fn differing_runs_coalesce_and_cap() { + let a = vec![0u8; 600]; + let mut b = a.clone(); + b[10] = 1; + b[14] = 1; // 3 bytes clear -> stitched + b[40] = 1; // 25 clear -> its own run + assert_eq!(differing_runs(&a, &b), vec![(10, 15), (40, 41)]); + + let long: Vec = (0..600).map(|_| 1u8).collect(); + let runs = differing_runs(&a, &long); + assert!(runs.iter().all(|(s, e)| e - s <= 256), "{runs:?}"); + assert_eq!(runs.iter().map(|(s, e)| e - s).sum::(), 600); + } + + /// The operator's own records, decoded to what his radio's screen shows and + /// re-encoded to the very same bytes. + #[test] + fn a_position_record_round_trips_through_the_operators_own_bytes() { + let v = Value::Object(decoded()); + assert_eq!(v["aprs-position-1-name"], json!("THESHACK")); + assert_eq!(v["aprs-position-1-lat"], json!("N 40 29.240")); + assert_eq!(v["aprs-position-1-lon"], json!("W 104 55.840")); + // FF-padded name, and a fraction whose low byte is zero — the case a + // big-endian reading would decode as 8.192 minutes instead of 0.520. + assert_eq!(v["aprs-position-2-name"], json!("RancH")); + assert_eq!(v["aprs-position-2-lat"], json!("N 40 26.520")); + assert_eq!(v["aprs-position-2-lon"], json!("W 104 59.150")); + + let base = sample(); + let (out, changed) = patch(&base, &v).expect("re-encode"); + assert_eq!(changed, 0, "decoding and re-encoding the radio's own bytes moved one"); + assert_eq!(out, base); + } + + /// ★ The measurement that named this layout, kept as a test: the exact bytes + /// poked into a slot the operator was not using, and the exact strings his + /// front panel then showed. + /// + /// ⚠ The panel showed `12 34.32` for a stored 321 — two digits of a value + /// held in three. The third digit is real storage, so it is preserved here + /// rather than rounded to what the screen can draw. + #[test] + fn the_poked_positions_decode_to_what_the_front_panel_showed() { + // lat 12/34/321 with hemisphere 1, lon 98/12/654 with hemisphere 1 + assert_eq!(decode_latlon(&[12, 34, 0x41, 0x01, 1], false), "S 12 34.321"); + assert_eq!(decode_latlon(&[98, 12, 0x8E, 0x02, 1], true), "W 098 12.654"); + // and the same slots with hemisphere 0, which read N and E on the screen + assert_eq!(decode_latlon(&[12, 34, 0x41, 0x01, 0], false), "N 12 34.321"); + assert_eq!(decode_latlon(&[98, 12, 0x8E, 0x02, 0], true), "E 098 12.654"); + // the second slot, a different set of digits entirely + assert_eq!(decode_latlon(&[56, 7, 0x7A, 0x03, 1], false), "S 56 07.890"); + assert_eq!(decode_latlon(&[123, 45, 0x9E, 0x02, 0], true), "E 123 45.670"); + } + + #[test] + fn a_position_is_accepted_the_way_people_actually_write_one() { + let lat = field("aprs-position-1-lat"); + let want = vec![40, 29, 0xF0, 0x00, 0]; + for s in ["N 40 29.240", "n 40 29.240", "40 29.240 N", " N 40 29.240 "] { + assert_eq!(encode_latlon(lat, &json!(s), false).expect(s), want, "{s:?}"); + } + // A fraction is DECIMAL, so ".5" is 500 thousandths of a minute, not 5. + assert_eq!( + encode_latlon(lat, &json!("N 40 29.5"), false).expect("short fraction"), + vec![40, 29, 0xF4, 0x01, 0] + ); + assert_eq!( + encode_latlon(lat, &json!("N 40 29"), false).expect("no fraction"), + vec![40, 29, 0, 0, 0] + ); + } + + #[test] + fn a_position_the_radio_cannot_store_is_refused_rather_than_clamped() { + let lat = field("aprs-position-1-lat"); + let lon = field("aprs-position-1-lon"); + for s in ["E 40 29.240", "40 29.240", "N 91 00.000", "N 40 60.000", "N 40 29.2405"] { + assert!(encode_latlon(lat, &json!(s), false).is_err(), "{s:?} was accepted"); + } + // A longitude reaches 180 and takes E/W, not N/S — the two halves do not + // share a range or a letter pair. + assert!(encode_latlon(lon, &json!("N 104 55.840"), true).is_err()); + assert!(encode_latlon(lon, &json!("W 104 55.840"), true).is_ok()); + assert!(encode_latlon(lon, &json!("W 181 00.000"), true).is_err()); + assert!(encode_latlon(lat, &json!("N 104 55.840"), false).is_err(), "past 90"); + } + + /// An unused slot is all zeros on the radio and blank in the form, both ways. + #[test] + fn an_unused_position_slot_is_blank_in_the_form_and_zeros_on_the_radio() { + let v = Value::Object(decoded()); + assert_eq!(v["aprs-position-3-lat"], json!(""), "slot 3 is unused"); + assert_eq!(v["aprs-position-5-lon"], json!("")); + assert_eq!( + encode_latlon(field("aprs-position-3-lat"), &json!(""), false).expect("blank"), + vec![0; 5] + ); + } + + /// A symbol is a table byte AND a code byte; half of one is a different + /// symbol, not a shorter one. + #[test] + fn a_station_icon_is_exactly_two_characters() { + assert_eq!(Value::Object(decoded())["aprs-station-icon"], json!("/-")); + let f = field("aprs-station-icon"); + assert_eq!(encode_symbol(f, &json!("/>")).expect("car"), b"/>".to_vec()); + assert_eq!(encode_symbol(f, &json!("\\K")).expect("factory"), b"\\K".to_vec()); + for s in ["/", "", "/->", "/\n"] { + assert!(encode_symbol(f, &json!(s)).is_err(), "{s:?} was accepted"); + } + } + + /// ⚠ A slot the radio has never written is FF-filled, while one it wrote is + /// NUL-padded — so a decoder that trimmed this field's `pad` would hand the + /// form 42 characters of `ÿ` for an empty status text. + #[test] + fn a_status_text_the_radio_never_wrote_reads_blank_rather_than_ff() { + let v = Value::Object(decoded()); + assert_eq!(v["aprs-status-text-1"], json!("IN THE SHACK ON 447.275, 3171 DMR, ")); + assert_eq!(v["aprs-status-text-3"], json!(""), "an FF-filled record"); + // The rate stores the DENOMINATOR, which is what being off by one record + // hid for two sessions. + assert_eq!(v["aprs-status-text-1-rate"], json!("1/1")); + assert_eq!(v["aprs-status-text-3-rate"], json!("Off")); + // ★ And record 5's rate is `+0x165`, the byte this project misnamed three + // times. It falls out of the array formula rather than being asserted. + assert_eq!(field("aprs-status-text-5-rate").off, 0x165); + assert_eq!(field("aprs-status-text-5").off, 0x13A); + } + + /// ⚠⚠ The rule that stops a fresh profile from wiping the radio. + /// + /// A profile the operator has never downloaded into seeds every text field to + /// `""`, and `write_radio_settings` sends the profile as saved. If `""` were a + /// value, that write would blank the call sign, all five status texts and all + /// five position records in one go — none of which the form had ever shown + /// them. Same shape as #90. + #[test] + fn an_empty_field_leaves_the_radios_own_bytes_alone() { + let base = sample(); + let blank = json!({ + "aprs-my-callsign": "", + "power-on-message": "", + "aprs-status-text-1": "", + "aprs-position-1-name": "", + "aprs-position-1-lat": "", + "aprs-position-1-lon": "", + "aprs-station-icon": "", + }); + let (out, changed) = patch(&base, &blank).expect("a blank profile must not fail"); + assert_eq!(changed, 0, "a blank field counted as a change"); + assert_eq!(out, base, "a blank profile rewrote the radio's own bytes"); + + // And a value that IS set still lands, so this is not a blanket skip. + let (out, changed) = + patch(&base, &json!({ "aprs-status-text-1": "CQ" })).expect("a real value"); + assert_eq!(changed, 1); + let (_, aprs) = out.iter().find(|(w, _)| *w == W::Aprs).expect("aprs window"); + assert_eq!(&aprs[0x08A..0x08C], b"CQ"); + assert!(aprs[0x08C..0x0B4].iter().all(|b| *b == 0), "the rest pads as the radio pads"); + assert_eq!(aprs[0x0B5], 0x01, "the neighbouring TX rate byte was not touched"); + } + + /// A field that ran into its neighbour would corrupt it on the first write, + /// and twenty of these are records at a fixed stride. + #[test] + fn the_record_strides_land_where_the_radio_puts_them() { + for n in 0..5u8 { + let i = n + 1; + let b = 0x01C + usize::from(n) * 20; + assert_eq!(field(&format!("aprs-position-{i}-name")).off, b); + assert_eq!(field(&format!("aprs-position-{i}-lat")).off, b + 9); + assert_eq!(field(&format!("aprs-position-{i}-lon")).off, b + 14); + let s = 0x08A + usize::from(n) * 44; + assert_eq!(field(&format!("aprs-status-text-{i}")).off, s); + assert_eq!(field(&format!("aprs-status-text-{i}-rate")).off, s + 43); + } + } + +} 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..78f9c71 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/memory.rs @@ -0,0 +1,502 @@ +//! 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 +//! +//! The **tone and DCS index tables** were the open question here — the captured +//! lines carry indices (`12`, `08`, `18`) whose meaning nothing had established, +//! and writing a wrong tone to a real repeater is the failure this project has hit +//! most often. They are now measured on the radio ([`super::tone`]) and +//! [`super::encode`] builds a `Memory` from an app channel, verified on hardware. + +// ⚠ 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..058d32d --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/mod.rs @@ -0,0 +1,535 @@ +//! 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. [`program`] therefore reports **where it stopped** and how +//! many memories went out before that, and saves a restorable transcript first. +//! - **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 +//! +//! ⚠ This section said "none yet, deliberately — this driver identifies and +//! nothing else, nothing has ever been written to this radio" for four sessions +//! after all three of these were wired and climbed the hardware ladder. In a +//! driver whose doc comments ARE the record of what is proven, a stale one is +//! worse than none: it tells the next reader the write path is dead. +//! +//! - [`Identifier`](crate::radios::driver::Identifier) — `ID`, read-only. +//! - [`CodeplugProgrammer`] — memories over live mode, one `ME`/`MN` pair at a +//! time, each verified by read-back. **Ladder rungs 1-5 passed.** +//! - [`SettingsReader`]/[`SettingsWriter`] — 95 controls over **two transports in +//! one port session**: `MU` for 42 menu parameters, and the image behind +//! `0M PROGRAM` for the 6xx APRS group, which `MU` cannot reach at all. +//! +//! ⚠⚠ Every one of these opens the radio and so must call [`confirm_model`] — +//! `every_path_that_opens_the_radio_confirms_the_model` asserts it. The G is +//! refused, and it was accepted here by a `contains("TM-D710")` for four +//! sessions while `identify` refused it by name. +//! +//! 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 image_settings; +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); + +pub(crate) 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), + } +} + +/// The model an `ID` reply names, or an error refusing it. +/// +/// ⚠⚠ **Shared by every entry point that opens this radio**, because a guard on +/// one exit is not a guard. `identify` refused the TM-D710**G** from the day it +/// was written — the G has a menu set nobody has measured — while `program` +/// checked `id.contains("TM-D710")`, which `"TM-D710G"` satisfies, and the two +/// settings paths checked nothing at all. So the one radio this driver explicitly +/// refuses to identify could still be sent 1000 `ME` lines and a settings write. +/// See `gate-only-covers-owned-exits`: grep the call sites, not the affordances. +pub(crate) fn check_model(reply: &str) -> Result { + let model = reply + .strip_prefix("ID ") + .ok_or_else(|| format!("unexpected answer to ID: {reply:?}. Nothing was written."))? + .trim() + .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). Nothing was written." + .into(), + ); + } + // ⚠ Exact, not `contains`. `TM-D710` covers the D710A and D710E, which answer + // identically; anything else is refused by name. + if model != "TM-D710" { + return Err(format!( + "expected a TM-D710 on this port, but it says {model:?}. Nothing was written." + )); + } + Ok(model) +} + +/// Ask the radio what it is and refuse anything this driver was not measured on. +pub(crate) fn confirm_model(p: &mut dyn SerialPort) -> Result { + let reply = ask_settling(p, "ID")?; + check_model(&reply) +} + +/// 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 = check_model(&reply)?; + + 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. + /// + /// ★★★ This test used to prove **nothing**: it asserted + /// `reply.strip_prefix("ID ").unwrap() == "TM-D710G"` — the fake's own answer + /// compared against itself, a guard that cannot fail. While it sat here green, + /// `program` accepted the G through `id.contains("TM-D710")` and both settings + /// paths checked no model at all. Now it exercises the real decision. + #[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"); + + let err = check_model(&reply).expect_err("the G must be refused"); + assert!(err.contains("TM-D710G"), "{err}"); + assert!(err.contains("Nothing was written"), "{err}"); + + assert_eq!(check_model("ID TM-D710").expect("the measured radio"), "TM-D710"); + // ⚠ A substring test is exactly how the G got through, so anything that + // merely CONTAINS the model has to be refused too. + for other in + ["ID TM-D710G", "ID TM-D710GE", "ID TM-V71", "ID TM-D700", "ID ", "TM-D710", ""] + { + assert!(check_model(other).is_err(), "{other:?} was accepted"); + } + } + + /// ⚠⚠ Every path that opens this radio must go through [`check_model`]. + /// + /// The bug this guards was not a wrong check, it was a check in only one of + /// four places — `identify` refused the G while `program` and both settings + /// paths did not. A per-path assertion could not have caught that, because the + /// paths that were wrong were the ones nobody had written a test for. So this + /// asserts the *shape*: one `confirm_model` for every `open_port`, and no + /// hand-rolled substring test anywhere. + #[test] + fn every_path_that_opens_the_radio_confirms_the_model() { + for (name, src) in + [("program.rs", include_str!("program.rs")), ("settings.rs", include_str!("settings.rs"))] + { + let count = |needle: &str| { + src.lines() + .filter(|l| l.split("//").next().unwrap_or("").contains(needle)) + .count() + }; + let opens = count("open_port("); + let confirms = count("confirm_model("); + assert!(opens > 0, "{name} no longer opens a port — update this guard"); + assert_eq!( + opens, confirms, + "{name} opens {opens} port(s) but confirms the model {confirms} time(s)" + ); + for line in src.lines() { + let code = line.split("//").next().unwrap_or(""); + assert!( + !code.contains("contains(\"TM-D710\")"), + "{name} rolls its own model check instead of using check_model: {line}" + ); + } + } + } + + /// 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..8992a1b --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/program.rs @@ -0,0 +1,507 @@ +//! 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, open_port, write_memory, write_name}; +use crate::commands::export::{exclusion_reason, expanded_names}; +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; + + // ⚠ Computed for the WHOLE payload up front, because disambiguation is a + // property of the set: `expanded_names` can only tell two `W0QEY` repeaters + // apart by seeing both. Every other radio here does this; the TM-D710 called + // the singular `expanded_name` per channel and so shipped duplicate names to + // the radio while the export preview showed them pulled apart (#26). + // + // Indexed by position in `payload.channels`, including the excluded ones, so + // a skipped channel cannot shift the names of the ones after it. + let names = expanded_names(payload.channels.iter(), model); + + let mut memories = Vec::new(); + let mut skipped = Vec::new(); + // (what the app calls the channel, what the radio will keep) — so the + // truncation warning can compare them instead of guessing from a length. + let mut planned_names: Vec<(String, String)> = Vec::new(); + for (ec, name) in payload.channels.iter().zip(names) { + // 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); + // ⚠ `name` comes from `expanded_names` (PLURAL), which appends + // talkgroup labels AND pulls collisions apart. The singular + // `expanded_name` does NOT disambiguate — a comment here used to + // claim it did — so two W0QEY repeaters both reached the radio as + // `W0QEY Fo` while every export path showed `W0QEY V`/`W0QEY U`. + // + // ⚠ And an empty result is dropped rather than written: a channel + // with no long or short name yields `""` here, which would + // overwrite `encode_name`'s call-sign fallback and send `MN nnn,`. + if !name.is_empty() { + n.text = super::encode::sanitize_name(&name); + planned_names.push((name.clone(), n.text.clone())); + } + 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(&planned_names) { + 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, + }) +} + +/// Whether any name actually lost characters. +/// +/// ⚠ Compares the name against the source it came from. Testing +/// `len() == MAX_NAME` warned about every name that merely *filled* the field — +/// a codeplug whose longest name was exactly `SIMPLEX8` told the operator names +/// "have been shortened" when nothing had been. +fn name_is_truncated(planned: &[(String, String)]) -> bool { + planned.iter().any(|(source, kept)| source != kept) +} + +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. + // + // ⚠ Through `confirm_model`, which is the SAME check `identify` applies. + // This used to be `id.contains("TM-D710")` — and `"TM-D710G"` contains + // `"TM-D710"`, so the one radio `identify` refuses by name could still be + // sent 1000 `ME` lines plus a clear pass from here. + super::confirm_model(&mut *p)?; + + // ── 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()) { + // ⚠ Read back, like every write above. A non-`?` reply is not evidence + // on this protocol: a well-formed line the radio chooses to ignore + // draws nothing at all, and this counted such a slot as cleared — so a + // previous codeplug's channel stayed live while the report said it was + // gone. `d710_clear_slots` verifies the same way. + ask(&mut *p, &format!("ME {slot:03},C")) + .map_err(|e| clear_failed(slot, slots_cleared, &backup_path, &e))?; + let after = ask(&mut *p, &format!("ME {slot:03}")) + .map_err(|e| clear_failed(slot, slots_cleared, &backup_path, &e))?; + if after != EMPTY_REPLY { + return Err(clear_failed( + slot, + slots_cleared, + &backup_path, + &format!("the radio still reports {after}"), + )); + } + 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, and every cleared slot was read back \ + empty. {channels_written} memories written, {slots_cleared} cleared." + ), + }) + } +} + +/// 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() + ) +} + +/// The error the CLEAR pass needs, which is a different situation. +/// +/// ⚠ This used to reuse [`stopped_at`], which told the operator "Programming +/// stopped at memory 500, 62 memories were written before it, the radio is +/// holding a MIXTURE" — when in fact every memory had been written correctly and +/// only a leftover slot from a previous codeplug failed to clear. The right +/// action is different too: nothing needs rewriting, one stale slot needs +/// removing. +fn clear_failed(slot: u16, cleared: usize, backup: &Path, cause: &str) -> String { + format!( + "All memories were written and verified, but clearing leftover memory {slot:03} failed \ + after {cleared} slot(s) had been cleared. The new codeplug IS on the radio; what remains \ + is one or more channels from what was there before, at memory {slot:03} and possibly \ + above it. The radio's original contents were saved to {} first.\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); + } + + /// ★ Hardware ladder step 4, the desk half: **a channel at each edge of the + /// claimed coverage**, held against the seed's own numbers. + /// + /// The edges are not invented here. `d710_rx_band_sweep` measured them on + /// the radio by refusal — this model validates an `ME` write and rejects it + /// whole, so acceptance is a measurement — and a 1350-point sweep put the + /// receiver at **118.000-523.995 MHz**, which is what the seed row says. + /// This test is what stops the two drifting: if someone edits `rx_bands`, + /// a frequency the radio was measured to accept starts being dropped, and + /// an out-of-coverage frequency becomes a **silently empty memory slot** + /// while the app reports success. That has cost three repeaters before. + #[test] + fn every_edge_of_the_measured_coverage_still_gets_a_memory() { + let m = model(); + // Inside: both TX bands' edges, and the receive-only extremes. + for mhz in [118.0, 144.0, 148.0, 224.84, 430.0, 450.0, 523.995] { + let chans = [ec(mhz, "EDGE", None)]; + let plan = plan(&payload(&m, &chans)).unwrap(); + assert_eq!( + plan.memories.len(), + 1, + "{mhz} MHz is inside the measured coverage and was skipped: {:?}", + plan.skipped + ); + } + // Outside, on both sides. A dropped channel must be REPORTED, which is + // the difference between a skipped row and an empty slot nobody notices. + for mhz in [117.995, 524.0] { + let chans = [ec(mhz, "PAST", None)]; + let plan = plan(&payload(&m, &chans)).unwrap(); + assert!(plan.memories.is_empty(), "{mhz} MHz is outside the measured coverage"); + assert_eq!(plan.skipped.len(), 1, "{mhz} MHz was dropped without a reason"); + assert_eq!(plan.skipped[0].name, "PAST"); + } + } + + /// 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..6960ae7 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/settings.rs @@ -0,0 +1,621 @@ +//! The TM-D710's menu settings — **two transports, one form** (#113). +//! +//! One `MU` line carries all **42** menu parameters, and setting any of them +//! means sending all 42 back. That is this module. +//! +//! ⚠⚠ It is not the radio's settings. `MU` reaches 42 of the radio's ~115 +//! menus and **none of the 600-series**, which is the APRS/TNC feature the +//! radio is named for. Those live in the image behind `0M PROGRAM` and are +//! [`super::image_settings`]'s half. A settings read here is both exchanges and a +//! settings write is both again, because an operator's profile is one thing. +//! +//! ★ This radio shipped a correct, fully measured 35-field schema with **no +//! APRS on an APRS radio** for a whole session, because one command's coverage +//! was taken for the radio's settings. The join below is the fix, and the +//! `carries_every_group_the_radio_advertises` test is what stops it recurring. +//! +//! ## 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. +//! +//! ## ★★★ s133: the menu NUMBERS were the G's, and ten were wrong +//! +//! A full audit against the **A** manual's own menu table, asked for after the +//! form went on screen. The values were right; the numbers beside them were not, +//! and a wrong number sends an operator to the wrong menu: +//! +//! | field | said | the A actually has | what that number IS on the A | +//! |---|---|---|---| +//! | VHF AIP | 100 | **103** | 100 is PROGRAMMABLE VFO | +//! | UHF AIP | 101 | **104** | 101 is STEP | +//! | Microphone key lock | 513? | **513** ✓ | the `?` was unearned doubt | +//! | Scan resume method | 907? | **514** | | +//! | Auto power off | 917? | **516** | | +//! | External data band | 918? | **517** | | +//! | External data speed | 919? | **518** | | +//! | SQC output source | 921? | **520** | | +//! | Auto PM store | 922? | **521** | | +//! | Display partition bar | 928 | **527** | | +//! +//! ★ The seven `9xx?` guesses came from a G-oriented source. The A puts all of +//! them in the 5xx AUX group, and the A manual states every one. This is the +//! same defect as the 6xx work — a source written for the **G** used on a +//! non-G radio — and it survived because a menu number is documentation and +//! nothing tests it. `MU`'s parameter order **is** the A's menu order, which is +//! what makes the corrected numbers self-consistent. +//! +//! ## ★ p25 is menu 403 or 406, and it matters which +//! +//! `MU` follows menu order, so p25 sits between p24 (402) and p26 (501). The A +//! manual leaves exactly two three-option menus in that gap, and p25's measured +//! size is 3: +//! +//! - **403 REPEATER MODE** — `CROSS BAND / LOCKED TX:A-BAND / LOCKED TX:B-BAND` +//! - **406 REPEATER ID TX** — `OFF / MORSE / VOICE` +//! +//! ⚠ One front-panel change to menu 403 settles it. Until then it stays +//! unexposed, and the reason is not tidiness: 403 is **cross-band repeat**, so +//! guessing wrong would make the radio transmit on a band the operator never +//! chose. This is the one omitted parameter whose identity is now nearly known +//! and still must not be shipped. +//! +//! ## What `MU` cannot reach at all +//! +//! Twelve menus the A has and this command has no parameter for: **105** +//! S-METER SQUELCH, **110** WEATHER ALERT, **203** GROUP LINK, **504** CONTRAST, +//! **505** display reverse, **515** VISUAL SCAN, **519** PC PORT BAUDRATE, +//! **522** REMOTE ID, **523** REMOTE ANSWER BACK, **524**-**526** DATE/TIME/TIME +//! ZONE, **528** COM PORT BAUDRATE. Plus the per-band and per-memory menus +//! (100-102, 200, 202, 204, 301, 400, 405) which are not profile settings. +//! +//! ★ Several of those **are** in the config window this driver now reads — +//! CHIRP names contrast, PC port baud, visual scan, group link, S-meter squelch, +//! WX alert and repeater mode inside the `0x0200` block. That is a real second +//! tranche and it is **not** shipped: CHIRP's field claims for this radio have +//! never been checked, and its APRS claims were useless while its structure was +//! right. Each would need the factory-default cross-check before it could ship. +//! +//! ## 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::image_settings as imgset; +use super::image::ProgramMode; +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 { + /// `MU`, then the APRS block out of program mode — one port session. + /// + /// ⚠ The image half is **not** best-effort. A read that quietly came back + /// with 35 of 57 fields would look like a success and be exactly the failure + /// this driver already shipped once, so a radio that will not enter program + /// mode is an error naming what to do about it. + fn read_settings(&self, port: &str, _schema_json: &str) -> Result { + let mut p = open_port(port)?; + // ⚠ Identity first even on the READ. The read is harmless, but it feeds a + // profile that `write_settings` later pushes back, so a menu line decoded + // from the wrong Kenwood becomes 42 wrong values on the next write. + super::confirm_model(&mut *p)?; + let line = ask_settling(&mut *p, "MU")?; + let menu = Menu::parse(&line)?; + + let mut pm = ProgramMode::enter(&mut *p)?; + let wins = imgset::read_all(&mut pm)?; + pm.leave()?; + + let Value::Object(mut settings) = decode(&menu) else { + unreachable!("decode returns an object") + }; + imgset::decode(&wins, &mut settings); + + Ok(SettingsCapture { + settings: Value::Object(settings), + // The backup for a live-mode radio is a TRANSCRIPT — and this radio + // has two transports, so the file carries both halves: the menu line + // and the APRS block as hex. Between them they are everything a + // settings write on this radio can clobber. + backup: backup_text(&line, &wins).into_bytes(), + backup_ext: "txt", + }) + } +} + +/// The pre-write backup: one file holding both transports' state. +/// +/// Written so `xxd -r` is not needed to read it and a person can see at a glance +/// which half is which — a backup nobody can interpret is not a backup. +fn backup_text(line: &str, wins: &imgset::Windows) -> String { + let mut out = format!("{line}\n"); + for (w, buf) in wins { + out.push_str(&format!("# {w:?} window, 16 bytes a line\n")); + for (i, chunk) in buf.chunks(16).enumerate() { + let addr = w.base() as usize + i * 16; + let hex: Vec = chunk.iter().map(|b| format!("{b:02X}")).collect(); + out.push_str(&format!("{addr:04X} {}\n", hex.join(" "))); + } + } + out +} + +impl SettingsWriter for super::KenwoodTmD710 { + /// Read both halves, back them up, patch the profile's fields over each, + /// write, and read back to verify — one port session, two transports. + /// + /// ⚠ **Not yet run on a real radio, and now in two ways.** Reading `MU` is + /// proven and writing one parameter at a time is proven by the 42-parameter + /// sweep; reading the APRS block and writing differing runs into it are both + /// proven by `d710_restore_diff`, which restored this radio to pristine. + /// What is **not** proven is (a) a profile's worth of fields patched in one + /// go and (b) **entering program mode after an `MU` exchange on the same + /// open port**. Neither has been in front of the radio. In this repo a + /// working read path has twice hidden a dead write path, so it is stated + /// rather than assumed — this is a hardware-ladder step 5 item. + fn write_settings( + &self, + port: &str, + settings: &Value, + _schema_json: &str, + backup_dir: &Path, + ) -> Result { + let mut p = open_port(port)?; + // ⚠⚠ Identity first. This path writes 42 menu parameters AND raw bytes into + // the image at `0x8100`/`0x0200`, and it had no model check of any kind — + // the only accidental guard was `Menu::parse` counting 42 fields, which + // says nothing about the image half. It is also the path that reaches the + // APRS block, so it is the one that most needed the check. + super::confirm_model(&mut *p)?; + let before = ask_settling(&mut *p, "MU")?; + let base = Menu::parse(&before)?; + + let mut pm = ProgramMode::enter(&mut *p)?; + let img_before = imgset::read_all(&mut pm)?; + pm.leave()?; + + // ⚠ The backup is written before a single byte is sent, and it holds + // BOTH transports — a settings write on this radio can move the menu + // line and the APRS block, so a backup of one half is not a backup. + 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, backup_text(&before, &img_before)).map_err(|e| e.to_string())?; + + let (wanted, mut fields_written) = patch(&base, settings)?; + + // ⚠ Both halves are encoded BEFORE either is written. A profile whose + // APRS half is unencodable must not leave the radio with its menus + // already changed — this is the cheap half of atomicity, and the only + // half a two-transport radio can have. + let (img_wanted, img_changed) = imgset::patch(&img_before, settings)?; + + let failed = write_menu(&mut *p, &wanted)?; + + let (windows_written, img_verified) = if img_changed == 0 { + (Vec::new(), true) + } else { + let mut pm = ProgramMode::enter(&mut *p)?; + let r = imgset::write_narrow(&mut pm, &img_before, &img_wanted); + pm.leave()?; + r? + }; + // ⚠ Counted only once the read-back agrees. Adding this before the write + // let a report say "27 fields written" while `verified` was `false` and the + // note told the operator to treat the 600-series settings as NOT written. + if img_verified { + fields_written += img_changed; + } + + let mut notes: Vec = Vec::new(); + if !failed.is_empty() { + let names: Vec = failed + .iter() + .map(|(p, mine, theirs)| format!("p{p}: sent {mine}, radio kept {theirs}")) + .collect(); + notes.push(format!( + "{} menu parameter(s) did not take: {}", + failed.len(), + names.join("; ") + )); + } + if !img_verified { + notes.push( + "an image window read back different from what was written. On this protocol \ + the radio answers 0x06 whether or not it kept a write, so the read-back is \ + the only evidence — treat the 600-series settings as NOT written." + .to_string(), + ); + } + + Ok(SettingsWriteReport { + fields_written, + // `write_menu` re-reads the line and diffs it, and + // `write_block_narrow` re-reads the block — both are real + // read-backs and not a buffer compared with itself, the mistake + // found in the TH-D72's review. + verified: Some(failed.is_empty() && img_verified), + note: (!notes.is_empty()).then(|| notes.join(" ")), + backup_path: backup_path.to_string_lossy().into_owned(), + expected_path: None, + windows_written, + }) + } +} + +#[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"); + // The form is both transports and `super::image_settings` asserts the + // same pairing over its half. + // + // ⚠ Partition on the keys that table actually owns, **not on a name + // prefix**. Menu 500's power-on message is an image field and is not + // called `aprs-*`, so a prefix test leaves it on this side and both + // halves claim it. The generator had the identical bug. + let theirs: Vec<&str> = crate::radios::kenwood_tmd710::image_settings::TMD710_IMAGE_FIELDS + .iter() + .map(|f| f.key) + .collect(); + let mine: Vec<&serde_json::Value> = schema + .iter() + .filter(|e| e["type"] != "section") + .filter(|e| !theirs.contains(&e["key"].as_str().unwrap_or_default())) + .collect(); + let extra: Vec<&str> = mine + .iter() + .map(|e| e["key"].as_str().unwrap_or_default()) + .filter(|k| !TMD710_SETTINGS_FIELDS.iter().any(|f| f.key == *k)) + .collect(); + assert_eq!(mine.len(), TMD710_SETTINGS_FIELDS.len(), "extra: {extra:?}"); + + 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"], "select", "{}", 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 &mine { + 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_image_table.rs b/src-tauri/src/radios/kenwood_tmd710/tmd710_image_table.rs new file mode 100644 index 0000000..9e37c8a --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/tmd710_image_table.rs @@ -0,0 +1,77 @@ +// GENERATED by scratchpad/kenwood_tmd710/gen_tmd710_image.py from +// scratchpad/kenwood_tmd710/APRS-MEASURED.md. Do not hand-edit; regenerate. +// +// `off` is a byte offset from `win`'s base. TWO windows: the APRS/TNC +// block at 0x8100 and the PM0 config block at 0x0200, which is where +// menu 500's POWER ON MESSAGE lives. None of this is reachable by `MU` +// -- it is all behind `0M PROGRAM`, which is why this radio needs two +// transports for one settings form. +// +// ⚠ Only rows whose whole encoding is ANCHORED are here: every index is +// either measured on the radio, is the factory default confirmed against +// the A manual, or is the single remaining printed entry. This radio's +// manual has printed a short option list twice, so "the rest of the +// manual's list, in order" is not an anchor. The rest are listed under +// `## Owed` in the sheet, each with the check that would settle it. +pub(crate) const TMD710_IMAGE_FIELDS: &[AF] = &[ + AF { key: "aprs-data-band", label: "Data band", menu: "601", win: W::Aprs, off: 0x00C, kind: AK::Enum { labels: &[(0, "A band"), (1, "B band"), (2, "TX A / RX B"), (3, "RX A / TX B")] } }, // measured 1,3; factory 0 + AF { key: "aprs-data-speed", label: "Packet data speed", menu: "601", win: W::Aprs, off: 0x00D, kind: AK::Enum { labels: &[(0, "1200 bps"), (1, "9600 bps")] } }, // measured 0,1 + AF { key: "aprs-dcd-sense", label: "DCD sense", menu: "601", win: W::Aprs, off: 0x00E, kind: AK::Enum { labels: &[(0, "D or RxD band"), (1, "Both band"), (2, "Ignore DCD")] } }, // measured 1,2; factory 0 + AF { key: "aprs-gps-baud", label: "GPS port baud rate", menu: "602", win: W::Aprs, off: 0x011, kind: AK::Enum { labels: &[(0, "2400 bps"), (1, "4800 bps"), (2, "9600 bps")] } }, // measured 1,2; factory 1 + AF { key: "aprs-waypoint-format", label: "Waypoint format", menu: "603", win: W::Aprs, off: 0x015, kind: AK::Enum { labels: &[(0, "NMEA"), (1, "Magellan"), (2, "Kenwood")] } }, // measured 1,2; factory 0 + AF { key: "aprs-waypoint-name", label: "Waypoint name length", menu: "603", win: W::Aprs, off: 0x016, kind: AK::Enum { labels: &[(6, "6-char"), (7, "7-char"), (8, "8-char"), (9, "9-char")] } }, // measured 6,9 — stores the literal + AF { key: "aprs-speed-info", label: "Speed information", menu: "606", win: W::Aprs, off: 0x083, kind: AK::Bool }, // measured 0,1 + AF { key: "aprs-altitude-info", label: "Altitude information", menu: "606", win: W::Aprs, off: 0x084, kind: AK::Bool }, // measured 0,1 + AF { key: "aprs-position-ambiguity", label: "Position ambiguity", menu: "606", win: W::Aprs, off: 0x085, kind: AK::Enum { labels: &[(0, "Off"), (1, "1 digit"), (2, "2 digits"), (3, "3 digits"), (4, "4 digits")] } }, // measured 2,4; factory 0 — index is the digit count + AF { key: "aprs-position-comment", label: "Position comment", menu: "607", win: W::Aprs, off: 0x087, kind: AK::Enum { labels: &[(0, "Off duty"), (1, "Enroute"), (2, "In service"), (3, "Returning"), (4, "Committed"), (5, "Special"), (6, "Priority"), (7, "Custom 0"), (8, "Custom 1"), (9, "Custom 2"), (10, "Custom 3"), (11, "Custom 4"), (12, "Custom 5"), (13, "Custom 6"), (14, "Emergency!")] } }, // measured 6,9; factory 0 — the APRS spec's own numbering + AF { key: "aprs-position-limit", label: "Position limit", menu: "609", win: W::Aprs, off: 0x166, kind: AK::Enum { labels: &[(0, "Off"), (1, "10"), (2, "20"), (3, "30"), (4, "40"), (5, "50"), (6, "60"), (7, "70"), (8, "80"), (9, "90")] } }, // measured 1,3,9; factory 0 — a linear index x 10 confirmed at three points + AF { key: "aprs-filter-weather", label: "Packet filter: Weather", menu: "609", win: W::Aprs, off: 0x167, kind: AK::Bit { bit: 5 } }, // mask measured with 0x01, 0x04, 0x2A + AF { key: "aprs-filter-mobile", label: "Packet filter: Mobile", menu: "609", win: W::Aprs, off: 0x167, kind: AK::Bit { bit: 4 } }, // mask measured with 0x01, 0x04, 0x2A + AF { key: "aprs-filter-navitra", label: "Packet filter: Navitra", menu: "609", win: W::Aprs, off: 0x167, kind: AK::Bit { bit: 3 } }, // mask measured with 0x01, 0x04, 0x2A + AF { key: "aprs-filter-digi", label: "Packet filter: Digipeater", menu: "609", win: W::Aprs, off: 0x167, kind: AK::Bit { bit: 2 } }, // mask measured with 0x01, 0x04, 0x2A + AF { key: "aprs-filter-object", label: "Packet filter: Object", menu: "609", win: W::Aprs, off: 0x167, kind: AK::Bit { bit: 1 } }, // mask measured with 0x01, 0x04, 0x2A + AF { key: "aprs-filter-others", label: "Packet filter: Others", menu: "609", win: W::Aprs, off: 0x167, kind: AK::Bit { bit: 0 } }, // mask measured with 0x01, 0x04, 0x2A + AF { key: "aprs-beacon-method", label: "Beacon TX method", menu: "611", win: W::Aprs, off: 0x16D, kind: AK::Enum { labels: &[(0, "Manual"), (1, "PTT"), (2, "Auto")] } }, // measured 2; factory 0 + AF { key: "aprs-decay-algorithm", label: "Decay algorithm", menu: "611", win: W::Aprs, off: 0x16F, kind: AK::Bool }, // measured 0,1 + AF { key: "aprs-proportional-pathing", label: "Proportional pathing", menu: "611", win: W::Aprs, off: 0x170, kind: AK::Bool }, // measured 0,1 + AF { key: "aprs-voice-alert", label: "Voice alert", menu: "614", win: W::Aprs, off: 0x1DF, kind: AK::Bool }, // factory 0 = Off; live 1 read as On on the radio's screen + AF { key: "aprs-voice-alert-ctcss", label: "Voice alert CTCSS frequency", menu: "614", win: W::Aprs, off: 0x1E0, kind: AK::Ctcss }, // measured 8 = 88.5, 12 = 100.0 — 0-based into the driver's own 42-tone table + AF { key: "aprs-ui-check-time", label: "UI check time (seconds)", menu: "617", win: W::Aprs, off: 0x1E9, kind: AK::Uint { min: 0, max: 250 } }, // measured 28, 100 — the literal + AF { key: "aprs-temperature-unit", label: "Temperature unit", menu: "626", win: W::Aprs, off: 0x362, kind: AK::Enum { labels: &[(0, "Fahrenheit"), (1, "Celsius")] } }, // measured 0,1 + AF { key: "power-on-message", label: "Power-on message", menu: "500", win: W::Config, off: 0x0E0, kind: AK::Text { bytes: 8, chars: 8, pad: 0xFF } }, // MEASURED both ways: live holds `WW8L` + four `FF`, every PM copy holds the factory `HELLO !!` filling all eight — ⚠ pads with FF, NOT 00 like the call sign + AF { key: "aprs-my-callsign", label: "My call sign", menu: "600", win: W::Aprs, off: 0x000, kind: AK::Text { bytes: 10, chars: 9, pad: 0x00 } }, // MEASURED off the radio's own writing: live holds `WW8L-1` + four `00`, every PM copy holds `NOCALL` + `00` — ASCII, NUL-padded + AF { key: "aprs-beacon-type", label: "Beacon type", menu: "600", win: W::Aprs, off: 0x00A, kind: AK::Enum { labels: &[(0, "APRS"), (1, "Navitra")] } }, // measured 1; factory 0; index 2 renders out-of-range text, so the list is two + AF { key: "aprs-tx-delay", label: "TX delay", menu: "601", win: W::Aprs, off: 0x00F, kind: AK::Enum { labels: &[(0, "100 ms"), (1, "150 ms"), (2, "200 ms"), (3, "300 ms"), (4, "400 ms"), (5, "500 ms"), (6, "750 ms"), (7, "1000 ms")] } }, // measured 0,2,3,5,7 each at its printed position; 1,4,6 bracketed between measured neighbours; 7 is the last printed entry so the list cannot be longer + AF { key: "aprs-gps-input", label: "GPS data input", menu: "602", win: W::Aprs, off: 0x012, kind: AK::Enum { labels: &[(0, "Off"), (1, "GPS"), (2, "Weather (PeetBros)"), (3, "Weather (Davis)")] } }, // all four measured — ⚠ 2 and 3 are the MANUAL'S ORDER REVERSED + AF { key: "aprs-gps-output", label: "GPS data output", menu: "602", win: W::Aprs, off: 0x013, kind: AK::Enum { labels: &[(0, "Off"), (1, "Waypoint"), (2, "DGPS")] } }, // measured 1,2; factory 0 + AF { key: "aprs-waypoint-output", label: "Waypoint output", menu: "603", win: W::Aprs, off: 0x017, kind: AK::Enum { labels: &[(0, "All"), (1, "Local"), (2, "Filtered")] } }, // measured 1,2; factory 0 + AF { key: "aprs-beacon-interval", label: "Beacon initial interval", menu: "611", win: W::Aprs, off: 0x16E, kind: AK::Enum { labels: &[(0, "0.2 min"), (1, "0.5 min"), (2, "1 min"), (3, "2 min"), (4, "3 min"), (5, "5 min"), (6, "10 min"), (7, "20 min"), (8, "30 min"), (9, "60 min")] } }, // measured 3,4,5,9 — index 3 = 2 min is the entry the manual omits entirely + AF { key: "aprs-interrupt-display-area", label: "Interrupt display area", menu: "625", win: W::Aprs, off: 0x35C, kind: AK::Enum { labels: &[(0, "Off"), (1, "Half"), (2, "Entire"), (3, "Entire always")] } }, // ALL FOUR measured; index 4 renders garbage. ⚠ the manual prints three entries and calls the default ENTIRE; the radio has four and ships on the fourth + AF { key: "aprs-position-1-name", label: "My position 1 name", menu: "605", win: W::Aprs, off: 0x01C, kind: AK::Text { bytes: 8, chars: 8, pad: 0xFF } }, // MEASURED off the radio's own writing: `THESHACK` fills all eight, `RancH` is followed by three `FF` + AF { key: "aprs-position-1-lat", label: "My position 1 latitude", menu: "605", win: W::Aprs, off: 0x025, kind: AK::LatLon { lon: false } }, // layout measured on the radio: poked 12/34/321 read back as `12 34.32`, and BOTH hemispheres read on the screen (0=N, 1=S) + AF { key: "aprs-position-1-lon", label: "My position 1 longitude", menu: "605", win: W::Aprs, off: 0x02A, kind: AK::LatLon { lon: true } }, // poked 98/12/654 read back as `098 12.65`, and BOTH hemispheres read on the screen (0=E, 1=W); a second slot poked 56/7/890 and 123/45/670 confirmed it + AF { key: "aprs-position-2-name", label: "My position 2 name", menu: "605", win: W::Aprs, off: 0x030, kind: AK::Text { bytes: 8, chars: 8, pad: 0xFF } }, // MEASURED off the radio's own writing: `THESHACK` fills all eight, `RancH` is followed by three `FF` — same record, +20 bytes per slot + AF { key: "aprs-position-2-lat", label: "My position 2 latitude", menu: "605", win: W::Aprs, off: 0x039, kind: AK::LatLon { lon: false } }, // layout measured on the radio: poked 12/34/321 read back as `12 34.32`, and BOTH hemispheres read on the screen (0=N, 1=S) — same record, +20 bytes per slot + AF { key: "aprs-position-2-lon", label: "My position 2 longitude", menu: "605", win: W::Aprs, off: 0x03E, kind: AK::LatLon { lon: true } }, // poked 98/12/654 read back as `098 12.65`, and BOTH hemispheres read on the screen (0=E, 1=W); a second slot poked 56/7/890 and 123/45/670 confirmed it — same record, +20 bytes per slot + AF { key: "aprs-position-3-name", label: "My position 3 name", menu: "605", win: W::Aprs, off: 0x044, kind: AK::Text { bytes: 8, chars: 8, pad: 0xFF } }, // MEASURED off the radio's own writing: `THESHACK` fills all eight, `RancH` is followed by three `FF` — same record, +20 bytes per slot + AF { key: "aprs-position-3-lat", label: "My position 3 latitude", menu: "605", win: W::Aprs, off: 0x04D, kind: AK::LatLon { lon: false } }, // layout measured on the radio: poked 12/34/321 read back as `12 34.32`, and BOTH hemispheres read on the screen (0=N, 1=S) — same record, +20 bytes per slot + AF { key: "aprs-position-3-lon", label: "My position 3 longitude", menu: "605", win: W::Aprs, off: 0x052, kind: AK::LatLon { lon: true } }, // poked 98/12/654 read back as `098 12.65`, and BOTH hemispheres read on the screen (0=E, 1=W); a second slot poked 56/7/890 and 123/45/670 confirmed it — same record, +20 bytes per slot + AF { key: "aprs-position-4-name", label: "My position 4 name", menu: "605", win: W::Aprs, off: 0x058, kind: AK::Text { bytes: 8, chars: 8, pad: 0xFF } }, // MEASURED off the radio's own writing: `THESHACK` fills all eight, `RancH` is followed by three `FF` — same record, +20 bytes per slot + AF { key: "aprs-position-4-lat", label: "My position 4 latitude", menu: "605", win: W::Aprs, off: 0x061, kind: AK::LatLon { lon: false } }, // layout measured on the radio: poked 12/34/321 read back as `12 34.32`, and BOTH hemispheres read on the screen (0=N, 1=S) — same record, +20 bytes per slot + AF { key: "aprs-position-4-lon", label: "My position 4 longitude", menu: "605", win: W::Aprs, off: 0x066, kind: AK::LatLon { lon: true } }, // poked 98/12/654 read back as `098 12.65`, and BOTH hemispheres read on the screen (0=E, 1=W); a second slot poked 56/7/890 and 123/45/670 confirmed it — same record, +20 bytes per slot + AF { key: "aprs-position-5-name", label: "My position 5 name", menu: "605", win: W::Aprs, off: 0x06C, kind: AK::Text { bytes: 8, chars: 8, pad: 0xFF } }, // MEASURED off the radio's own writing: `THESHACK` fills all eight, `RancH` is followed by three `FF` — same record, +20 bytes per slot + AF { key: "aprs-position-5-lat", label: "My position 5 latitude", menu: "605", win: W::Aprs, off: 0x075, kind: AK::LatLon { lon: false } }, // layout measured on the radio: poked 12/34/321 read back as `12 34.32`, and BOTH hemispheres read on the screen (0=N, 1=S) — same record, +20 bytes per slot + AF { key: "aprs-position-5-lon", label: "My position 5 longitude", menu: "605", win: W::Aprs, off: 0x07A, kind: AK::LatLon { lon: true } }, // poked 98/12/654 read back as `098 12.65`, and BOTH hemispheres read on the screen (0=E, 1=W); a second slot poked 56/7/890 and 123/45/670 confirmed it — same record, +20 bytes per slot + AF { key: "aprs-status-text-1", label: "Status text 1", menu: "608", win: W::Aprs, off: 0x08A, kind: AK::Text { bytes: 42, chars: 42, pad: 0x00 } }, // padding MEASURED off three texts the radio itself wrote (35, 40 and 25 characters, each NUL-filled to the field width); an untouched record is `FF`-filled instead, so the decoder stops at the first non-printable byte either way + AF { key: "aprs-status-text-1-rate", label: "Status text 1 TX rate", menu: "608", win: W::Aprs, off: 0x0B5, kind: AK::Enum { labels: &[(0, "Off"), (1, "1/1"), (2, "1/2"), (3, "1/3"), (4, "1/4"), (5, "1/5"), (6, "1/6"), (7, "1/7"), (8, "1/8")] } }, // stores the DENOMINATOR, not a list position: poked 03 on record 3 read `1/3` while record 4's untouched 00 read `Off`; s131 independently read 05 as `1/5`. ⚠ this is what the record boundary being off by one had hidden + AF { key: "aprs-status-text-2", label: "Status text 2", menu: "608", win: W::Aprs, off: 0x0B6, kind: AK::Text { bytes: 42, chars: 42, pad: 0x00 } }, // padding MEASURED off three texts the radio itself wrote (35, 40 and 25 characters, each NUL-filled to the field width); an untouched record is `FF`-filled instead, so the decoder stops at the first non-printable byte either way — same record, +44 bytes per slot + AF { key: "aprs-status-text-2-rate", label: "Status text 2 TX rate", menu: "608", win: W::Aprs, off: 0x0E1, kind: AK::Enum { labels: &[(0, "Off"), (1, "1/1"), (2, "1/2"), (3, "1/3"), (4, "1/4"), (5, "1/5"), (6, "1/6"), (7, "1/7"), (8, "1/8")] } }, // stores the DENOMINATOR, not a list position: poked 03 on record 3 read `1/3` while record 4's untouched 00 read `Off`; s131 independently read 05 as `1/5`. ⚠ this is what the record boundary being off by one had hidden — same record, +44 bytes per slot + AF { key: "aprs-status-text-3", label: "Status text 3", menu: "608", win: W::Aprs, off: 0x0E2, kind: AK::Text { bytes: 42, chars: 42, pad: 0x00 } }, // padding MEASURED off three texts the radio itself wrote (35, 40 and 25 characters, each NUL-filled to the field width); an untouched record is `FF`-filled instead, so the decoder stops at the first non-printable byte either way — same record, +44 bytes per slot + AF { key: "aprs-status-text-3-rate", label: "Status text 3 TX rate", menu: "608", win: W::Aprs, off: 0x10D, kind: AK::Enum { labels: &[(0, "Off"), (1, "1/1"), (2, "1/2"), (3, "1/3"), (4, "1/4"), (5, "1/5"), (6, "1/6"), (7, "1/7"), (8, "1/8")] } }, // stores the DENOMINATOR, not a list position: poked 03 on record 3 read `1/3` while record 4's untouched 00 read `Off`; s131 independently read 05 as `1/5`. ⚠ this is what the record boundary being off by one had hidden — same record, +44 bytes per slot + AF { key: "aprs-status-text-4", label: "Status text 4", menu: "608", win: W::Aprs, off: 0x10E, kind: AK::Text { bytes: 42, chars: 42, pad: 0x00 } }, // padding MEASURED off three texts the radio itself wrote (35, 40 and 25 characters, each NUL-filled to the field width); an untouched record is `FF`-filled instead, so the decoder stops at the first non-printable byte either way — same record, +44 bytes per slot + AF { key: "aprs-status-text-4-rate", label: "Status text 4 TX rate", menu: "608", win: W::Aprs, off: 0x139, kind: AK::Enum { labels: &[(0, "Off"), (1, "1/1"), (2, "1/2"), (3, "1/3"), (4, "1/4"), (5, "1/5"), (6, "1/6"), (7, "1/7"), (8, "1/8")] } }, // stores the DENOMINATOR, not a list position: poked 03 on record 3 read `1/3` while record 4's untouched 00 read `Off`; s131 independently read 05 as `1/5`. ⚠ this is what the record boundary being off by one had hidden — same record, +44 bytes per slot + AF { key: "aprs-status-text-5", label: "Status text 5", menu: "608", win: W::Aprs, off: 0x13A, kind: AK::Text { bytes: 42, chars: 42, pad: 0x00 } }, // padding MEASURED off three texts the radio itself wrote (35, 40 and 25 characters, each NUL-filled to the field width); an untouched record is `FF`-filled instead, so the decoder stops at the first non-printable byte either way — same record, +44 bytes per slot + AF { key: "aprs-status-text-5-rate", label: "Status text 5 TX rate", menu: "608", win: W::Aprs, off: 0x165, kind: AK::Enum { labels: &[(0, "Off"), (1, "1/1"), (2, "1/2"), (3, "1/3"), (4, "1/4"), (5, "1/5"), (6, "1/6"), (7, "1/7"), (8, "1/8")] } }, // stores the DENOMINATOR, not a list position: poked 03 on record 3 read `1/3` while record 4's untouched 00 read `Off`; s131 independently read 05 as `1/5`. ⚠ this is what the record boundary being off by one had hidden — same record, +44 bytes per slot + AF { key: "aprs-station-icon", label: "Station icon", menu: "610", win: W::Aprs, off: 0x169, kind: AK::Symbol }, // the raw APRS symbol table + code, not an index: `2F 2D` = `/-` reads House and a poked `2F 3E` = `/>` read Car. Two anchors, and both agree with the PUBLISHED APRS symbol spec rather than with any option list in the manual + AF { key: "aprs-rx-beep", label: "RX beep", menu: "624", win: W::Aprs, off: 0x350, kind: AK::Enum { labels: &[(0, "All"), (1, "All new"), (2, "Mine"), (3, "Message only"), (4, "Off")] } }, // ALL FIVE indices measured. ⚠ the manual's list REVERSED; index 4 previously read `ALL`, which was a stale-menu artifact — re-poked and read after leaving and re-entering menu 624 it reads `Off` +]; 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..f0f5f20 --- /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("103"), kind: TK::Bool }, // MU p11; size MEASURED (2); inferred + TF { key: "uhf-aip", label: "UHF AIP", mu: 11, menu: Some("104"), 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("514"), 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("516"), 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("517"), kind: TK::Enum { labels: &[(0, "A band"), (1, "B band"), (2, "TX A-RX B"), (3, "RX A-TX B")] } }, // MU p38; size MEASURED (4); inferred (the manual's 4th value is `RX:A-BAND TX:B-BAND`; the old label said `TX B-RX A`, the same thing said backwards) + TF { key: "external-data-speed", label: "External data speed", mu: 38, menu: Some("518"), 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("520"), 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("521"), kind: TK::Bool }, // MU p41; size MEASURED (2); inferred + TF { key: "display-partition-bar", label: "Display partition bar", mu: 41, menu: Some("527"), kind: TK::Bool }, // MU p42; size MEASURED (2); inferred (the A manual names Menu **527**; this row said 928, which is the G's numbering) +]; 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..a2e5c49 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710/tone.rs @@ -0,0 +1,224 @@ +//! 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. +//! +//! ## ⚠ Why `allow(dead_code)` is still here +//! +//! This said "nothing here is called yet — the Phase 2 encoder does not exist" +//! for several sessions after [`super::encode`] shipped and climbed the hardware +//! ladder. It does exist, and it uses [`TONES_DHZ`] and [`DCS_CODES`]. +//! +//! What is still test-only is the **reverse** direction — [`tone_hz`] and +//! [`dcs_code`], which turn an index back into a value and are used by the +//! measurement harness and by tests, not by any shipped path. That is why the +//! attribute is conditional on `not(test)` rather than unconditional, and 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. If a `never used` appears here for +//! anything the encoder needs, that is the bug, not the attribute. 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..f8449a6 --- /dev/null +++ b/src-tauri/src/radios/kenwood_tmd710_probe.rs @@ -0,0 +1,2910 @@ +//! 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. +//! +//! ## The APRS/TNC settings block, as measured on the radio +//! +//! `scratchpad/` is gitignored, so the working sheet +//! (`scratchpad/kenwood_tmd710/APRS-BLOCK.md`) does not survive this machine. +//! This is the committed copy of what the radio itself has confirmed. +//! +//! The 600-series menus are **not** reachable by `MU` — they live in the image +//! behind `0M PROGRAM`, in six `0x480`-byte blocks at `0x8100 + n * 0x480` +//! (live, then PM1-5). Offsets below are relative to the **live** block base. +//! +//! | offset | field | menu | values measured | +//! |---|---|---|---| +//! | `+0x000` | My call sign, 10 bytes | 600 | | +//! | `+0x00A` | Beacon type | 600 | `00` APRS, `01` NAVITRA | +//! | `+0x00C` | Data band | 601 | `01` = `B Band` | +//! | `+0x00D` | Packet transfer rate | 601 | `01` = `9600 bps` | +//! | `+0x00E` | DCD sense | 601 | `02` = `IGNORE DCD` | +//! | `+0x00F` | TX delay | 601 | `00` = `100ms`, `03` = `300ms` | +//! | `+0x01C` | My position channels 1-5, 20 bytes each | 605 | | +//! | `+0x083` | Speed information | 606 | `00` off, `01` on | +//! | `+0x084` | Altitude information | 606 | `00` off, `01` on | +//! | `+0x085` | Position ambiguity | 606 | `04` = `4-DIGIT` | +//! | `+0x087` | Position comment | 607 | `09` = `CUSTOM 2` | +//! | `+0x089` | Status text 1-5, 44 bytes each | 608 | | +//! | `+0x0B5` | Status text TX rate | 608 | `01`=`1/1`, `02`=`1/2`, `05`=`1/5` | +//! | `+0x169` | Station icon, 2 raw ASCII bytes | 610 | `/-` shows as a house | +//! | `+0x16D` | Packet transmit method | 611 | `02` = `AUTO` | +//! | `+0x16E` | Beacon TX interval | 611 | `04`=`3 min`, `05`=`5 min`, `09`=`60 min` | +//! | `+0x16F` | Decay algorithm | 611 | `00` = off | +//! | `+0x460` | SkyCommand commander / transporter call signs | 700/701 | | +//! | `+0x474` | SkyCommand tone | 702 | `08` = `88.5 Hz`, the manual's default | +//! +//! ⚠⚠ **The D710A has no SmartBeaconing.** The 7 bytes at `+0x3D7` do match the +//! published SmartBeaconing defaults, but "SmartBeaconing" appears **zero** +//! times in the TM-D710**A** manual and there is no menu 630/631/632 on this +//! radio. They were graded against the **G**'s manual — a grade that was never +//! valid here. No menu reaches them; they must not ship as settings. +//! +//! ## The menu census (`new-radio` step 1, for this transport) +//! +//! `MU` reaches 42 of the radio's ~115 menus and **none** of the 6xx group, +//! which is the feature the radio is named for. The 6xx/7xx range is +//! **34 menu numbers and 66 individual settings**; ~25 are located and 15 are +//! confirmed on the radio. +//! +//! ★★★ **The factory-default cross-check.** The PM blocks are untouched +//! defaults and the A manual states the default of every menu, so a candidate +//! offset whose PM byte contradicts the manual is refuted with no radio time — +//! and since most defaults are `0`, a **non-zero** default is a rare anchor. It +//! is 5-for-5 on already-measured fields (`+0x00F`=`02`=`200 ms`, `+0x083` and +//! `+0x084`=`01`=`ON`, `+0x169`=`\K`=the KENWOOD icon, `+0x474`=`08`=`88.5 Hz`). +//! +//! ## The image map, closed at the desk (s131) +//! +//! CHIRP's **non-G** class (`TM-D710_CloneMode`) declares a structural map that +//! this project had written off wholesale. Its APRS claims are indeed useless, +//! but its **structure** is right and was never tested: +//! +//! | region | what | check | +//! |---|---|---| +//! | `0x0200`+`0x0400`..`0x0C00` | config block 0 and PM1-5, 394 bytes each | | +//! | `0x0E00`-`0x160B` | channel map | | +//! | `0x1700`-`0x575F` | memory channels, 16 bytes each | ch 39 is all `FF`; the radio holds 39 | +//! | `0x5800`-`0x77DF` | channel names, 8 bytes each | `W0UPS`/`W0LRA`/`W0TX`, matching `MN` | +//! | `0x77E0`-`0x782F` | weather channel names | **`WX 1 … WX 8`** ✓ | +//! | `0x7DA0` / `0x7DF0` | PM names / MCP comment | zeros here, untouched | +//! | `0x8100`-`0x9BFF` | the six APRS blocks | | +//! +//! ★★ `0x5800 + 1020*8 = 0x77E0` exactly, and `0x8100 + 6*0x480 = 0x9C00` +//! exactly — the last structure ends on the last address [`read_plan`] asks +//! for. Four regions previously logged as "unidentified" were simply **unused +//! memory and name slots**: the array extents had been read off the *populated* +//! part instead of the structure. +//! +//! ★ CHIRP's offsets **above** the hole are `true + 0x100` (`skycmd` declared at +//! `0x8660`/`0x8674`, measured here at `0x8560`/`0x8574`). Use the rule. +//! +//! ⚠ Do not re-search for menu 612's path or menu 613's `APK102`: neither +//! appears anywhere in the 39 840 bytes, in plain **or** AX.25 bit-shifted form. +//! That is not evidence they are absent — 612's TYPE is an enum whose default +//! *renders* as `WIDE1-1, WIDE2-1`, and 613's `APK102` is almost certainly enum +//! index 0. Both are small enums at their default, which is the same blind spot +//! that hid the first four fields. Find them with a front-panel change. +//! +//! ⚠ `+0x00C`, `+0x00E`, `+0x085`, `+0x087`, `+0x16D` and `+0x16F` are each +//! pinned at **one** value and are owed a second before they go in a table: +//! one index does not settle an enum. +//! +//! ★ **TX rate stores the denominator, not a list position** — `05` reads +//! `1/5`, not the sixth entry. A table built on display order would write this +//! field wrong for every value but one. +//! +//! ⚠ `+0x0B5` is `+0x089 + 44`, the lead byte of status text **record 2**. The +//! obvious reading — that each record carries its own TX rate, and menu 608 +//! edited record 2 because record 2 is selected — is **one measurement on one +//! record and is not established.** +//! +//! ⚠ `+0x165` is unidentified and has been misnamed twice: position comment in +//! session 129, status text TX rate in session 131. Both times it was a value +//! that merely fit. +//! +//! ⚠ Use `TM-D710A_manual.pdf` (the A's own, with the full menu table, option +//! lists and factory defaults), **not** the G's. `APRS LOCK` is in the G manual +//! and is **not on this radio's screen**; the radio says `ID TM-D710`, +//! `TY K,0,3,1,0`. +//! +//! ★ And the A manual is not sufficient either: it prints menu 611's interval +//! list with **8** entries and the radio has at least **10**. Three readings +//! (`04`=`3 min`, `05`=`5 min`, `09`=`60 min`) fit only +//! `0.2/0.5/1/2/3/5/10/20/30/60`. Take **defaults** from the manual — those +//! cross-check perfectly — and **lists** from the radio. +//! +//! ## ⚠⚠ RETRACTED: "menu 625 is not in the image" — it is, at `+0x35C` +//! +//! This section claimed menu 625 was absent from every byte the radio serves, +//! because its factory default vector `02 01 01` occurs zero times. **The claim +//! was wrong and the search was right.** `02` was *my* index for `ENTIRE`, taken +//! from the A manual's three-entry list `OFF / HALF / ENTIRE`. The factory byte +//! is `03`, so the radio's list is longer than the manual prints. +//! +//! `03 01 01` hits **once per block, at `+0x35C`, in all five factory copies** — +//! block-unique, exactly like the ten menus the same instrument located. +//! +//! ★★★ The lesson is not about the instrument, which was sound. It is that a +//! default vector is built from **a default (a name) plus a list (an order)**, +//! and on this radio the manual is reliable for the first and demonstrably not +//! for the second — menu 611's interval list is missing entries, and now menu +//! 625's DISPLAY AREA list is too. "Take **defaults** from the manual and +//! **lists** from the radio" was already written down here before this run, and +//! then an index was computed from a manual list anyway. **A vector search that +//! returns zero hits indicts the vector before it indicts the image.** +//! +//! ⚠ Two conclusions rested on the retracted claim and both are withdrawn: +//! that menus 624-627 are non-contiguous (they are contiguous), and that a +//! setting existed which the read plan could not reach (the `0x9C00` probe was +//! still right, but for reasons of its own — see below). +//! +//! ### Located by a front-panel change against a zero noise floor +//! +//! | offset | menu | field | evidence | +//! |---|---|---|---| +//! | `+0x35C` | 625 | DISPLAY AREA | `01` -> `00` set to `OFF`; factory `03` = `ENTIRE` | +//! | `+0x35D` | 625 | AUTO BRIGHTNESS | factory `01` = `ON`, in the unique vector | +//! | `+0x35E` | 625 | CHANGE COLOR | factory `01` = `ON`, in the unique vector | +//! | `+0x360` | 626 | SPEED, DISTANCE | factory `00` = `mi/h mile` | +//! | `+0x361` | 626 | ALTITUDE, RAIN | factory `00` = `feet/inch` | +//! | `+0x362` | 626 | TEMPERATURE | `00` = `°F`, `01` = `°C` — **two values** | +//! | `+0x363` | 627 | POSITION | factory `00` = `dd°mm.mm'` | +//! | `+0x364` | 627 | GRID FORMAT | `00` -> `01` | +//! +//! ⚠ `+0x35F` = `82` is unexplained and sits between 625 and 626. Not named. +//! ⚠ Menu **624** is unlocated. `+0x350` (live `03`, factory `00`) and `+0x351` +//! (`01`) with ten zero bytes after them *look* like RX BEEP / APRS VOICE / +//! SPECIAL CALL, but the manual's RX BEEP default is `ALL` against a factory +//! `00`, so that is a guess and is recorded as one. +//! +//! ## ★★★ Menu 612 at `+0x421`, and why the first attempt measured nothing +//! +//! First attempt: `TYPE` was set to `Others`, and a full re-dump differed from +//! pristine in three bytes, all of them menus 625/626/627. That was written up +//! as "menu 612's TYPE is in none of the 65 168 bytes this radio serves". **It +//! was not a finding.** Asked afterwards to set the menu back, the operator +//! found it *already* on `New N Paradigm` — the radio was not holding the +//! change when the dump ran, and a dump of an unchanged setting shows nothing +//! trivially. +//! +//! ★★★ Every **poke** in this campaign is verified by read-back, on the standing +//! rule that this protocol acknowledges writes that never commit — and then a +//! **front-panel** change was taken on trust. **An operator's change needs the +//! same proof as a harness write: leave the menu, re-enter it, confirm the value +//! held, then dump.** +//! +//! The retest, with that check: `TYPE` = `Relay` **persisted**, and exactly one +//! non-volatile byte moved. +//! +//! | offset | menu | field | values | +//! |---|---|---|---| +//! | `+0x421` | 612 | PACKET PATH TYPE | `00` = `New N Paradigm`, `01` = `Relay` | +//! | `+0x350` | 624 | RX BEEP | `03` = `ALL NEW`, `02` = `MINE` | +//! +//! ⚠ So `Others` really does not persist while `New N Paradigm` and `Relay` do — +//! most likely it requires path strings first. The reversion was the radio +//! refusing an incomplete setting, which is a fact about menu 612 and not noise. +//! +//! `WIDE` genuinely appears nowhere in the image, plain or AX.25 bit-shifted. +//! That search stands; it simply never implied the field was absent, because +//! `WIDE1-1, WIDE2-1` is what enum value `00` *renders as*. +//! +//! ## ★★ Neither half of the A manual is trustworthy alone +//! +//! A default vector needs a **default** (a name) and a **list** (an order), and +//! this radio's manual gets each one wrong somewhere: +//! +//! | menu | manual default | manual list | which was wrong | +//! |---|---|---|---| +//! | 625 DISPLAY AREA | `ENTIRE` ✓ | `OFF/HALF/ENTIRE` -> index 2 ✗ (byte is `03`) | the **list** | +//! | 624 RX BEEP | `ALL` ✗ (factory is `00`) | `OFF/MESSAGE ONLY/MINE/…` -> `MINE` = 2 ✓ | the **default** | +//! | 611 INITIAL INTERVAL | `3 min` ✓ | 8 entries, radio has ≥10 ✗ | the **list** | +//! +//! ★ So "defaults from the manual, lists from the radio" is a **useful bias, not +//! a rule** — 624 is a counter-example in the other direction. Confirm whichever +//! half a conclusion actually rests on. +//! +//! ## ★★★ The same instrument, run forwards: ten menus located (s132) +//! +//! A menu's factory defaults form a byte **string**. Run it against the six +//! blocks: a string that occurs **exactly once per 1152-byte block, at the same +//! offset in all six**, is an anchor no single byte can match. Offsets below +//! are relative to a block base; factory values read from a PM copy. +//! +//! | menu | setting | offset | factory | anchor | +//! |---|---|---|---|---| +//! | 603 WAYPOINT | FORMAT | `+0x015` | `00` = `NMEA` | `00 06 00`, block-unique | +//! | | NAME | `+0x016` | `06` = `6-CHAR` | ″ | +//! | | OUTPUT | `+0x017` | `00` = `ALL` | ″ | +//! | 609 PACKET FILTER | POSITION LIMIT | `+0x166` ⚠ | `00` = `OFF` | `00 3F`, block-unique | +//! | | TYPE | `+0x167` | `3F` = checked all | ″ | +//! | 611 BEACON TX | METHOD | `+0x16D` | `00` = `MANUAL` | `00 04 01 01`, block-unique | +//! | | INITIAL INTERVAL | `+0x16E` | `04` = `3 min` | ″ | +//! | | DECAY | `+0x16F` | `01` = `ON` | ″ | +//! | | PROPORTIONAL PATHING | `+0x170` | `01` = `ON` | ″ | +//! | 614 VOICE ALERT | VOICE ALERT | `+0x1DF` | `00` = `OFF` | `00 0C`, block-unique | +//! | | CTCSS FREQUENCY | `+0x1E0` | `0C` = `100.0 Hz` | ″ | +//! | 623 GROUP FILTERING | MESSAGE | `+0x2F6` | `ALL,QST,CQ,KWD` | literal, unique | +//! | 628 NAVITRA GROUP | GROUP CODE | `+0x367` | `000` | literal, unique | +//! +//! ★ The manual prints `ALL, QST, CQ, KWD` **with spaces** and the radio stores +//! it without. A printed default is a value, not a byte layout. +//! +//! ★ `+0x016` = `06` for `6-CHAR` is the **literal**, not an index — the same +//! shape as status text TX rate storing its denominator. +//! +//! Weaker, and marked so: `+0x1E9` = `1C` = 617 UI CHECK TIME (`1C` occurs +//! twice per block, the other at `+0x3DB`; `+0x1E9` chosen by contiguity with +//! 614), `+0x011` = `01` = 602 GPS BAUD `4800` (the only non-zero byte in +//! `+0x008`-`+0x01B`, but `01` is not a rare value), and `+0x012`/`+0x013` +//! (602 INPUT/OUTPUT) and `+0x366` (628 GROUP MODE) by contiguity alone. +//! +//! ⚠ **All of this is desk evidence.** A block-unique default vector is a much +//! better prediction than a lone byte; it is not a measurement. Its value is a +//! *shared* failure mode: if one poke misses, the instrument is wrong rather +//! than just that offset. +//! +//! ## ★★★ `+0x165`: a hypothesis with an anchor behind it, for once +//! +//! `+0x165` is `00` factory and **`03` live** — the operator moved it — and it +//! sits immediately below the block-unique `00 3F`. Menu 609 has exactly two +//! settings and prints POSITION LIMIT **above** TYPE, so POSITION LIMIT is +//! `+0x166` (`00` in both copies) or `+0x165` (the one that moved). +//! +//! ⚠ `+0x165` has been misnamed twice, both times because a value fit. This +//! claim comes from the `3F` next door instead — and it costs **one screen +//! read** to settle: menu 609, POSITION LIMIT. `OFF` leaves the byte open; a +//! distance names it. No writes. +//! +//! ★ Free and the same shape: `+0x1DF` is `00` factory, `01` live, so menu +//! **614 VOICE ALERT should read `ON`** on this radio. One glance tests the +//! `00 0C` anchor against real data. +//! +//! ⚠ An unexplained recurring `0x11` sits at `+0x07F`, `+0x168`, `+0x1E1`, +//! `+0x1EA`, `+0x435`, `+0x477` — three of them immediately after a field +//! located above. Looks like a per-record tag. Logged, not named. +//! +//! ## s132 at the radio — six fields measured, and the read plan was wrong +//! +//! ★★★ **`read_plan()` is NOT the radio's ceiling.** `0x9C00`, `0xA000`, +//! `0xC000`, `0xE000` and `0xFE00` all answer, each probe followed by a passing +//! control read. [`d710_dump_span`] then took all **25 328 bytes** of +//! `0x9C00`-`0xFEEF` with zero refusals. This is the **third** inherited +//! stopping rule to hide real data here, after `0x7F00`. +//! +//! | region | what | check | +//! |---|---|---| +//! | `0x9C00`-`0xCDFF` | **APRS station list**, 100 × 128 B | `0xCE00-0x9C00 = 100*128` exactly; all 100 slots hold a call sign | +//! | `0xD200`-… | **APRS message list** | `KF0SFW-9`, `CQ`, message text | +//! | `0xFE00`-`0xFE63` | station-list display order | 100 bytes, a **permutation of 0..99** | +//! +//! ⚠ None of it is settings — it is received traffic, and a driver must never +//! write it. But "the whole image" was short by 25 328 bytes, so any claim +//! resting on *absence* from a dump has to be re-checked against this span. +//! Menu 625's `02 01 01` is **not** here either, so 624-627 are non-contiguous +//! rather than out of reach. +//! +//! ### Measured on the radio, two distinct values each +//! +//! | offset | menu | field | values | +//! |---|---|---|---| +//! | `+0x00C` | 601 | DATA BAND | `01` = `B Band`, `03` = `A:RX B:TX` | +//! | `+0x011` | 602 | GPS BAUD RATE | `01` = `4800`, `02` = `9600` | +//! | `+0x016` | 603 | WAYPOINT NAME | `06` = `6-CHAR`, `09` = `9-CHAR` — the **literal** | +//! | `+0x085` | 606 | POSITION AMBIGUITY | `04` = `4-DIGIT`, `02` = `2-DIGIT` | +//! | `+0x087` | 607 | POSITION COMMENT | `09` = `CUSTOM 2`, `06` = `PRIORITY` | +//! | `+0x170` | 611 | PROPORTIONAL PATHING | `01` = `ON`, `00` = `OFF` (DECAY held as control) | +//! | `+0x1E0` | 614 | CTCSS FREQUENCY | `0C` = `100.0`, `08` = `88.5 Hz` | +//! | `+0x1E9` | 617 | UI CHECK TIME | `1C` = `28`, `64` = `100` — **literal seconds** | +//! +//! Every one was predicted from a block-unique default vector before the write. +//! Eight for eight: the instrument in the section above is sound. +//! +//! ## ★★★ A field can be GATED, and a gated field measures nothing +//! +//! Menu 609 `TYPE` read as **nothing selected** while its byte `+0x167` held +//! `3F` — all six bits set — in both the live and factory copies, against a +//! manual that prints the default as `Checked all`. The explanation is neither +//! an inverted mask nor a wrong manual: *"you can't set TYPE if POSITION LIMIT +//! is off"*. **`TYPE` is gated by `POSITION LIMIT`, which is `OFF`.** +//! +//! ⚠ Generalise it. [`poke-confirms-frontpanel-finds`] already says never to +//! *move* a mode selector in the same pass as the fields it gates. This is the +//! other half: **a field may be gated by a selector nobody moved**, and then a +//! screen read of it measures the gate, not the field. Before believing a +//! screen read, ask what upstream setting has to be on for that line to be +//! live. +//! +//! +//! ### Five more, once the gate was opened +//! +//! Poking `+0x166` both named it and un-gated `TYPE`, which is the only way +//! `TYPE` could be measured at all. +//! +//! | offset | menu | field | values | +//! |---|---|---|---| +//! | `+0x00D` | 601 | DATA SPEED | `00` = `1200`, `01` = `9600 bps` | +//! | `+0x00E` | 601 | DCD SENSE | `02` = `IGNORE DCD`, `01` = `BOTH BAND` | +//! | `+0x166` | 609 | POSITION LIMIT | `00` = `OFF`, `01` = `10` — an index, not the literal | +//! | `+0x167` | 609 | PACKET FILTER TYPE | a **6-bit mask**, mapped below | +//! | `+0x16F` | 611 | DECAY ALGORITHM | `01` = `ON`, `00` = `OFF` | +//! +//! ## ★★★ The packet-filter mask: a list order and a bit order, both hidden +//! +//! `+0x167` is a six-bit mask, and **neither half of its encoding is in the +//! manual.** The manual prints the six types in a row — WEATHER, DIGI, MOBILE, +//! OBJECT, NAVITRA, OTHERS — and the radio lays them out as a 2×3 grid that +//! reads row-major in exactly that order, so the *printed order is right*. The +//! packing is not what it implies: +//! +//! | bit | 5 | 4 | 3 | 2 | 1 | 0 | +//! |---|---|---|---|---|---|---| +//! | | Weather | Mobile | Navitra | Digi | Object | Others | +//! +//! ★ Read the grid **down the left column then down the right** — Weather, +//! Mobile, Navitra, Digi, Object, Others — and pack that list **MSB-first**. +//! So the list order is *column*-major while the display is row-major, and the +//! bits run high-to-low. +//! +//! Measured, not fitted: `01` marked Others, `04` marked Digi (which killed the +//! obvious "printed list reversed" reading — it predicts Object), and `2A` was +//! then written as a three-bit discriminator whose three rival hypotheses gave +//! three disjoint answers. It marked Weather, Navitra and Object, as this table +//! predicts. Bit 4 = Mobile is the single assignment left once the other five +//! are pinned. +//! +//! ⚠⚠ `3F` — the factory default, all six bits — is **invariant under every one +//! of those orderings** and could not have caught any of it. A mask at +//! all-set is the bitmask version of [`an-anchored-index-catches-a-bad-list`]. +//! +//! ⚠ `+0x165` is **not** POSITION LIMIT — menu 609 read `OFF` while `+0x165` +//! held `03`, and `+0x166` then took the role. That is the **third** hypothesis +//! for this byte to die (position comment s129, status text TX rate s131, +//! position limit s132). It is the cheapest kind of failure — a screen read, no +//! writes — and it is still a failure. Leave `+0x165` alone until a front-panel +//! change moves it. +//! +//! ## ★★★ s133: the measurements are WIRED, and what is still held back +//! +//! Everything above was a doc comment until now — the shipped schema was still +//! the 35-field, zero-APRS one this radio is the cautionary tale for. +//! `scratchpad/kenwood_tmd710/APRS-MEASURED.md` is the sheet, +//! `gen_tmd710_image.py` emits [`super::kenwood_tmd710::aprs`]'s table and the +//! profile schema from that one parse, and the form is now **57 fields: 35 over +//! `MU` and 22 in the image**. +//! +//! ★ The emit rule that decided which of the ~45 located settings ship: a row +//! goes in only when **every index of its list is anchored** — measured on the +//! radio, the factory default confirmed against the A manual, or the single +//! remaining printed entry. That bar is set by this radio's own manual, which +//! has printed a **short** list twice (611's intervals, 625's display area), so +//! "the rest of the manual's list, in order" is not evidence here. It holds back +//! eleven located fields, each with a named check: menu 601 TX DELAY (indices +//! 4-7), 609 POSITION LIMIT, 611 INITIAL INTERVAL, 612 PACKET PATH, 624 RX BEEP +//! (the order of `Off`/`Message only`), 625 DISPLAY AREA, and the text fields +//! whose padding is unmeasured. +//! +//! ★ **19 of 19 checkable offsets cross-check clean** against the manual's +//! factory defaults in the five PM copies, including the five identifying +//! non-zero ones. That validated the whole emit set at the desk, for free. +//! +//! ⚠ Two things about the driver are **not** hardware-proven and are ladder +//! step 5: a profile's worth of fields patched in one go, and **entering program +//! mode after an `MU` exchange on the same open port** — the settings read now +//! does both in one session and nobody has watched the radio do it. +//! +//! ★ The mechanical version of the census gate is now in `radios/wiring.rs`: +//! a model seeded `aprs_capable: true` whose settings schema has no APRS field +//! fails the build. On its first run it found a **second** instance — the Icom +//! ID-52, 173 fields, a GPS section and no APRS/D-PRS settings at all. Listed as +//! a known gap so it stays greppable and a new radio still cannot slip through. +//! +//! ## s133 at the radio — ladder step 5 PASSED, nine more fields measured +//! +//! ★★★ **The two-transport settings path works.** `read_settings` does `MU` +//! then `0M PROGRAM` on one open port and all 31 image fields decode; +//! `write_settings` patched one field from each transport, `verified=true`, one +//! narrow write to `0x8462`, and Tim confirmed **Menu 501 DISPLAY BRIGHTNESS** +//! and **Menu 626 TEMPERATURE** on the radio's own screens. The restore left +//! both transports byte-identical. +//! +//! ⚠⚠ **A live command sent immediately after `E` draws SILENCE.** Not the `?` +//! that [`super::kenwood_tmd710::ask_settling`] absorbs — nothing at all, and it +//! killed the first settings write. [`d710_settling_after_program_mode`] +//! separated the two candidate causes: with a long enough pause the **first** +//! command is answered, so the radio needs *time* and is not discarding a +//! command. Threshold measured between 100 ms (fails) and 250 ms (works); +//! `ProgramMode::exit` now waits 500 ms, in the transition rather than at the +//! call sites. +//! +//! ⚠ Once in five poke rounds the exit ack came back as `F6 06 0D` — a leftover +//! byte in front of it. A full dump proved every written byte had committed, so +//! rejecting that reply turns a **successful** write into a reported failure, +//! which is the error that makes an operator run the write again. The ack is now +//! accepted anywhere in the three-byte window, and refused only when absent. +//! +//! ### ★★★ This manual is wrong about LISTS three different ways +//! +//! Five poke rounds, each read off the front panel: +//! +//! | menu | the manual prints | the radio has | +//! |---|---|---| +//! | 611 INITIAL INTERVAL | 8 entries | **10** — `2 min` and `60 min` are missing | +//! | 625 DISPLAY AREA | `OFF/HALF/ENTIRE`, default `ENTIRE` | **4** entries; index 3 is `ENTIRE ALWAYS` and that is what it ships on | +//! | 624 RX BEEP | `OFF/MESSAGE ONLY/MINE/ALL NEW/ALL` | **reversed** (see below) | +//! | 602 INPUT | `…/WEATHER(Davis)/WEATHER(PeetBros)` | **swapped**: 2 is PeetBros, 3 is Davis | +//! +//! ★★★ So three distinct failure modes — a list too **short**, a list with the +//! wrong **labels**, and a list **permuted** — and the fourth (601 TX DELAY, +//! 5 of 8 indices measured each at its printed position) came out exactly right. +//! There is no way to tell which kind you have without measuring, and *agreeing +//! with the manual at one index is not a signal*: 624's index 2 = MINE matched +//! and the list was reversed around it, because MINE is the middle of an +//! odd-length run. See [`an-anchored-index-catches-a-bad-list`]. +//! +//! ### ⚠⚠ Menu 624 RX BEEP is HELD, on a contradiction +//! +//! `+0x350` measured `00`=ALL, `01`=ALL NEW, `02`=MINE, `03`=MESSAGE ONLY, +//! `04`=ALL. **Two indices cannot be one entry.** Every reading but index 4 fits +//! the manual's list reversed — `ALL/ALL NEW/MINE/MESSAGE ONLY/OFF` — under +//! which index 4 is `OFF`; and index 4 is the one reading taken *without* first +//! leaving and re-entering the menu, which is the discipline that already caught +//! a stale front-panel read on menu 612. Re-poke `04` and read it after leaving +//! the menu. Do not ship this field until then. +//! +//! ### Research, re-run for this substrate (asked, and checked rather than recalled) +//! +//! Nobody has published this map. CHIRP's `tmd710.py` is on disk: it maps +//! channels, names and the `0x0200` PM config block, its settings groups are +//! Display / Audio / Aux / TX-RX / Memory / PF Keys / VFO / Band Masks / +//! Repeater / DTMF / Sky Command, and **neither the D710 nor the D710G class +//! maps one byte at `0x8100`+**. LA3QMA's command repo — the source this +//! project already uses for `MU` — documents no APRS/TNC settings command; +//! `CS` ("set/read the callsign") was the one candidate and **this radio +//! answers it `?`**. No MCP-2A file-format work is published either. +//! +//! ⚠ MCP-2A as a *measuring instrument* was offered and **declined** — the +//! cable has to be re-attached to a VM for every pass, which is slower than +//! poking. Do not re-offer it. +//! +//! ## s133: hardware ladder step 4 PASSED — the ladder is complete +//! +//! [`d710_band_probe`] put a channel at **each edge of the seeded coverage** +//! through the shipped encoder into a scratch slot, read it back, and cleared +//! it. All seven inside landed — `118.000`, `144.000`, `148.000`, `224.840` +//! (receive-only, the 220 gap), `430.000`, `450.000`, `523.995` — and both +//! outside were **refused by the radio itself**: `117.995` and `524.000`. +//! +//! ★ The negative control had to be fixed to mean anything. The first run used +//! `117.999`, which the *encoder* rejected for not sitting on a tuning step — +//! a pass that tested the wrong rule. `117.995` is step-aligned, so its refusal +//! comes from the band edge and nothing else. +//! +//! ★ Non-destructive by design: step 3 already proved the whole-codeplug +//! bookkeeping, and what it could *not* prove is that the encoder's own output +//! is accepted at the edges, because Tim's 39 memories sit nowhere near them. +//! The probe writes one slot that was empty as-found and clears it each time. +//! +//! ⚠ This is also what pins `rx_bands` to the 1350-point refusal sweep that +//! measured it. Edit the seed row and a frequency the radio was measured to +//! accept starts becoming a **silently empty memory slot**. +//! +//! ## ⚠ There are no assignable zones on this radio, and that is not a gap +//! +//! Menu **203 GROUP LINK** takes "up to 10 digits (0~9)" — a scan *sequence* +//! over ten fixed groups — and a memory's group is decided by its own slot +//! number. MCP-2A can "view" and "name" groups, not populate them. So +//! `zones_supported: false` is correct and there is nothing to implement. +//! ★ Two group-*adjacent* things do exist and are **not** done: the ten group +//! **names**, and menu 203 itself. Both are settings, neither is located. +//! +//! ## The radio was returned to pristine +//! +//! `d710_restore_aprs_block` from `progfull-71022.bin`, then a full re-dump: +//! **5 bytes differ, all in the known volatile operating-state set at `0x0216`, +//! `0x0222`, `0x0224`, `0x0228`, `0x022E`, and 0 differences inside the APRS +//! block.** + +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 — and + // as of s133 that is TWO transports in one open port: the `MU` line, then + // `0M PROGRAM` for the APRS block. **Entering program mode after an `MU` + // exchange has never been done in front of the radio**, so this first call + // is itself the measurement. + let before = DRIVER.read_settings(&path, "[]").expect("read settings"); + let backup_before = String::from_utf8(before.backup.clone()).expect("utf8 backup"); + let (line_before, block_before) = backup_before.split_once('\n').expect("two halves"); + println!("\nMU: {line_before}"); + assert!( + block_before.contains("8100 ") && block_before.contains("0200 "), + "the backup is missing an image window — the image half of the read did not happen" + ); + + println!("\n--- the 600-series half, as the form now sees it ---"); + let mut aprs: Vec = before + .settings + .as_object() + .expect("object") + .iter() + .filter(|(k, _)| k.starts_with("aprs-")) + .map(|(k, v)| format!(" {k:<28} {v}")) + .collect(); + aprs.sort(); + for l in &aprs { + println!("{l}"); + } + assert_eq!(aprs.len(), 32, "the image half did not decode"); + println!(" {:<28} {}", "power-on-message", before.settings["power-on-message"]); + + // ★ One field from EACH transport, in one write. Two visually obvious + // values, and each is set to something 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_beep = before.settings["display-brightness"].as_str().expect("an option label"); + let beep = if was_beep == "Level 8" { "Level 3" } else { "Level 8" }; + let was_unit = before.settings["aprs-temperature-unit"].as_str().expect("a label"); + let unit = if was_unit == "Celsius" { "Fahrenheit" } else { "Celsius" }; + println!("\ndisplay brightness {was_beep} -> {beep}, temperature unit {was_unit} -> {unit}"); + + let report = DRIVER + .write_settings( + &path, + &json!({ "display-brightness": beep, "aprs-temperature-unit": unit }), + "[]", + &dir, + ) + .expect("write settings"); + println!( + "wrote {} field(s), verified={:?}, windows={:?} {}", + report.fields_written, + report.verified, + report.windows_written, + report.note.as_deref().unwrap_or("") + ); + assert_eq!(report.fields_written, 2, "expected one field from each transport"); + assert_eq!(report.verified, Some(true), "the radio did not take the write"); + assert_eq!( + report.windows_written.len(), + 1, + "one APRS byte changed, so exactly one narrow write should have gone out" + ); + + let mid = DRIVER.read_settings(&path, "[]").expect("re-read"); + assert_eq!( + mid.settings["display-brightness"], + json!(beep), + "the decoder does not see the MU value the write claimed to make" + ); + assert_eq!( + mid.settings["aprs-temperature-unit"], + json!(unit), + "the decoder does not see the APRS value the write claimed to make" + ); + println!("\n>>> Menu 501 DISPLAY BRIGHTNESS should be {beep}, Menu 626 TEMPERATURE {unit}."); + + // ★ The read-back above is the DRIVER checking its own work. Ladder step 5 + // wants two visually obvious values read off the radio's own screens, which + // is the only independent half — so `D710_HOLD=1` stops here and leaves them + // on the radio. Re-run without it to restore. + if std::env::var("D710_HOLD").is_ok() { + println!("\n--- HELD on the radio. Re-run WITHOUT D710_HOLD to restore.\n"); + return; + } + + // Put it back, and require the WHOLE backup to match — both the 42-parameter + // line and all 1152 bytes of the APRS block. That is what says each write + // patched one field instead of rebuilding its whole transport, and it is a + // much stronger restore check than the menu line alone was. + DRIVER + .write_settings( + &path, + &json!({ "display-brightness": was_beep, "aprs-temperature-unit": was_unit }), + "[]", + &dir, + ) + .expect("restore"); + let after = DRIVER.read_settings(&path, "[]").expect("final read"); + let backup_after = String::from_utf8(after.backup).expect("utf8"); + assert_eq!( + backup_after, backup_before, + "the settings round trip did not leave BOTH transports as it found them" + ); + println!("\n--- settings read + write proven through the driver traits, both transports\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}"); +} + +/// Poke individual bytes of the **live** APRS block and read them back. +/// +/// The inverse of a front-panel measurement pass. `PASS-A.md` asks the operator +/// to move a menu so a diff can name the byte; that only works for fields whose +/// byte actually moves, and the four this campaign still wants — `DATA SPEED`, +/// `DCD SENSE`, `POSITION AMBIGUITY`, `POSITION COMMENT` — all sit on the first +/// entry of their list, so they read `00` in the live block *and* `00` in the PM +/// defaults and no differential can see them. +/// +/// So run it the other way: write a distinct value into each candidate byte and +/// let the operator read the menus. A menu that comes back showing entry `N` +/// names its own offset, because exactly one byte was given the value `N`. +/// +/// `D710_POKE="160=01,161=02"` — offsets are hex and **relative to the live +/// block base**, matching `APRS-BLOCK.md`; values are hex. Only the 256-byte +/// pages that actually contain a poke are written, and every one is read back +/// before the next: this protocol acknowledges writes that did not commit. +/// +/// Reverse it with `d710_restore_aprs_block`. +#[test] +#[ignore = "requires a TM-D710 on the cable — WRITES to the radio"] +fn d710_poke_aprs() { + use super::kenwood_tmd710::image::{ProgramMode, APRS_BLOCK_LEN, APRS_LIVE}; + + let spec = std::env::var("D710_POKE") + .expect("set D710_POKE to off=val[,off=val...] — hex, offsets relative to the live APRS block"); + let pokes: Vec<(usize, u8)> = spec + .split(',') + .filter(|s| !s.trim().is_empty()) + .map(|kv| { + let (o, v) = kv.split_once('=').unwrap_or_else(|| panic!("{kv:?} is not off=val")); + let off = usize::from_str_radix(o.trim(), 16) + .unwrap_or_else(|_| panic!("offset {o:?} is not hex")); + let val = u8::from_str_radix(v.trim(), 16) + .unwrap_or_else(|_| panic!("value {v:?} is not hex")); + assert!(off < APRS_BLOCK_LEN, "+0x{off:03X} is outside the {APRS_BLOCK_LEN}-byte live block"); + (off, val) + }) + .collect(); + assert!(!pokes.is_empty(), "D710_POKE named no bytes"); + // Two pokes at one offset would make the read-back check pass while the + // second value silently won, and the whole method rests on one value per byte. + let mut seen: Vec = pokes.iter().map(|(o, _)| *o).collect(); + seen.sort_unstable(); + seen.dedup(); + assert_eq!(seen.len(), pokes.len(), "D710_POKE names the same offset twice"); + + 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"); + + // Read every page that a poke touches, so the bytes around it go back untouched. + let mut pages: Vec = pokes.iter().map(|(o, _)| o / 256 * 256).collect(); + pages.sort_unstable(); + pages.dedup(); + + let mut before: Vec<(usize, u8, u8)> = vec![]; + for base in pages { + let n = 256.min(APRS_BLOCK_LEN - base); + let addr = APRS_LIVE + base as u16; + let mut page = prog + .read(addr, if n == 256 { 0 } else { n as u8 }) + .unwrap_or_else(|e| panic!("read 0x{addr:04X}: {e}")); + for (off, val) in pokes.iter().filter(|(o, _)| o / 256 * 256 == base) { + before.push((*off, page[off - base], *val)); + page[off - base] = *val; + } + prog.write(addr, &page).unwrap_or_else(|e| panic!("write 0x{addr:04X}: {e}")); + 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, page, "0x{addr:04X} did not take the write"); + } + prog.leave().expect("leave"); + + before.sort_unstable_by_key(|(o, _, _)| *o); + println!("\n offset was -> now (decimal value the radio should show as its list index)"); + for (off, was, now) in &before { + println!(" +0x{off:03X} {was:02X} -> {now:02X} {now}"); + } + println!("\n{} bytes poked; read back clean. Restore with d710_restore_aprs_block.", before.len()); +} + +/// Write menu 605's position record and menu 608's status text **through the +/// driver traits**, and prove the radio holds what the form asked for. +/// +/// ★ The codecs for these were measured with pokes and then tested against a +/// buffer, and a buffer cannot tell you the offset arithmetic is right — two +/// generated artifacts agreeing proves nothing. This points the test at the real +/// consumer: `write_settings`, the same call the profile screen makes. +/// +/// ⚠ Uses **slot 3 of each**, which is unused on this operator's radio, so a +/// wrong offset damages nothing of his. The as-found bytes of both records are +/// captured first and written back raw at the end, because the form itself +/// cannot express "FF-filled": an empty field deliberately means *leave it +/// alone*, so the driver has no way to un-write a record it created. +#[test] +#[ignore = "requires a TM-D710 on the cable — WRITES to the radio"] +fn d710_record_fields_write() { + use super::kenwood_tmd710::image::{ProgramMode, APRS_LIVE}; + use crate::radios::driver::{SettingsReader, SettingsWriter}; + use crate::radios::kenwood_tmd710::DRIVER; + use serde_json::json; + + /// Position record 3 and status text record 3, as byte ranges in the block. + const POS3: (u16, usize) = (0x044, 20); + const TXT3: (u16, usize) = (0x0E2, 42); + + let path = port_path(); + let dir = std::env::temp_dir().join("d710-record-probe"); + + // --- as found, raw, before anything ------------------------------------ + let asfound = { + 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 got = vec![]; + for (off, len) in [POS3, TXT3] { + got.push((off, prog.read(APRS_LIVE + off, len as u8).expect("read record"))); + } + prog.leave().expect("leave"); + got + }; + for (off, bytes) in &asfound { + println!("as found +0x{off:03X} {}", show(bytes)); + } + + let before = DRIVER.read_settings(&path, "[]").expect("read settings"); + for k in ["aprs-position-3-name", "aprs-position-3-lat", "aprs-position-3-lon", + "aprs-status-text-3", "aprs-status-text-3-rate"] { + println!(" {k:<26} {}", before.settings[k]); + } + + // Values chosen so a swapped or off-by-one field cannot look right: the + // latitude and longitude share no digit, the minutes differ, and the + // fractions are three distinct digits each. + let want = json!({ + "aprs-position-3-name": "PROBE3", + "aprs-position-3-lat": "S 12 34.321", + "aprs-position-3-lon": "E 098 12.654", + "aprs-status-text-3": "CQ FROM CODEPLUG MAGIC", + "aprs-status-text-3-rate": "1/3", + }); + let report = DRIVER.write_settings(&path, &want, "[]", &dir).expect("write settings"); + println!( + "\nwrote {} field(s), verified={:?}, windows={:?}", + report.fields_written, report.verified, report.windows_written + ); + assert_eq!(report.fields_written, 5); + assert_eq!(report.verified, Some(true), "the radio did not take the write"); + + // --- the radio's own bytes, decoded again ------------------------------ + let after = DRIVER.read_settings(&path, "[]").expect("re-read"); + for (k, v) in want.as_object().expect("object") { + assert_eq!(&after.settings[k], v, "{k} did not come back off the radio"); + } + println!("\nall five fields read back off the radio as written"); + + // ⚠ And the neighbours are untouched. A record that overran would corrupt + // the record next door, which the field's own read-back cannot see. + for k in ["aprs-position-2-name", "aprs-position-2-lat", "aprs-position-2-lon", + "aprs-position-4-lat", "aprs-status-text-2", "aprs-status-text-4", + "aprs-status-text-2-rate", "aprs-status-text-4-rate", "aprs-my-callsign"] { + assert_eq!(after.settings[k], before.settings[k], "{k} moved and should not have"); + } + println!("neighbouring records unchanged"); + + // --- give the records back exactly as found ---------------------------- + { + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + let mut prog = ProgramMode::enter(&mut *p).expect("enter"); + for (off, bytes) in &asfound { + prog.write(APRS_LIVE + off, bytes).expect("restore record"); + let back = prog.read(APRS_LIVE + off, bytes.len() as u8).expect("read back"); + assert_eq!(&back, bytes, "+0x{off:03X} did not take the restore"); + } + prog.leave().expect("leave"); + } + let restored = DRIVER.read_settings(&path, "[]").expect("final read"); + assert_eq!( + String::from_utf8(restored.backup).expect("utf8"), + String::from_utf8(before.backup).expect("utf8"), + "the radio was not given back as it was found" + ); + println!("\n--- menu 605 and menu 608 records proven through the driver, radio as found\n"); +} + +/// 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) +} + + +/// Read blocks at addresses [`read_plan`] has never requested, to find out +/// whether the read plan's ceiling is the radio's. +/// +/// `read_plan` is MCP-2A's ritual, inherited from CHIRP: blocks `0x00`-`0x9B` +/// except the hole, plus two tails at `0xFEF0` and `0xFF00`. **`0x9C00`-`0xFEEF` +/// — 25 328 addresses — has never been asked for once.** Session 129 learned +/// what an untested stopping rule costs when `0x7F00` turned out to be a hole +/// rather than the end of the image, and 7 KB holding the entire APRS block was +/// sitting above it. +/// +/// Session 132 made this worth doing rather than merely tidy. Menu 625's factory +/// defaults are the byte string `02 01 01`, and that pattern occurs **zero times +/// in all 39 840 bytes we have ever read**. Unlike menus 612 and 613 it cannot +/// be an enum hiding at a zero default — 625 defaults to `ENTIRE` (`02`) and 624 +/// to `ALL` (`04`). So there is a setting this radio certainly has and this +/// image demonstrably does not, and one of the places it can be is up here. +/// +/// ★★★ **Every probe is paired with a control read of a known-good address.** +/// A refusal only means "the radio will not serve this address" if the session +/// is still alive afterwards; a desynced or wedged stream refuses *everything* +/// and would otherwise be reported as a discovery. The control runs after each +/// probe, and a control failure aborts rather than being written down as data. +/// +/// `D710_PROBE="9C00,A000,C000,E000,FE00"` — hex addresses, defaulting to a +/// spread across the unrequested span. Read-only; nothing is written. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_probe_addr() { + use super::kenwood_tmd710::image::{ProgramMode, APRS_LIVE}; + + /// Inside the APRS block, which every dump has answered — so a failure here + /// is the session, never the address. + const CONTROL: u16 = APRS_LIVE; + + let spec = std::env::var("D710_PROBE").unwrap_or_else(|_| "9C00,A000,C000,E000,FE00".into()); + let addrs: Vec = spec + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|a| u16::from_str_radix(a, 16).unwrap_or_else(|_| panic!("address {a:?} is not hex"))) + .collect(); + assert!(!addrs.is_empty(), "D710_PROBE named no addresses"); + + 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"); + + // The control has to work before any probe, or the whole run means nothing. + let baseline = prog + .read(CONTROL, 16) + .unwrap_or_else(|e| panic!("the control read at 0x{CONTROL:04X} failed before any probe: {e}")); + println!("\ncontrol 0x{CONTROL:04X} answers: {}", show(&baseline)); + + let mut answered: Vec<(u16, Vec)> = vec![]; + let mut refused: Vec<(u16, String)> = vec![]; + for addr in addrs { + match prog.read(addr, 16) { + Ok(data) => { + println!(" 0x{addr:04X} ANSWERED {}", show(&data)); + answered.push((addr, data)); + } + Err(e) => { + println!(" 0x{addr:04X} refused {e}"); + refused.push((addr, e)); + } + } + // ⚠ A refusal is only a measurement if the session survived it. + match prog.read(CONTROL, 16) { + Ok(c) if c == baseline => {} + Ok(c) => panic!( + "the control at 0x{CONTROL:04X} changed after probing 0x{addr:04X} \ + ({} -> {}) — the stream is out of step and nothing after this is data", + show(&baseline), + show(&c) + ), + Err(e) => panic!( + "the control at 0x{CONTROL:04X} failed after probing 0x{addr:04X}: {e}. \ + The session is wedged, so that probe's result is NOT a refusal by the radio. \ + Power-cycle the radio and re-run with fewer addresses." + ), + } + } + prog.leave().expect("leave"); + + println!("\n{} answered, {} refused, control held throughout.", answered.len(), refused.len()); + if answered.is_empty() { + println!( + "★ Every probe refused with a live session between each one. \ + read_plan's ceiling is the radio's, and menus 624-627 are somewhere \ + inside 0x0000-0x9BFF after all — non-contiguous, not missing." + ); + } else { + println!( + "★★★ The read plan is NOT the radio's ceiling. Re-run d710_program_mode_dump_full \ + with the span extended and diff against progfull-71022.bin." + ); + } +} + +/// Dump an arbitrary address span to a file whose offsets are addresses. +/// +/// Written for the span [`read_plan`] never asks for. Session 132's +/// [`d710_probe_addr`] found that `0x9C00`, `0xA000`, `0xC000`, `0xE000` and +/// `0xFE00` all answer — with APRS call signs in them — so MCP-2A's ritual +/// stops well short of what this radio will serve. That is the **third** time +/// an inherited stopping rule has hidden real data here: the first dump stopped +/// at `0x7F00`, the second at `0x9C00`. +/// +/// Same control discipline as [`d710_probe_addr`]: a known-good address is +/// re-read after every block, so a stream that goes out of step aborts the run +/// instead of filling the file with plausible garbage. +/// +/// `D710_SPAN="9C00-FEEF"`, hex, inclusive. Read-only. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_dump_span() { + use super::kenwood_tmd710::image::{ProgramMode, APRS_LIVE}; + + const CONTROL: u16 = APRS_LIVE; + + let spec = std::env::var("D710_SPAN").unwrap_or_else(|_| "9C00-FEEF".into()); + let (lo, hi) = spec.split_once('-').expect("D710_SPAN is LO-HI in hex"); + let lo = u32::from_str_radix(lo.trim(), 16).expect("LO is not hex"); + let hi = u32::from_str_radix(hi.trim(), 16).expect("HI is not hex"); + assert!(lo < hi && hi < 0x1_0000, "span 0x{lo:04X}-0x{hi:04X} is not inside the address space"); + + 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 baseline = prog.read(CONTROL, 16).expect("control read before the dump"); + + let mut buf = vec![0xFFu8; 0x1_0000]; + let mut got: Vec<(u32, u32)> = vec![]; + let mut addr = lo; + while addr <= hi { + let want = (hi - addr + 1).min(256) as usize; + let len = if want == 256 { 0u8 } else { want as u8 }; + match prog.read(addr as u16, len) { + Ok(data) => { + buf[addr as usize..addr as usize + data.len()].copy_from_slice(&data); + got.push((addr, addr + data.len() as u32)); + addr += data.len() as u32; + } + Err(e) => { + println!("0x{addr:04X} refused: {e}"); + // A refusal is a hole, not necessarily the end — that is the whole + // lesson of 0x7F00. Step over it and keep going. + let alive = prog.read(CONTROL, 16); + match alive { + Ok(c) if c == baseline => {} + _ => panic!("the session died at 0x{addr:04X}; nothing after it would be data"), + } + addr += 256; + continue; + } + } + if (addr / 256) % 8 == 0 { + let c = prog.read(CONTROL, 16).expect("control read mid-dump"); + assert_eq!(c, baseline, "the control moved mid-dump — the stream is out of step"); + } + } + prog.leave().expect("leave"); + + let total: u32 = got.iter().map(|(a, b)| b - a).sum(); + let out = format!( + "{}/scratchpad/kenwood_tmd710/span-{lo:04X}-{hi:04X}-{}.bin", + env!("CARGO_MANIFEST_DIR").trim_end_matches("/src-tauri"), + std::process::id() + ); + std::fs::write(&out, &buf).expect("write the dump"); + println!("\n{total} bytes from 0x{lo:04X}-0x{hi:04X} -> {out}"); + println!("offsets in the file are addresses; FF means never answered."); +} + +/// Put the radio back to a reference dump, writing **only the bytes that +/// actually differ**. +/// +/// [`d710_restore_aprs_block`] covers the 1152 bytes of the live APRS block, +/// which is enough while a measurement pass only pokes there. It is not enough +/// once the operator changes a menu whose byte nobody has located yet — the +/// change can land anywhere, and asking the operator to write down old values +/// first makes them the backup system for a dump this harness already holds. +/// +/// So: read the image, diff it against the reference, and write back the +/// differing runs. ★ Narrow the write to what changed — a restore that rewrites +/// regions it did not need to touch is a bigger operation than the change it is +/// undoing. +/// +/// ⚠ The five bytes at `0x0216`, `0x0222`, `0x0224`, `0x0228` and `0x022E` are +/// operating state that drifts as the operator walks menus. They are **skipped**, +/// not restored: writing them back would be pushing stale state onto a radio +/// that has legitimately moved on, and they are why a "clean" dump still shows +/// five differences. +/// +/// `D710_IMAGE=` names the reference. **Dry-run by default** — it prints +/// what it would write and stops; set `D710_RESTORE=1` to actually write. A +/// harness that can write anywhere in the image should not do so by accident. +#[test] +#[ignore = "requires a TM-D710 on the cable — WRITES to the radio when D710_RESTORE=1"] +fn d710_restore_diff() { + use super::kenwood_tmd710::image::ProgramMode; + + /// Operating state, not settings. See the doc comment. + const VOLATILE: [u32; 5] = [0x0216, 0x0222, 0x0224, 0x0228, 0x022E]; + + let src = std::env::var("D710_IMAGE").expect("set D710_IMAGE to a reference dump"); + let want = std::fs::read(&src).unwrap_or_else(|e| panic!("reading {src}: {e}")); + assert_eq!(want.len(), 0x1_0000, "a reference dump is a 64 KiB addressed file"); + let commit = std::env::var("D710_RESTORE").is_ok_and(|v| v == "1"); + + 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 image = prog.read_image().expect("read the image"); + let have = image.as_addressed_bytes(); + + // Contiguous runs of differing addresses, so the write is as narrow as the + // change and no narrower. + let mut runs: Vec<(u32, u32)> = vec![]; + for a in 0..0x1_0000u32 { + let differs = have[a as usize] != want[a as usize] + && image.slice(a as u16, 1).is_ok() + && !VOLATILE.contains(&a); + match runs.last_mut() { + Some((_, end)) if differs && *end == a => *end = a + 1, + _ if differs => runs.push((a, a + 1)), + _ => {} + } + } + + let total: u32 = runs.iter().map(|(a, b)| b - a).sum(); + println!("\n{} differing run(s), {total} byte(s):", runs.len()); + for (a, b) in &runs { + println!( + " 0x{a:04X}..0x{:04X} radio {} -> reference {}", + b - 1, + show(&have[*a as usize..*b as usize]), + show(&want[*a as usize..*b as usize]) + ); + } + let skipped: Vec = + VOLATILE.into_iter().filter(|a| have[*a as usize] != want[*a as usize]).collect(); + if !skipped.is_empty() { + println!( + " (skipped {} volatile operating-state byte(s): {})", + skipped.len(), + skipped.iter().map(|a| format!("0x{a:04X}")).collect::>().join(", ") + ); + } + + if runs.is_empty() { + println!("\nNothing to restore — the radio already matches {src}."); + return; + } + if !commit { + println!("\nDry run. Set D710_RESTORE=1 to write these back."); + return; + } + + for (a, b) in &runs { + for chunk_start in (*a..*b).step_by(256) { + let n = 256.min(b - chunk_start) as usize; + let data = &want[chunk_start as usize..chunk_start as usize + n]; + prog.write(chunk_start as u16, data) + .unwrap_or_else(|e| panic!("write 0x{chunk_start:04X}: {e}")); + // ⚠ This protocol acknowledges writes that did not commit. + let back = prog + .read(chunk_start as u16, if n == 256 { 0 } else { n as u8 }) + .unwrap_or_else(|e| panic!("read back 0x{chunk_start:04X}: {e}")); + assert_eq!(back, data, "0x{chunk_start:04X} did not take the write"); + } + } + println!("\nRestored {total} byte(s) from {src}; every run read back clean."); +} + +/// ## ✅ RUN AND PASSED on the real radio (s132) +/// +/// 69 memories programmed from codeplug 5 (DEN ANALOG + CHEYENNE, dev DB), 31 +/// channels skipped — every skip a DMR/D-STAR/YSF/P25 repeater this radio +/// cannot do. Pushed from the **app's own Program button**, not this harness. +/// +/// **Verified four ways, in increasing independence:** +/// +/// 1. per-memory read-back inside `program` — proves the radio echoed the write +/// 2. `d710_read_slots`: 000/034/068 populated, **069, 070, 500, 503 and 506 +/// empty**, so the clear covered the whole range and a program is a replace +/// 3. ★★★ **the operator read 000, 034, 068 and 069 off the radio's own +/// screen** — the only step that asks the radio what it calls a slot rather +/// than comparing a read-back to the same computation that produced it. That +/// distinction is what made the BT-9000's "ladder step 3 PASSED" worthless. +/// 4. region diff of a full image dump against the pre-program reference +/// +/// ### ★★ The region diff answered a question the operator asked first +/// +/// *"Do I need to do a settings read first?"* No: the D710 takes the +/// `CodeplugProgrammer` branch of `program_radio`, which never reads +/// `non_channel_settings` — that lookup is in the `ImageProgrammer` branch this +/// radio does not implement. Proven rather than asserted: +/// +/// - the **APRS block differed in 0 bytes** +/// - all **42 `MU` menu parameters** read back identical to the prior capture +/// - the only config-block movement was VFO / current-channel state, outside +/// every known settings offset (`beepon` `0x0350`, `beepvol` `0x0351`, +/// `bright` `0x0368`, PF keys `0x036B`-`0x0370`, `pwron` `0x02E0`) +/// +/// That is the exact defect the BT-9000 shipped in reverse: there, +/// `carries_profile_settings` was false while the whole-image upload rewrote +/// the settings segment on every program. Here there is no image write at all. +/// +/// ### ⚠ What this step did NOT test +/// +/// The ladder's step 3 is "memories, **groups, group names, per-group +/// numbering**". This driver programs **flat memories only** — `preview` +/// reports `zones: 0` — so the groups half is untested because it is +/// unimplemented, not because it passed. The TM-D710 does have memory groups +/// (CHIRP maps group names at `0x7D00`). Recorded as a gap. +/// +/// ### The radio was restored and the restore verified +/// +/// `d710_restore_diff` wrote back 292 runs / 1325 bytes, every run read back +/// clean. A fresh dump then differed from the pre-program reference in **5 +/// bytes, all of them the known volatile operating state, and 0 bytes across +/// the channel map, memories and names.** +/// +/// **Hardware ladder step 3 — the full codeplug**, driven through the app's own +/// pipeline rather than a synthetic payload. +/// +/// [`crate::commands::export::resolve_codeplug_payload`] is the exact function +/// `program_radio` calls, so what this hands the driver is what the button +/// hands it. Only the React form is skipped. +/// +/// ⚠ This is a **full replace**: `program` clears every occupied slot the +/// codeplug does not fill, by design, so a program is never a merge. On this +/// radio it therefore overwrites the operator's memories. Two backups must +/// exist before it runs and both are cheap: +/// +/// 1. `d710_program_mode_dump_full` — the whole image, restorable byte-wise by +/// [`d710_restore_diff`]. +/// 2. `d710_dump_memories` — an `ME`/`MN` transcript over the *other* transport, +/// so a failure in one does not take the backup with it. +/// +/// `program` also writes its own transcript backup before the first byte goes +/// out; this harness refuses to run unless the two above are on disk, because a +/// backup taken by the thing that is about to overwrite you is not independent. +/// +/// **Dry run by default** — resolves, plans and prints, touching no port. Set +/// `D710_LADDER=1` to actually program. +/// +/// ```text +/// CPM_DEV_DB=~/Library/.../com.ww8l.codeplugmagic.dev/codeplug_manager.sqlite3 \ +/// CPM_CODEPLUG=5 D710_PORT=/dev/cu.usbserial-XXXX \ +/// cargo test --lib d710_full_codeplug_ladder -- --ignored --nocapture +/// ``` +#[tokio::test] +#[ignore = "requires a TM-D710 on the cable and the dev database — REPLACES every memory"] +async fn d710_full_codeplug_ladder() { + use crate::commands::export::resolve_codeplug_payload; + use crate::radios::driver::CodeplugProgrammer; + + let db = std::env::var("CPM_DEV_DB").expect("set CPM_DEV_DB to the dev sqlite3 path"); + let codeplug_id: i64 = std::env::var("CPM_CODEPLUG") + .expect("set CPM_CODEPLUG to the codeplug id") + .parse() + .expect("CPM_CODEPLUG is not a number"); + let commit = std::env::var("D710_LADDER").is_ok_and(|v| v == "1"); + + let pool = sqlx::SqlitePool::connect(&format!("sqlite:file:{db}?mode=ro")) + .await + .expect("open the dev db read-only"); + let resolved = resolve_codeplug_payload(&pool, codeplug_id).await.expect("resolve"); + let payload = resolved.payload(); + + let driver = super::kenwood_tmd710::KenwoodTmD710; + let preview = driver.preview(&payload).expect("preview"); + println!( + "\ncodeplug {codeplug_id} -> {}: {} channels to program, {} skipped", + preview.radio, + preview.channels, + preview.skipped.len() + ); + for s in &preview.skipped { + println!(" skipped {:<20} {}", s.name, s.reason); + } + for w in &preview.warnings { + println!(" ⚠ {w}"); + } + + if !commit { + println!("\nDry run — nothing sent to the radio. Set D710_LADDER=1 to program."); + return; + } + + // ⚠ Independent backups, checked here rather than assumed. `program` takes + // its own, but a backup written by the process that is about to overwrite + // you is one failure away from being no backup at all. + let sheet = concat!(env!("CARGO_MANIFEST_DIR"), "/../scratchpad/kenwood_tmd710"); + for f in ["memories.txt", "progfull-71022.bin"] { + let p = format!("{sheet}/{f}"); + assert!( + std::path::Path::new(&p).exists(), + "refusing to program: the independent backup {p} is not on disk" + ); + } + + let port = port_path(); + let backup_dir = std::path::PathBuf::from(format!("{sheet}/ladder-backups")); + let report = driver.program(&port, &payload, &backup_dir).expect("program"); + println!( + "\n{} written, {} cleared\nbackup: {}\n{}", + report.channels_written, report.slots_cleared, report.backup_path, report.note + ); +} + +/// How long the radio needs after `E` before it answers a live-mode command. +/// +/// The two-transport settings write failed on its first `MU` after leaving +/// program mode — **silence**, not `?`, which is a different symptom from the +/// mid-line parser state [`super::kenwood_tmd710::ask_settling`] exists for. +/// Rather than sprinkle a retry over it, measure the gap: enter program mode, +/// read one block, leave, then ask `ID` after a delay, doubling until it answers. +/// +/// A control read before the whole sequence proves the port was good to begin +/// with, so silence afterwards is the radio's answer and not a dead cable. +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_settling_after_program_mode() { + use crate::radios::kenwood_tmd710::image::{ProgramMode, APRS_LIVE}; + use crate::radios::kenwood_tmd710::{ask, open_port}; + + let path = port_path(); + println!("\n=== is it TIME, or is one command swallowed? ===\n"); + + // Two arms, and they give different answers. If a long sleep BEFORE the + // first command works, the radio needs time. If it still draws silence and + // the *second* command works regardless of any sleep, the radio is + // discarding exactly one command and a delay would never have fixed it. + for pre_ms in [0u64, 100, 250, 400, 550, 700, 850, 1000, 1500] { + let mut p = open_port(&path).expect("open"); + let control = ask(&mut *p, "ID").expect("the control read before program mode"); + println!("sleep {pre_ms} ms before the first command (control ID = {control:?})"); + + let mut pm = ProgramMode::enter(&mut *p).expect("enter"); + let _ = pm.read(APRS_LIVE, 0).expect("one block"); + pm.leave().expect("leave"); + + std::thread::sleep(Duration::from_millis(pre_ms)); + for n in 1..=2 { + match ask(&mut *p, "ID") { + Ok(r) => { + println!(" command {n}: {r:?}"); + break; + } + Err(_) => println!(" command {n}: silence"), + } + } + drop(p); + std::thread::sleep(Duration::from_millis(500)); + } + println!("\n--- a long pre-sleep that still draws silence means it is not time\n"); +} + +/// Send one **bare** command and print the reply. +/// +/// ⚠ The safety argument is enforced, not documented: on this protocol a command +/// with a parameter list WRITES and the bare form reads, so anything carrying a +/// space or a comma is refused here. `TX` (which keys the transmitter) and `MC` +/// (which moves the operator's current channel) are refused outright even bare. +/// +/// `D710_ASK=CS` +#[test] +#[ignore = "requires a TM-D710 on the cable"] +fn d710_ask_readonly() { + let cmd = std::env::var("D710_ASK").expect("set D710_ASK to one bare command, e.g. CS"); + let cmd = cmd.trim().to_ascii_uppercase(); + assert!( + !cmd.contains(' ') && !cmd.contains(','), + "{cmd:?} carries a parameter list, which on this radio WRITES. This harness reads." + ); + assert!( + !["TX", "MC"].contains(&cmd.as_str()), + "{cmd} changes the radio: TX keys the transmitter, MC moves the current channel" + ); + + let path = port_path(); + let mut p = open(&path, 57600).expect("open"); + let _ = ask(&mut *p, "ID"); + let (text, raw) = ask(&mut *p, &cmd).expect("ask"); + println!("\n{cmd} -> {text:?}\n raw = {raw:02X?}\n"); +} + +/// Hardware ladder step 4 — **a channel at each edge of the claimed coverage**, +/// through the shipped encoder, into a slot that was empty when the radio was +/// first read. +/// +/// Non-destructive on purpose. The ladder's wording ("count what actually +/// landed against what the app reported") does not require a full program: +/// step 3 already proved the whole-codeplug bookkeeping on this radio. What is +/// *not* proved by step 3 is that the **encoder's own output** is accepted at +/// each edge, because Tim's 39 memories sit nowhere near them. +/// +/// ⚠ It refuses to touch a slot that held a memory as-found — those are the +/// operator's and `memories.txt` is the only copy of them — and it clears the +/// slot again after each frequency, so the radio ends as it started. +#[test] +#[ignore = "requires a TM-D710 on the cable — writes one scratch memory slot"] +fn d710_band_probe() { + use crate::radios::kenwood_tmd710::encode::encode_channel; + use crate::radios::kenwood_tmd710::{open_port, write_memory}; + use crate::models::Channel; + + 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 write 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" + ); + + // The seed row's own numbers: both TX band edges, the receive-only extremes + // measured by `d710_rx_band_sweep`, and one in the 220 gap the radio only + // receives. Plus two that must be REFUSED, so a pass cannot be vacuous. + const INSIDE: [f64; 7] = [118.0, 144.0, 148.0, 224.84, 430.0, 450.0, 523.995]; + const OUTSIDE: [f64; 2] = [117.995, 524.0]; + + let path = port_path(); + let mut p = open_port(&path).expect("open"); + let _ = ask(&mut *p, "ID"); + assert!(ask(&mut *p, "ID").expect("ID").0.contains("TM-D710")); + + let mut landed = Vec::new(); + let mut refused = Vec::new(); + for (mhz, expect_ok) in INSIDE + .iter() + .map(|f| (*f, true)) + .chain(OUTSIDE.iter().map(|f| (*f, false))) + { + let c = Channel { + rx_freq: mhz, + name_short: Some("EDGE".into()), + mode: Some("FM".into()), + ..Default::default() + }; + let mem = match encode_channel(slot, &c) { + Ok(m) => m, + Err(e) => { + println!(" {mhz:>10.3} MHz encoder refused: {e}"); + refused.push(mhz); + continue; + } + }; + match write_memory(&mut *p, &mem) { + Ok(()) => { + println!(" {mhz:>10.3} MHz landed and read back"); + landed.push(mhz); + } + Err(e) => { + println!(" {mhz:>10.3} MHz radio refused: {e}"); + refused.push(mhz); + } + } + // Give the slot back whatever happened. + let _ = ask(&mut *p, &format!("ME {slot:03},C")); + assert_eq!( + ask(&mut *p, &format!("ME {slot:03}")).expect("read back").0, + "N", + "slot {slot:03} was not cleared after {mhz} MHz" + ); + let _ = expect_ok; + } + + println!("\n landed: {landed:?}\n refused: {refused:?}\n"); + assert_eq!( + landed.len(), + INSIDE.len(), + "a frequency inside the seeded coverage did not land — the seed claims \ + coverage the radio does not have, and channels there become silently \ + empty slots" + ); + assert_eq!( + refused.len(), + OUTSIDE.len(), + "a frequency outside the seeded coverage was accepted — the seed is \ + narrower than the radio, so real channels are being dropped" + ); +} 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/radios/wiring.rs b/src-tauri/src/radios/wiring.rs index 4e071e3..2f1c63b 100644 --- a/src-tauri/src/radios/wiring.rs +++ b/src-tauri/src/radios/wiring.rs @@ -494,3 +494,133 @@ fn every_path_that_sends_settings_to_a_radio_checks_their_range_first() { leaving it passing vacuously" ); } + +/// ★★★ A radio whose seed row says it does APRS must have APRS settings in its +/// settings schema. +/// +/// The seed row is the app's own claim about what the radio can do, so it is +/// free evidence about whether the settings schema covers the radio or only +/// covers one transport. The TM-D710 shipped `aprs_capable: true` beside a +/// **correct, fully measured, 35-field schema with no APRS in it** — every field +/// right, and the radio's headline feature missing — because `MU` stops at menu +/// 500 and nobody counted the menus. Nothing looked wrong from the inside; this +/// is the check that would have. +/// +/// Deliberately shallow. It cannot tell whether a schema covers APRS *well*, and +/// a driver could satisfy it with one field. What it catches is the case that +/// actually happened: a whole feature absent while the row advertises it. +#[test] +fn an_aprs_radio_offers_at_least_one_aprs_setting() { + /// ⚠ A **known gap, not an exemption.** The first time this test ran it + /// found a second radio with the same defect: the ID-52 is seeded + /// `aprs_capable: true` and its 173-field schema carries a GPS section — + /// `gps-tx-mode`, the NMEA sentence toggles, `gps-auto-tx-timer` — and no + /// APRS/D-PRS settings at all. No call sign, no SSID, no symbol, no + /// comment, no beacon method. GPS is not APRS, and the schema stops right + /// where the radio's headline data feature starts. + /// + /// It is listed rather than deleted so the gap stays greppable and so a + /// **new** radio still cannot slip through. Remove this entry when the + /// ID-52's APRS menus are measured, not before. + const KNOWN_GAPS: &[&str] = &["Icom ID-52"]; + + let mut checked = 0; + for (name, aprs_capable, schema_json) in crate::seed::model_capability_rows() { + if KNOWN_GAPS.contains(&name) { + continue; + } + if !aprs_capable || schema_json.trim() == "[]" { + // An empty schema is a radio whose settings were never measured at + // all, which is a different and visible state — this test is about + // a schema that looks finished and is not. + continue; + } + checked += 1; + let schema: Vec = + serde_json::from_str(schema_json).unwrap_or_else(|e| panic!("{name}: {e}")); + let aprs = schema.iter().filter(|f| { + let hay = format!( + "{} {}", + f["key"].as_str().unwrap_or_default(), + f["label"].as_str().unwrap_or_default() + ) + .to_ascii_lowercase(); + hay.contains("aprs") + || hay.contains("beacon") + || hay.contains("packet") + || hay.contains("tnc") + }); + assert!( + aprs.count() > 0, + "{name} is seeded aprs_capable: true and its {}-field settings schema has no \ + APRS field. Either the schema stops at one transport's coverage — count the \ + radio's menus against it — or the capability flag is wrong.", + schema.len() + ); + } + assert!(checked > 0, "no APRS radio had a settings schema — this would pass vacuously"); +} + +/// ★★★ Every settings schema may only use field types the profile form can +/// actually render. +/// +/// The TM-D710 shipped 38 fields typed `"enum"`. The form's renderer branches on +/// `"select"`, and its final `else` is a **free-text box** — so every dropdown +/// on that radio rendered as a text input, including a 42-entry CTCSS list. +/// Nothing failed and nothing warned; it just quietly stopped being a menu. +/// +/// ⚠ The lesson is about the *test* that was supposed to catch it. There was +/// already an agreement test pairing the Rust field table against this JSON — +/// and it passed, because both said `"enum"`. **Two artifacts I generate from +/// one sheet agreeing with each other says nothing about whether either is +/// right about a third party.** The frontend is that third party, and this is +/// the assertion that reaches it. +/// +/// Kept deliberately dumb: the list below is read out of `src/lib/types.ts`, so +/// adding a renderer branch updates the guard automatically and removing one +/// breaks every schema that still uses it. +#[test] +fn every_settings_schema_uses_only_field_types_the_form_can_render() { + let types = read(&manifest_dir().join("../src/lib/types.ts")); + let decl = types + .split("export interface SettingField {") + .nth(1) + .and_then(|s| s.split('}').next()) + .expect("SettingField is declared in src/lib/types.ts"); + let line = decl + .lines() + .find(|l| l.trim_start().starts_with("type:")) + .expect("SettingField declares a `type` field"); + let allowed: Vec = line + .split(':') + .nth(1) + .expect("a type union") + .split('|') + .map(|s| s.trim().trim_end_matches(';').trim_matches('"').to_string()) + .filter(|s| !s.is_empty()) + .collect(); + assert!( + allowed.contains(&"select".to_string()) && allowed.len() >= 4, + "the union did not parse as expected: {allowed:?}" + ); + + let mut checked = 0; + for (name, _, schema_json) in crate::seed::model_capability_rows() { + if schema_json.trim() == "[]" { + continue; + } + let schema: Vec = + serde_json::from_str(schema_json).unwrap_or_else(|e| panic!("{name}: {e}")); + for f in &schema { + let t = f["type"].as_str().unwrap_or_default(); + checked += 1; + assert!( + allowed.contains(&t.to_string()), + "{name}: field {:?} is typed {t:?}, which the profile form does not \ + render — it falls through to a free-text box. The form knows {allowed:?}.", + f["key"].as_str().unwrap_or_default() + ); + } + } + assert!(checked > 100, "only {checked} fields checked — this would pass vacuously"); +} diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index 2a831f7..7e66655 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -189,6 +189,24 @@ 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"); + + +#[cfg(test)] +pub(crate) fn model_capability_rows() -> Vec<(&'static str, bool, &'static str)> { + models() + .into_iter() + .map(|m| (m.display_name, m.aprs_capable, m.non_channel_settings_schema)) + .collect() +} fn models() -> Vec { vec![ @@ -919,6 +937,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, + }, ] } @@ -1153,6 +1256,133 @@ mod tests { } } + /// ★ 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); + } + /// `tx_bands`/`rx_bands` are hand-written JSON that the exporter parses /// 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 diff --git a/src-tauri/src/tmd710_settings_schema.json b/src-tauri/src/tmd710_settings_schema.json new file mode 100644 index 0000000..8c3dc45 --- /dev/null +++ b/src-tauri/src/tmd710_settings_schema.json @@ -0,0 +1,949 @@ +[ + { + "key": "section-audio-and-voice", + "label": "Audio and Voice", + "type": "section" + }, + { + "key": "key-beep", + "label": "Key beep (Menu 000)", + "type": "boolean" + }, + { + "key": "beep-volume", + "label": "Beep volume (Menu 001)", + "type": "select", + "options": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7" + ] + }, + { + "key": "external-speaker-mode", + "label": "External speaker mode (Menu 002)", + "type": "select", + "options": [ + "Mode 1", + "Mode 2" + ] + }, + { + "key": "announce", + "label": "Announce (Menu 003)", + "type": "select", + "options": [ + "Off", + "Auto", + "Manual" + ] + }, + { + "key": "language", + "label": "Language (Menu 004)", + "type": "select", + "options": [ + "English", + "Japanese" + ] + }, + { + "key": "voice-volume", + "label": "Voice volume (Menu 005)", + "type": "select", + "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": "section-receive-and-transmit", + "label": "Receive and Transmit", + "type": "section" + }, + { + "key": "vhf-aip", + "label": "VHF AIP (Menu 103)", + "type": "boolean" + }, + { + "key": "uhf-aip", + "label": "UHF AIP (Menu 104)", + "type": "boolean" + }, + { + "key": "squelch-hang-up-time", + "label": "Squelch hang-up time (Menu 106)", + "type": "select", + "options": [ + "Off", + "125 ms", + "250 ms", + "500 ms" + ] + }, + { + "key": "mute-hang-up-time", + "label": "Mute hang-up time (Menu 107)", + "type": "select", + "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": "select", + "options": [ + "3 min", + "5 min", + "10 min" + ] + }, + { + "key": "section-memory-and-echolink", + "label": "Memory and EchoLink", + "type": "section" + }, + { + "key": "memory-recall-method", + "label": "Memory recall method (Menu 201)", + "type": "select", + "options": [ + "All bands", + "Current band" + ] + }, + { + "key": "echolink-speed", + "label": "EchoLink speed (Menu 205)", + "type": "select", + "options": [ + "Fast", + "Slow" + ] + }, + { + "key": "section-dtmf", + "label": "DTMF", + "type": "section" + }, + { + "key": "dtmf-hold", + "label": "DTMF hold (Menu 300)", + "type": "boolean" + }, + { + "key": "dtmf-speed", + "label": "DTMF speed (Menu 302)", + "type": "select", + "options": [ + "Fast", + "Slow" + ] + }, + { + "key": "dtmf-pause", + "label": "DTMF pause (Menu 303)", + "type": "select", + "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": "section-repeater", + "label": "Repeater", + "type": "section" + }, + { + "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": "section-display", + "label": "Display", + "type": "section" + }, + { + "key": "power-on-message", + "label": "Power-on message (Menu 500)", + "type": "text", + "max_length": 8 + }, + { + "key": "display-brightness", + "label": "Display brightness (Menu 501)", + "type": "select", + "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": "select", + "options": [ + "Amber", + "Green" + ] + }, + { + "key": "section-auxiliary", + "label": "Auxiliary", + "type": "section" + }, + { + "key": "microphone-key-lock", + "label": "Microphone key lock (Menu 513)", + "type": "boolean" + }, + { + "key": "scan-resume-method", + "label": "Scan resume method (Menu 514)", + "type": "select", + "options": [ + "Time-operated", + "Carrier-operated", + "Seek" + ] + }, + { + "key": "auto-power-off", + "label": "Auto power off (Menu 516)", + "type": "select", + "options": [ + "Off", + "30 min", + "60 min", + "90 min", + "120 min", + "180 min" + ] + }, + { + "key": "external-data-band", + "label": "External data band (Menu 517)", + "type": "select", + "options": [ + "A band", + "B band", + "TX A-RX B", + "RX A-TX B" + ] + }, + { + "key": "external-data-speed", + "label": "External data speed (Menu 518)", + "type": "select", + "options": [ + "1200 bps", + "9600 bps" + ] + }, + { + "key": "sqc-output-source", + "label": "SQC output source (Menu 520)", + "type": "select", + "options": [ + "Off", + "Busy", + "SQL", + "TX", + "Busy or TX", + "SQL or TX" + ] + }, + { + "key": "auto-pm-store", + "label": "Auto PM store (Menu 521)", + "type": "boolean" + }, + { + "key": "display-partition-bar", + "label": "Display partition bar (Menu 527)", + "type": "boolean" + }, + { + "key": "section-aprs-basic", + "label": "APRS Basic", + "type": "section" + }, + { + "key": "aprs-my-callsign", + "label": "My call sign (Menu 600)", + "type": "text", + "max_length": 9 + }, + { + "key": "aprs-beacon-type", + "label": "Beacon type (Menu 600)", + "type": "select", + "options": [ + "APRS", + "Navitra" + ] + }, + { + "key": "section-aprs-internal-tnc", + "label": "APRS Internal TNC", + "type": "section" + }, + { + "key": "aprs-data-band", + "label": "Data band (Menu 601)", + "type": "select", + "options": [ + "A band", + "B band", + "TX A / RX B", + "RX A / TX B" + ] + }, + { + "key": "aprs-data-speed", + "label": "Packet data speed (Menu 601)", + "type": "select", + "options": [ + "1200 bps", + "9600 bps" + ] + }, + { + "key": "aprs-dcd-sense", + "label": "DCD sense (Menu 601)", + "type": "select", + "options": [ + "D or RxD band", + "Both band", + "Ignore DCD" + ] + }, + { + "key": "aprs-tx-delay", + "label": "TX delay (Menu 601)", + "type": "select", + "options": [ + "100 ms", + "150 ms", + "200 ms", + "300 ms", + "400 ms", + "500 ms", + "750 ms", + "1000 ms" + ] + }, + { + "key": "section-aprs-gps-and-waypoints", + "label": "APRS GPS and Waypoints", + "type": "section" + }, + { + "key": "aprs-gps-baud", + "label": "GPS port baud rate (Menu 602)", + "type": "select", + "options": [ + "2400 bps", + "4800 bps", + "9600 bps" + ] + }, + { + "key": "aprs-gps-input", + "label": "GPS data input (Menu 602)", + "type": "select", + "options": [ + "Off", + "GPS", + "Weather (PeetBros)", + "Weather (Davis)" + ] + }, + { + "key": "aprs-gps-output", + "label": "GPS data output (Menu 602)", + "type": "select", + "options": [ + "Off", + "Waypoint", + "DGPS" + ] + }, + { + "key": "aprs-waypoint-format", + "label": "Waypoint format (Menu 603)", + "type": "select", + "options": [ + "NMEA", + "Magellan", + "Kenwood" + ] + }, + { + "key": "aprs-waypoint-name", + "label": "Waypoint name length (Menu 603)", + "type": "select", + "options": [ + "6-char", + "7-char", + "8-char", + "9-char" + ] + }, + { + "key": "aprs-waypoint-output", + "label": "Waypoint output (Menu 603)", + "type": "select", + "options": [ + "All", + "Local", + "Filtered" + ] + }, + { + "key": "section-aprs-my-position", + "label": "APRS My Position", + "type": "section" + }, + { + "key": "aprs-position-1-name", + "label": "My position 1 name (Menu 605)", + "type": "text", + "max_length": 8 + }, + { + "key": "aprs-position-1-lat", + "label": "My position 1 latitude (Menu 605)", + "type": "text", + "max_length": 12, + "placeholder": "N 40 29.240" + }, + { + "key": "aprs-position-1-lon", + "label": "My position 1 longitude (Menu 605)", + "type": "text", + "max_length": 12, + "placeholder": "W 104 55.840" + }, + { + "key": "aprs-position-2-name", + "label": "My position 2 name (Menu 605)", + "type": "text", + "max_length": 8 + }, + { + "key": "aprs-position-2-lat", + "label": "My position 2 latitude (Menu 605)", + "type": "text", + "max_length": 12, + "placeholder": "N 40 29.240" + }, + { + "key": "aprs-position-2-lon", + "label": "My position 2 longitude (Menu 605)", + "type": "text", + "max_length": 12, + "placeholder": "W 104 55.840" + }, + { + "key": "aprs-position-3-name", + "label": "My position 3 name (Menu 605)", + "type": "text", + "max_length": 8 + }, + { + "key": "aprs-position-3-lat", + "label": "My position 3 latitude (Menu 605)", + "type": "text", + "max_length": 12, + "placeholder": "N 40 29.240" + }, + { + "key": "aprs-position-3-lon", + "label": "My position 3 longitude (Menu 605)", + "type": "text", + "max_length": 12, + "placeholder": "W 104 55.840" + }, + { + "key": "aprs-position-4-name", + "label": "My position 4 name (Menu 605)", + "type": "text", + "max_length": 8 + }, + { + "key": "aprs-position-4-lat", + "label": "My position 4 latitude (Menu 605)", + "type": "text", + "max_length": 12, + "placeholder": "N 40 29.240" + }, + { + "key": "aprs-position-4-lon", + "label": "My position 4 longitude (Menu 605)", + "type": "text", + "max_length": 12, + "placeholder": "W 104 55.840" + }, + { + "key": "aprs-position-5-name", + "label": "My position 5 name (Menu 605)", + "type": "text", + "max_length": 8 + }, + { + "key": "aprs-position-5-lat", + "label": "My position 5 latitude (Menu 605)", + "type": "text", + "max_length": 12, + "placeholder": "N 40 29.240" + }, + { + "key": "aprs-position-5-lon", + "label": "My position 5 longitude (Menu 605)", + "type": "text", + "max_length": 12, + "placeholder": "W 104 55.840" + }, + { + "key": "section-aprs-beacon-information", + "label": "APRS Beacon Information", + "type": "section" + }, + { + "key": "aprs-speed-info", + "label": "Speed information (Menu 606)", + "type": "boolean" + }, + { + "key": "aprs-altitude-info", + "label": "Altitude information (Menu 606)", + "type": "boolean" + }, + { + "key": "aprs-position-ambiguity", + "label": "Position ambiguity (Menu 606)", + "type": "select", + "options": [ + "Off", + "1 digit", + "2 digits", + "3 digits", + "4 digits" + ] + }, + { + "key": "aprs-position-comment", + "label": "Position comment (Menu 607)", + "type": "select", + "options": [ + "Off duty", + "Enroute", + "In service", + "Returning", + "Committed", + "Special", + "Priority", + "Custom 0", + "Custom 1", + "Custom 2", + "Custom 3", + "Custom 4", + "Custom 5", + "Custom 6", + "Emergency!" + ] + }, + { + "key": "section-aprs-status-text", + "label": "APRS Status Text", + "type": "section" + }, + { + "key": "aprs-status-text-1", + "label": "Status text 1 (Menu 608)", + "type": "text", + "max_length": 42 + }, + { + "key": "aprs-status-text-1-rate", + "label": "Status text 1 TX rate (Menu 608)", + "type": "select", + "options": [ + "Off", + "1/1", + "1/2", + "1/3", + "1/4", + "1/5", + "1/6", + "1/7", + "1/8" + ] + }, + { + "key": "aprs-status-text-2", + "label": "Status text 2 (Menu 608)", + "type": "text", + "max_length": 42 + }, + { + "key": "aprs-status-text-2-rate", + "label": "Status text 2 TX rate (Menu 608)", + "type": "select", + "options": [ + "Off", + "1/1", + "1/2", + "1/3", + "1/4", + "1/5", + "1/6", + "1/7", + "1/8" + ] + }, + { + "key": "aprs-status-text-3", + "label": "Status text 3 (Menu 608)", + "type": "text", + "max_length": 42 + }, + { + "key": "aprs-status-text-3-rate", + "label": "Status text 3 TX rate (Menu 608)", + "type": "select", + "options": [ + "Off", + "1/1", + "1/2", + "1/3", + "1/4", + "1/5", + "1/6", + "1/7", + "1/8" + ] + }, + { + "key": "aprs-status-text-4", + "label": "Status text 4 (Menu 608)", + "type": "text", + "max_length": 42 + }, + { + "key": "aprs-status-text-4-rate", + "label": "Status text 4 TX rate (Menu 608)", + "type": "select", + "options": [ + "Off", + "1/1", + "1/2", + "1/3", + "1/4", + "1/5", + "1/6", + "1/7", + "1/8" + ] + }, + { + "key": "aprs-status-text-5", + "label": "Status text 5 (Menu 608)", + "type": "text", + "max_length": 42 + }, + { + "key": "aprs-status-text-5-rate", + "label": "Status text 5 TX rate (Menu 608)", + "type": "select", + "options": [ + "Off", + "1/1", + "1/2", + "1/3", + "1/4", + "1/5", + "1/6", + "1/7", + "1/8" + ] + }, + { + "key": "section-aprs-packet-filter", + "label": "APRS Packet Filter", + "type": "section" + }, + { + "key": "aprs-position-limit", + "label": "Position limit (Menu 609)", + "type": "select", + "options": [ + "Off", + "10", + "20", + "30", + "40", + "50", + "60", + "70", + "80", + "90" + ] + }, + { + "key": "aprs-filter-weather", + "label": "Packet filter: Weather (Menu 609)", + "type": "boolean" + }, + { + "key": "aprs-filter-mobile", + "label": "Packet filter: Mobile (Menu 609)", + "type": "boolean" + }, + { + "key": "aprs-filter-navitra", + "label": "Packet filter: Navitra (Menu 609)", + "type": "boolean" + }, + { + "key": "aprs-filter-digi", + "label": "Packet filter: Digipeater (Menu 609)", + "type": "boolean" + }, + { + "key": "aprs-filter-object", + "label": "Packet filter: Object (Menu 609)", + "type": "boolean" + }, + { + "key": "aprs-filter-others", + "label": "Packet filter: Others (Menu 609)", + "type": "boolean" + }, + { + "key": "section-aprs-beacon-transmission", + "label": "APRS Beacon Transmission", + "type": "section" + }, + { + "key": "aprs-station-icon", + "label": "Station icon (Menu 610)", + "type": "text", + "max_length": 2, + "placeholder": "/-" + }, + { + "key": "aprs-beacon-method", + "label": "Beacon TX method (Menu 611)", + "type": "select", + "options": [ + "Manual", + "PTT", + "Auto" + ] + }, + { + "key": "aprs-decay-algorithm", + "label": "Decay algorithm (Menu 611)", + "type": "boolean" + }, + { + "key": "aprs-proportional-pathing", + "label": "Proportional pathing (Menu 611)", + "type": "boolean" + }, + { + "key": "aprs-beacon-interval", + "label": "Beacon initial interval (Menu 611)", + "type": "select", + "options": [ + "0.2 min", + "0.5 min", + "1 min", + "2 min", + "3 min", + "5 min", + "10 min", + "20 min", + "30 min", + "60 min" + ] + }, + { + "key": "section-aprs-voice-alert-and-timing", + "label": "APRS Voice Alert and Timing", + "type": "section" + }, + { + "key": "aprs-voice-alert", + "label": "Voice alert (Menu 614)", + "type": "boolean" + }, + { + "key": "aprs-voice-alert-ctcss", + "label": "Voice alert CTCSS frequency (Menu 614)", + "type": "select", + "options": [ + "67.0 Hz", + "69.3 Hz", + "71.9 Hz", + "74.4 Hz", + "77.0 Hz", + "79.7 Hz", + "82.5 Hz", + "85.4 Hz", + "88.5 Hz", + "91.5 Hz", + "94.8 Hz", + "97.4 Hz", + "100.0 Hz", + "103.5 Hz", + "107.2 Hz", + "110.9 Hz", + "114.8 Hz", + "118.8 Hz", + "123.0 Hz", + "127.3 Hz", + "131.8 Hz", + "136.5 Hz", + "141.3 Hz", + "146.2 Hz", + "151.4 Hz", + "156.7 Hz", + "162.2 Hz", + "167.9 Hz", + "173.8 Hz", + "179.9 Hz", + "186.2 Hz", + "192.8 Hz", + "203.5 Hz", + "206.5 Hz", + "210.7 Hz", + "218.1 Hz", + "225.7 Hz", + "229.1 Hz", + "233.6 Hz", + "241.8 Hz", + "250.3 Hz", + "254.1 Hz" + ] + }, + { + "key": "aprs-ui-check-time", + "label": "UI check time (seconds) (Menu 617)", + "type": "integer", + "min": 0, + "max": 250 + }, + { + "key": "section-aprs-display-and-sound", + "label": "APRS Display and Sound", + "type": "section" + }, + { + "key": "aprs-rx-beep", + "label": "RX beep (Menu 624)", + "type": "select", + "options": [ + "All", + "All new", + "Mine", + "Message only", + "Off" + ] + }, + { + "key": "aprs-interrupt-display-area", + "label": "Interrupt display area (Menu 625)", + "type": "select", + "options": [ + "Off", + "Half", + "Entire", + "Entire always" + ] + }, + { + "key": "aprs-temperature-unit", + "label": "Temperature unit (Menu 626)", + "type": "select", + "options": [ + "Fahrenheit", + "Celsius" + ] + } +] 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 && (