Skip to content

Kenwood TM-D710: Phase 1 capture and the memory line (#113) - #114

Open
ww8l wants to merge 15 commits into
mainfrom
issue-113-kenwood-tmd710
Open

Kenwood TM-D710: Phase 1 capture and the memory line (#113)#114
ww8l wants to merge 15 commits into
mainfrom
issue-113-kenwood-tmd710

Conversation

@ww8l

@ww8l ww8l commented Aug 22, 2026

Copy link
Copy Markdown
Owner

First half of #113, following the new-radio skill. Phase 1 is complete and measured on the radio; Phase 2 is partial. The radio does not appear in the app yet — that is Phase 3.

The TM-D710 is a fourth programming modality

No clone image and no card file. One ASCII command per memory, \r terminated, the radio answering in kind:

ME 000  ->  ME 000,0447275000,0,2,0,0,1,0,12,12,000,05000000,0,0000000000,0,0
ME 999  ->  N
MU      ->  MU 0,4,0,1,0,4,1,0,10,…      all 42 menu settings in one line

Every existing driver clones an image (UV-5R, TD-H3), writes binary records at flash addresses (AnyTone), or patches a file the radio wrote to a microSD card (FT5D, ID-52, TH-D75). Consequences: a write here is not atomic — a failure halfway leaves the radio half-programmed, which nothing else in this repo can do — and a "backup" is a transcript rather than a blob.

Measured, not inherited

Identity ID TM-D710, K-type — non-G
Baud 57600. CHIRP's kenwood_live.py assumes 9600; this radio is silent there
Empty slot N — 962 of 1000, zero unexpected replies
Round trip 17 ms; all 1000 slots in 17.2 s
Noise floor zero across two MU reads with nothing changed

Two published claims died on contact with the radio, both from the table this was about to generate code from:

  • LA3QMA documents the brightness menu as 0=off, 1=max. It is Menu 501, OFF/LEVEL 1~LEVEL 8. Proven by setting LEVEL 3 and watching p26 go 8 -> 3 alone. Generating a schema from that enum would have shipped an 8-level control as a checkbox. It was ambiguous on value alone — Menu 501 BRIGHTNESS and Menu 504 CONTRAST both default to 8, and the radio read 8 — so it was settled by changing it, not by position.
  • MU is not exhaustive. p28 is Menu 503 and p29 is Menu 507, so 504 CONTRAST, 505 DISPLAY REVERSE and 506 have no MU parameter at all. Settings the operator can see are not all reachable this way.

Also measured: the first command after opening the port can answer ? because a previous write left the parser mid-line. One ? is not a refusal — ask_settling retries once, a second ? is real. That is measured behaviour, not defensive coding.

The Phase 2 gate

memory.rs models the ME line field by field, and its gate is the live-mode form of the byte-identical re-encode every card radio here is held to: all 38 captured slots re-emit character-identically. Field widths are preserved rather than normalised — 0 and 000 are the same number and not the same line.

Fields whose meaning is not established are carried through as the radio's own text, so a slot round-trips long before every field is understood. Shift is decoded against Tim's actual repeaters (447.275 and 145.310 minus, 147.360 plus), not against the documentation that describes them.

The full capture lives in gitignored scratchpad/, so four real lines are embedded in the tests and a second test checks the whole corpus when it is present on this machine.

Capabilities: none, deliberately

The driver identifies and nothing else — the scaffolding stance the FT5D was registered under. It could claim SettingsReader today on the strength of MU and does not: the same capability flag puts a settings write in front of an operator, and nothing has ever been written to this radio. The registry guard carries that reasoning. A D710G is named and refused — different menu set, not measured.

Not done, and what it is waiting on

  • Building an ME line from an app channel — blocked on the tone and DCS index tables. The captured indices (12, 08, 18) have no established meaning here, and a published table would be a guess about what a number means. One measurement pass settles it.
  • Phase 3 — seed entry, tx_bands, and rx_bands modelling the receiver's real gaps. Until then no model names this driver_key, so nothing is reachable from the UI.
  • No write of any kind has been attempted on this radio.

Verification

npm run ci green: 446 tests, clippy -D warnings clean. Nothing to verify in the dev app — with no seed entry, this changes nothing an operator can see, by design.

🤖 Generated with Claude Code

https://claude.ai/code/session_013cZgbB4FwqZ3Z4cdHt6vMH

This was referenced Aug 25, 2026
@ww8l
ww8l force-pushed the issue-113-kenwood-tmd710 branch from 7227826 to abf1835 Compare September 1, 2026 22:16
ww8l and others added 11 commits September 2, 2026 18:00
The TM-D710 is a **live-mode** radio and the fourth programming modality in
this app: no clone image and no card file, one ASCII command per memory,
`\r` terminated, the radio answering in kind. Every other driver here clones
an image, writes binary records at flash addresses, or patches a file the
radio wrote to a microSD card.

Measured on Tim's radio, not inherited:

  ID        -> ID TM-D710        non-G, K-type
  baud      -> 57600             CHIRP's driver assumes 9600; this is silent there
  ME 999    -> N                 an empty slot, 962 of 1000
  round trip-> 17 ms             all 1000 slots in 17.2 s
  MU        -> 42 fields         order anchored at p1 and p26 by single-change diffs

Two published claims died on contact with the radio, both from the table
this was about to generate code from:

- LA3QMA documents menu brightness as `0=off, 1=max`. It is Menu 501,
  `OFF/LEVEL 1~LEVEL 8`. Measured by changing it to LEVEL 3 and watching
  p26 go 8 -> 3 alone. Generating from that enum would have shipped an
  8-level control as a checkbox.
- `MU` is **not exhaustive**: p28 is Menu 503 and p29 is Menu 507, so 504
  CONTRAST, 505 DISPLAY REVERSE and 506 have no parameter at all.

Also measured: the first command after opening the port can answer `?`
because a previous write left the parser mid-line, so one `?` is not a
refusal. `ask_settling` retries once; a second `?` is real.

`memory.rs` models the `ME` line field by field and its gate is the
live-mode form of the byte-identical re-encode every card radio here is
held to: **all 38 captured slots re-emit character-identically.** Fields
whose meaning is not established are carried through as the radio's own
text, so a slot can be read and written back long before every field is
understood. Shift is decoded against Tim's actual repeaters (447.275 and
145.310 minus, 147.360 plus) rather than against the documentation.

The driver identifies and nothing else — the scaffolding stance the FT5D
was registered under. It could claim `SettingsReader` today on the strength
of `MU`, and deliberately does not: the same capability flag would put a
settings write in front of an operator, and **nothing has ever been written
to this radio**. A D710G is named and refused; its menu set is a different
radio's and has not been measured.

`kenwood_tmd710_probe.rs` is the measuring instrument that produced all of
the above, `#[ignore]`d like the FT5D's `hw_probe` so `cargo test` stays
hardware-free.

npm run ci green: 446 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cZgbB4FwqZ3Z4cdHt6vMH
Tim's counter-proposal, and a better shape than mine: build the write path
first, then changes are made from here and verified on the radio's screen —
instead of him setting things by hand for me to read back.

Every write proves itself by reading the slot back and comparing the whole
line. That is not belt-and-braces, it is the only evidence available: this
radio has no checksum and no commit step, so a malformed line draws `?` but
a well-formed line the radio interprets differently draws nothing at all.
The D890UV had a settings field the firmware owned that silently reverted
after a write; read-back is what makes that visible rather than a lie in the
report.

- `write_memory` / `write_name` — write, re-read, refuse to claim success
  unless the line comes back identical
- `write_menu` — `MU` sets all 42 parameters at once, so it returns which
  ones did NOT take rather than throwing. A field the firmware owns is a
  finding, not an exception
- `Menu` — the 42-field line, kept as text. `with_field` pads to the width
  the radio used, because `8` where the radio said `08` shifts every field
  after it: one badly formatted field is not one wrong setting, it is
  potentially forty-two. A too-wide value is refused
- `d710_restore` — puts the radio back from the captured transcript. This is
  what makes writing to a real radio a reasonable thing to do at all

HARDWARE, on Tim's radio:

  Ladder step 1 (identity write) PASSED. Slot 000 read, written back
  identically, re-read: byte-for-byte the same line.

  Ladder step 2 in flight: slots 500-503 were empty (`N`) and now hold known
  tone and DCS values for Tim to read off the radio's own screen. Nothing
  populated was touched.

Also measured, free of radio time: joining the radio's 38 memories to Tim's
channel library on frequency AND callsign, field 9 (Tone) predicts the
library's TX tone 33 right / 0 wrong under "standard 42-tone CTCSS list,
0-indexed", while field 10 (CTCSS) gets 27/6 — so field 9 is the transmit
tone and field 10 is a separate squelch tone. The ENDS of the table are what
slots 500/501 are for; DCS is unmeasured entirely, which is what 502/503
are for.

npm run ci green: 453 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cZgbB4FwqZ3Z4cdHt6vMH
Phase 1 ended owing four readings off the radio's screen, because `023` in
field 11 is both a plausible index and a real DCS code. The radio answers it
itself: it validates a write and refuses it *whole*, leaving the slot exactly
as it was, so acceptance is a measurement.

  754  valid code, invalid index  -> refused, slot stayed N
  103  invalid code, valid index  -> accepted
  104  invalid either way         -> refused, slot kept 103

Takes 103, refuses 754: an index. The same pair on fields 9 and 10 puts both
at 0..=41. So 42 tones and 104 DCS codes, 0-based, all measured rather than
inherited.

The tables are Kenwood's own (TM-D710GA/GE manual, SIGNALING-1/2 — ⚠ that is
the G, and the caveat is written down), and the transcription is checked, not
trusted: joined against Tim's channel library on frequency AND callsign it
reproduces session 120's number exactly, 33 right / 0 wrong. The manual's
printed 01~42 keypad numbers are display order and are not where the 0-based
offset comes from; the join is.

Left unconfirmed on purpose: the DCS list's *order* has no cross-check, since
not one of the radio's 38 memories uses DCS. Memory 503 holds dcs_idx 023 and
should read D134 on the screen. Anything else means this table is wrong.

Also here:

- `ME nnn,C` clears a slot — documented since session 120, never run, now
  proven. `d710_restore` could overwrite a memory but not un-write one, so
  every slot the campaign created was permanent. `d710_clear_slots` closes
  that and refuses any slot present in memories.txt, so it cannot eat one of
  Tim's own channels.
- `d710_read_slots`, read-only. Not `d710_dump_memories`: that one rewrites
  memories.txt, which is the only copy of the radio's as-found state.
- The harness said the cable goes in the main unit's PC port. It does not —
  that is the D710G. Session 120 measured the operation panel's rear COM port
  and the doc never caught up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CrC78t5gKpZi3eJPhYN4tx
The encoder, and the nine field ranges it needed. All of them measured the
same way the tone tables were: this radio validates a write and refuses it
whole, so `d710_field_bounds` sweeps a field on an empty slot, reads back, and
the first refused value is the edge of the enum. Nine fields in eight seconds.

★★ A step that does not divide the frequency is refused. Field 3 came back
NON-CONTIGUOUS — [0,3,5,6,8] at 146.520 — which is the answer rather than a
problem: those are exactly the steps that divide it evenly. Against the table
[5, 6.25, 8.33, 10, 12.5, 15, 20, 25, 30, 50] kHz, four frequencies gave 40
accept/refuse predictions with no misses. 145.050 is what pins index 9 as
50 kHz and not 100 — it divides by one and not the other. A driver emitting a
fixed step would write memories the radio quietly declines to store.

★★ There is no split shift. Shift::Split = 3 came from CHIRP's table; the
radio refuses it with a zero offset, with a 600 kHz offset, and with a TX
frequency present. An odd split is field 14 alone, and a shift or offset
together with field 14 is refused in every combination tried. Fourth published
claim about this radio to die on contact with it. The variant stays parseable
so a line carrying one can be read, and a test asserts nothing the encoder can
be handed ever emits it.

Mode 2 = AM is pinned by the radio's own 118.400 memory — air band — and 0 = FM
by the 37 repeaters beside it; the field takes three values and Menu 102 offers
three modes, so 1 = NFM by elimination rather than by the menu's printed order.

Names: 94 of 95 printable characters survive verbatim, lowercase included. The
comma — the field separator — comes back as '+', and a ninth character is
silently truncated. Both are done in encode_name, which is what keeps
write_name's read-back a real check instead of a guaranteed failure.

The gate runs both ways. Each real memory is decoded into app terms and the
encoder asked to rebuild the line; all 38 do, with three fields excluded and
named. One of those exclusions is something the gate found: ME 040 (144.390,
APRS) is simplex and still carries a 600 kHz offset, so the offset field
persists whether or not the shift uses it.

Then on the radio itself — the check no unit test can make, because a value
this radio dislikes is not an error but a slot that quietly stays as it was:

    d710_encoder_acceptance: 38 encoded memories accepted by the radio

each written to a spare slot, verified by read-back, and cleared.

Still owed and written down, not smoothed over: memory 503 should read D134 on
the screen (the DCS list's order is the last thing a cable cannot settle);
Cross Tone falls back to the transmit tone rather than guessing a flag pair,
since the radio accepts all eight combinations and refusal can measure
nothing; and the split is stored but unconfirmed on the air.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CrC78t5gKpZi3eJPhYN4tx
…Phase 3)

★ rx_bands is measured, not copied out of a manual. The radio refuses an `ME`
line it cannot hold, so coverage is the same accept/refuse question every
other field answered this session: `d710_rx_band_sweep` wrote 1350
frequencies at 1 MHz into a spare slot and bisected both edges to 5 kHz. The
answer is ONE contiguous span, 118.000-523.995 MHz, with no interior gap.

That matters twice. The manual on hand is the TM-D710**G**'s and lists an
800-1300 MHz group this radio refuses outright, so a band table copied from it
would have promised memories that cannot exist — and rx_bands has the worst
failure mode in this project, an out-of-coverage frequency reported as written
while the memory sits empty. Here it cannot: the sweep is the coverage.

⚠ tx_bands is NOT measured and must not be — the only way to ask this radio
what it transmits on is to key it. 144-148 / 430-450 is the K-type allocation
and `TY` answered K,0,3,1,0 on Tim's radio. A TX-modified radio is
under-served by this row, which is the safe direction.

covers_220 is TRUE where the TH-D72 beside it is false. This radio genuinely
hears 220 — Tim's own memory 005 is a 224.840 repeater — so that channel must
land RECEIVE-ONLY rather than be dropped. The Phase 3 gate is that distinction:
a test seeds a real database and runs the four channel shapes through
`channel_fit`, the same function the export preview and every program dialog
call, and asserts the verdicts rather than the band arithmetic. ReceiveOnly and
Excluded are different answers and only one of them keeps the repeater.

Deliberately conservative, each with its reason in the seed comment:

- banks_supported false. The radio has ten memory groups (Menu 203) and they
  are probably the D72's positional hundreds, but that is an inference from a
  sibling radio and this project has a rule about those. A flat 1000-slot pool
  is what the encoder writes today.
- the settings schema is EMPTY. `MU` carries all 42 menu parameters and the
  driver parses them, but none is attributed to a named setting yet. Phase 4.
- max_name_length 8 measured on the wire, not read off Menu 200 — a ninth
  character comes back silently truncated rather than refused.

No migration: seeding INSERTs new (manufacturer, model) rows, so the model
reaches existing databases on the next startup. Verified in dev — it is in the
dev database with the measured bands, returned by the same query the Radios
screen uses, and the app starts clean.

The generic program dialog is capability-driven and this driver still claims no
capability trait, so nothing here puts a write in front of an operator. That
stays true until the hardware ladder is climbed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CrC78t5gKpZi3eJPhYN4tx
Seeding the TM-D710 exposed a hole. `showCable` is

    isProgrammable(model) && (media == null || cableCapable)

so for a non-media radio it is true whatever the driver can do — `media ==
null` was standing in for "has a cable path", which had been true of every
non-media radio until now. The TM-D710 is the first driver registered with a
cable modality and NO capability trait, deliberately, because nothing had been
written to that radio yet.

Result: a live "Program radio" button that walked the operator through a
destructive-write confirmation and then failed at the backend with "Kenwood
TM-D710 cannot be programmed over the cable". Same shape as #65 — the gate
existed, the button just never consulted it.

The port section still belongs there (every registered driver implements
`identify`, and confirming the cable is useful), so `showCable` keeps that job
and a new `canWriteOverCable` gates the write affordances: the Program button
and the backup-then-write-then-verify safety banner, which was promising a run
that could not happen.

The codeplug summary keeps showing — it is the codeplug's FIT against the
model, and the receive-only count is the most useful thing in the dialog for a
radio you cannot write yet — but "Programming 62 channels into" is a promise,
so it reads "This codeplug holds 62 channels for" when nothing can be written.

Verified by rendering the dialog headless against stubbed capabilities: with
none, Close and Identify and no banner; with `program_codeplug`, the banner and
Program radio come back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CrC78t5gKpZi3eJPhYN4tx
…hase 4)

The refusal trick works on `MU` too, and better: where a memory the radio
dislikes is acknowledged and quietly not stored, an out-of-range MENU value
draws an explicit `?`. So `d710_menu_bounds` sweeps a parameter, reads the line
back, and the first refused value is the size of the enum behind that menu.
All 42 in 131 seconds, menu restored exactly.

That is stronger evidence than the sheet the last two Kenwoods were built from,
and it caught FIVE errors in it:

  p2, p6   "volume 0-7"      -> 7 values (0..=6). The manual says "a level from
                               1 to 7", so display = stored PLUS ONE and 7 is
                               refused. A direct mapping would have been off by
                               one across the whole range.
  p26      "brightness off/max" -> 9 values. Menu 501, Off + Level 1-8 — which
                               also settles session 120's 501-vs-504 ambiguity
                               by count alone, since CONTRAST has 16.
  p29,p30  "PF key 0-16"     -> 12 values, NON-CONTIGUOUS: 0x00-0x0A and 0x16.
  p31-p34  "Mic PF key 0-16" -> 32 values, 0x00-0x1F.

35 of 42 are exposed. The other seven — the six PF-key assignments and p25,
which no source names — have measured ranges and undetermined meanings, so they
are left out rather than guessed, with a test asserting they stay out. The
non-contiguous PF-key set is the clearest case for that: even the shape of the
enum is unusual and nothing names the functions.

One sheet, both halves: `gen_tmd710_settings.py` parses `MEASURED.md` and emits
the Rust table and the profile schema together, refusing any row whose option
list is not exactly as long as the range the radio accepted. A test asserts the
pair still agrees afterwards.

The write is a PATCH, never a rebuild. `MU` sets all 42 at once, so anything the
profile does not carry — including all seven unexposed parameters — goes back
exactly as it came; otherwise changing the beep volume would silently rewrite
the operator's PF key assignments. Asserted in a test and again on the radio.

Proven on hardware, through the traits the app actually calls rather than the
command underneath them, because a working read path has twice hidden a dead
write path here:

    d710_settings_roundtrip: read -> write one field -> re-read -> restore
    the whole 42-parameter line came back byte-identical

The driver now claims SettingsReader and SettingsWriter. It claimed neither
before this, deliberately, because "nothing has ever been written to this radio"
was true.

Grading, stated rather than assumed: sizes are MEASURED; option ORDERS are
mostly inferred from the manual and the published table, which agree with each
other and now with the radio on 37 of 42 counts. A printed option list is
display order, not the stored index — that cost the TH-D75 a shipped wrong
meaning. MEASURED.md grades every row and names the four worth a screen check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CrC78t5gKpZi3eJPhYN4tx
The driver claims `CodeplugProgrammer` — record-by-record from the database,
the AnyTone's shape rather than a clone image, because a live-mode radio has no
image to write.

★ This is the only NON-ATOMIC write in the app, and that is a requirement on
the error path rather than a caveat in a doc comment. Every other radio here
commits a whole image or patches a file the radio reads later; this one sends a
memory, waits for the radio to take it, and sends the next, a thousand times.
A failure halfway leaves the radio holding a MIXTURE of the new codeplug and
what it had — nothing else in this repo can do that — so `stopped_at` names the
slot it stopped on, how many landed, and the path to the backup. "Programming
failed" tells an operator nothing when their radio is in that state.

The backup is a transcript: all 1000 `ME`/`MN` lines, in the format
`d710_restore` already reads, taken before the first byte goes out. Failing to
save it aborts the run rather than proceeding unprotected.

Per-channel problems are skips, not failures. A codeplug with one 159.8 Hz tone
still programs the other sixty-one channels and says which one did not go — the
encoder refuses rather than substituting a near value, and its reason is what
the operator sees. Structural problems (wrong model, over capacity) stop the
run instead.

Both filters run, in the order the rest of the app uses them: `exclusion_reason`
first, so the program and the export preview cannot disagree about what is in
the codeplug, then the encoder. A 220 MHz repeater passes both and gets a
memory — receive-only, not dropped — which is the case that separates this
radio from the TH-D72 beside it in the seed.

No zones, scan lists or contacts. The radio has ten memory groups and
program-scan limit pairs and neither has been measured, which is why the seed
row says `banks_supported: false`; channel lists flow into one flat pool.

`sanitize_name` is now shared, because the program path names channels with
`expanded_name` (the app's own disambiguated name) and that string needs the
same 8-character cut and comma substitution the encoder does. Two places doing
it differently would be two different names on the radio for one channel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CrC78t5gKpZi3eJPhYN4tx
Tim pulled settings off the radio and none of the APRS settings were there. He
is right, and it is not a gap in the schema — it is a gap in the TRANSPORT.

`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 a
single one of them, so no amount of work on `MU` reaches them. On a radio whose
headline feature is APRS, that is most of the point of the thing missing.

MCP-2A does not use `MU` either. 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.

    "0M PROGRAM\r"        -> "0M\r"      the display shows PROG MCP
    R <addr:2 BE> <len:1> -> W <addr:2> <len:1> <data>   (len 0 = 256)
                             host sends 06, radio answers 06, then the next
    "E"                   -> 06 0D 00

32 512 bytes, 0x0000-0x7EFF, in 15 seconds.

★★ Two published details were wrong and each cost a dump. Both look exactly
like the radio refusing, and neither is:

  1. The address is BIG-endian. The published note says little-endian, and
     0x0000 — the first address anyone reads — is the same two bytes either
     way, so the error survives being "tested". Stepping to 0x0100 sent `00 01`,
     which the radio read as 0x0001: a 64 KiB dump that came back drifting
     exactly one byte per block, the same content sliding. Settled by reading
     offset 0x10, whose contents were already known from block 0.
  2. The host must acknowledge each block with 0x06 and the radio answers 06.
     Nothing published mentions it. Without it the first R of a session returns
     a block and every one after times out. Three probes died on this before a
     raw stream capture showed a header arriving as `06 57 00 00` — a status
     byte, then W, then the address. I had been reading a stream one byte out
     of step and calling it a refusal.

The map, and it cross-validates: memory channels at 0x1700, 16 bytes each,
frequency as 32-bit LITTLE-endian Hz — `f8 df a8 1a` = 447 275 000 = ME 000,
matching the transcript exactly for 000/001/002. (The frequency inside the
image is little-endian while the address on the wire is big-endian. Assuming
one from the other is how the first dump went wrong.) Memory names at 0x5800,
8 bytes each. Six 394-byte config blocks at 0x0200 + n*0x200 — the live
settings plus PM1-PM5, the radio's five Programmable Memory profiles.

Already located in the APRS half: 0x02E0 is Tim's callsign `WW8L`, FF-padded,
with `HELLO ` at the same offset in the untouched PM blocks. And the 42 MU
parameters are in the image too — the PF-key signature `00 02 14 15 0C 0E`
appears once, at 0x036B — so MU and the image are two views of the same
settings, the same relationship the TH-D72 has.

This commit is the transport and the map only. It is READ-ONLY, and it still
changes the radio's state: entering the mode puts `PROG MCP` on the display and
leaving it there strands the operator until they power-cycle, so the exit runs
inside a `catch_unwind` and `E` is sent whether the dump succeeds or not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CrC78t5gKpZi3eJPhYN4tx
The TM-D710 (#113) shipped a 35-field settings schema built on its `MU`
command. Every field was measured on the radio and every one was correct — and
there was no APRS in it at all, on a radio whose headline feature is APRS and
whose model name says so. Tim found it by pulling settings in the app.

The manual names 115 menus. `MU` reaches 42. The 6xx group is 32 APRS and TNC
menus and `MU` touches none of them — the largest group on the radio. One grep
of the manual would have shown that on day one.

The failure was SCOPE, not accuracy, which is why nothing looked wrong from the
inside: when every measurement is right and the set is simply incomplete, there
is no error to trip over. A published table covers what its author needed, and
the gap between that and the radio is invisible without the radio's own list to
hold it against.

Step 1 gains a census that has to be done BEFORE reading any source in depth:
what the radio is for (a TNC, a GPS, a second receiver — each is a family of
settings, and manufacturer naming often carries it), the complete menu map by
group from the manual, and the transport that reaches each group. A group with
no transport is a finding, not an omission. The gate now requires the numbers,
and `templates/PLAN.md` has a census table to fill in.

Step 6 gains the reconciliation at the other end: "35 fields" is not a result,
"35 of the 42 this transport reaches, and it reaches 42 of the radio's 115" is.
Plus a cheap mechanical version — a model with `aprs_capable: true` and no APRS
field in its schema is a contradiction a test can catch, and the D710 shipped
exactly that pairing for a whole session.

Two traps added. The second is the one that stings: "`MU` is not exhaustive —
three menus have no parameter" had been sitting in the findings since session
120. It was the same fact as "an entire feature is unreachable", written small.
A noted gap is a question — what else, and how would I know? — not a footnote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CrC78t5gKpZi3eJPhYN4tx
…EADME (#113)

`main` moved a long way under this branch — the Binteradio BT-9000 landed as
the eighth radio, taking with it a shared guard this branch had never seen.

`every_seeded_radio_appears_in_the_readme` (radios/wiring.rs) now FAILS CI for
any seeded working driver missing from the README's Supported table, which is
exactly what a nine-driver registry with the TM-D710 still under "Planned"
produces. Moved to Supported, and the row says what this radio actually is:
live-mode programming with no image file, so its backup is a transcript.

Also: the drivers array is 9 now, and the two settings-capability arms merged
rather than one replacing the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM
@ww8l
ww8l force-pushed the issue-113-kenwood-tmd710 branch from 88193c7 to 528f6de Compare September 3, 2026 00:03
ww8l and others added 4 commits September 2, 2026 18:12
`d710_program_mode_dump` walked forward until a read failed, stopped at
`0x7F00`, and reported 32 512 bytes as "the image". It is not the image. The
radio answers nothing at `0x7F00` — re-measured, `timed out after 0 of 4
bytes` — and then answers again at `0x8000`. CHIRP's clone-mode driver skips
exactly that block with the comment `# Skip block 7f !!??` and keeps reading to
`0x9BFF`, plus two odd tails at `0xFEF0` and `0xFF00`.

So about 7 KB has never been read, and it is the only place left for the 32
APRS and TNC menus of the 600-series — the feature this radio is named for.

`d710_program_mode_dump_full` reads the whole ritual and writes a 64 KiB file
with `FF` for anything unread, so a file offset IS a radio address. CHIRP
concatenates its blocks instead, which shifts everything above the hole down by
0x100; that convention is fine for a driver and wrong for a measurement.

`d710_leave_program_mode` exists because a probe that fails mid-block never
reaches its own `E`, and the radio then answers `ID` with nothing at all —
indistinguishable from a dead cable at exactly the moment someone starts
unplugging things.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM
…#113)

Nothing had ever been written to this radio's image. The ladder from the
`new-radio` skill, adapted from a container to a transport, run on status text
3 — empty on Tim's radio, so there was no operator data to lose, and visible on
the radio's own screen, which is the half a read-back cannot supply:

1. identity write of the 42 bytes already there, read back unchanged
2. `CPMAGIC TEST 129` written, read back
3. out of program mode, back in, read again — still there

Rung 3 is the one that counts. This protocol has no checksum and no commit
step, so a value that survives leaving and re-entering the mode is the only
evidence that anything was stored rather than echoed. On the BT-9000 an APRS
block answered 0x06 four times and never changed a byte.

★★ A NARROW write commits. CHIRP writes all 156 blocks wrapped in an
invalidate/revalidate dance — FF over the header bytes at 0x0000 and 0x8000,
every block, then the headers back. This writes 42 bytes, touches no header,
and sticks. That was the open question, and it is the difference between "to
change a status text, rewrite the operator's entire radio" and not.

`write_block` is the counterpart to `read_block` and the asymmetry is the trap:
a READ is acknowledged by the host and the radio answers that acknowledgement;
a WRITE is acknowledged by the radio and there is nothing to send back. 0x0F is
the program-mode error state, not a refusal of the write in hand.

`d710_restore_aprs_block` puts the live block back from a saved full dump, which
is what makes a front-panel measurement pass reversible — the reason to build
the write path before the campaign rather than after it. It writes only the live
block; PM1-5 are the operator's saved profiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM
…driver (#113)

`image.rs` owns the session now: enter, read, write, the full-image ritual, and
`E` from `Drop`. The probes call it, so the code that will eventually read APRS
settings for an operator is the same code that was proven on the radio — 39 840
bytes read back byte-identical to the harness version it replaces.

Three things it encodes that each cost a session:

- the address is big-endian, and 0x0000 reads the same either way, so the error
  survives being tested and the dump drifts one byte per block
- a READ is acknowledged by the host and the radio answers that; a WRITE is
  acknowledged by the radio with nothing to send back. Backwards leaves the
  stream one byte out of step for everything after it
- 0x7F00 is a hole, not the end. `read_plan` skips it the way CHIRP does, and
  `Image::slice` refuses an address that was never read rather than serving the
  FF filler — on this image FF is also an ordinary stored value, so "the field
  is empty" and "the field was never fetched" must not be the same answer

`Drop` sends `E` because a radio left in PROG MCP answers nothing at all, which
looks exactly like a dead cable; it cost a power cycle this session to learn.

Eight tests against a fake that speaks both halves, ASCII then binary. They
cannot prove the wire format — they replay the one measured on the radio — but
they prove the sequencing: the hole skipped, the acknowledgement asymmetry, `E`
sent when the caller never asks, an unread region refused, and an acknowledged
write that did not commit showing up only on read-back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM
Tim, this session: "don't do a bunch of PRs, just wait on it until we're pretty
much done with a radio model."

The standing rule is "open a PR for finished work", and a radio build produces a
lot of things that look finished and are not the deliverable — a transport
proven on hardware, a schema, a ladder rung. The radio is the deliverable.

Keep pushing the branch; that is what runs CI on three OSes and it costs
nothing. One PR, when channels and settings both work and the ladder is climbed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant