From ecb4e8028beba1f15b5efae94b7129ca58f78bce Mon Sep 17 00:00:00 2001 From: ww8l Date: Tue, 1 Sep 2026 21:52:51 -0600 Subject: [PATCH 01/15] Binteradio BT-9000: container, memory encoder, ladder 1-4 (#43) The BT-9000 is one badge on an OEM platform also sold as the Radtel RT-950 Pro, Bajeton BJ-9000 and Tenway TP-900 Pro. The platform names itself in the protocol -- the clone session opens with the ASCII string `PROGRAMBT9000U` -- which is why searching the Binteradio badge finds nothing and searching the Radtel one finds a complete MIT-licensed reverse-engineering of the format. The radio reports its model as `RT-950` whatever the case says, so the handshake checks that token and never the badge. Hardware ladder, on Tim's radio: 1. identity write PASSED 33,024 of 33,152 bytes byte-identical 2. one-name write PASSED and there is no checksum anywhere 3. full codeplug PASSED 30 channels across all 15 zones, via this driver's own encoder and transport 4. band probe CANNOT BE RUN -- see below Four defects in the inherited protocol, each measured here: * A block ACK can take 15 seconds. The first identity write died at 0x8080 with a 3 s timeout and nothing wrong with the data: a flash erase at a segment boundary. * Radio 0x8080-0x80FF is a firmware journal, not VFO storage. Writing it makes the radio append a snapshot of its own VFO state there and discard ours. The write segment stops at 0x80 and a test asserts it. * The APRS block cannot be written. Its payload must go unobfuscated to draw any response, and the 0x06 it then answers is a lie -- the block never changes, verified four times. APRS is read-only here, so `aprs_capable` is false rather than offering a form that does nothing. * Frequencies are little-endian packed BCD, not big-endian, and names have two sentinels: 0x00 for never-named, 0xFF padding once set. Two things the radio taught us that no source did: * It validates NOTHING. It stored 127 in settings fields whose maxima are 9, 2, 3 and 1, and it stored every band probe from 27.5 to 580 MHz. So the band probe cannot be run from the image at all, and tx_bands stays at the manual's 136-174 / 400-520 until each channel is confirmed on the radio itself. Under-claiming excludes a channel visibly; over-claiming writes a dead memory and reports success. * An ACK is not a commit. Every claim here was verified by reading the image back. Channel encodings measured by writing candidate values and reading the radio's own screen: CTCSS, DCS at both ends of a 210-entry table, power (0=High, the reverse of the TD-H3's mapping in this same crate) and bandwidth. Zones are index arithmetic with no names in the radio, so `zones_supported` is false. Settings stay unwired and the schema empty on purpose. Sixteen function fields are measured and screen-confirmed, but nothing carries them to the radio yet, and a settings form with a dead write path is a trap this project has already shipped once. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CrC78t5gKpZi3eJPhYN4tx --- src-tauri/src/db.rs | 13 +- src-tauri/src/radios/binteradio_bt9000/dcs.rs | 241 ++++ .../src/radios/binteradio_bt9000/hw_ladder.rs | 217 ++++ src-tauri/src/radios/binteradio_bt9000/mod.rs | 1062 +++++++++++++++++ src-tauri/src/radios/mod.rs | 1 + src-tauri/src/radios/registry.rs | 17 +- src-tauri/src/seed.rs | 63 + 7 files changed, 1605 insertions(+), 9 deletions(-) create mode 100644 src-tauri/src/radios/binteradio_bt9000/dcs.rs create mode 100644 src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs create mode 100644 src-tauri/src/radios/binteradio_bt9000/mod.rs diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index acedb34..9cdb67d 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -59,8 +59,8 @@ 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 and Kenwood - // TH-D72. (0015 removed + // 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. @@ -69,8 +69,9 @@ mod tests { .await .unwrap(); assert_eq!( - count.0, 7, - "expected the UV-5R, TD-H3, AT-D890UV, FT5D, ID-52, TH-D75 and TH-D72 seeded models" + count.0, 8, + "expected the UV-5R, TD-H3, AT-D890UV, FT5D, ID-52, TH-D75, TH-D72 and BT-9000 \ + seeded models" ); let models: Vec<(String,)> = @@ -81,7 +82,7 @@ mod tests { let names: Vec<&str> = models.iter().map(|m| m.0.as_str()).collect(); assert_eq!( names, - vec!["AT-D890UV", "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", "UV-5R"] ); // Seeding twice must remain idempotent. @@ -90,7 +91,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count2.0, 7, "seeding should be idempotent"); + assert_eq!(count2.0, 8, "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/binteradio_bt9000/dcs.rs b/src-tauri/src/radios/binteradio_bt9000/dcs.rs new file mode 100644 index 0000000..d0f3494 --- /dev/null +++ b/src-tauri/src/radios/binteradio_bt9000/dcs.rs @@ -0,0 +1,241 @@ +//! The radio's DCS code table, generated from the reference implementation +//! and hardware-checked at both ends (issue #43, 2026-09-01). +//! +//! A channel stores DCS as `index_into_this_table + 1` in the first tone byte, +//! second byte zero. That makes the *order* load-bearing: a table off by one +//! entry puts a different, entirely valid-looking tone on every DCS channel, +//! and no byte-level round-trip can catch it. +//! +//! Verified on the radio by writing both ends of the table and reading the +//! radio's own screen: index 1 shows `D023N`, index 210 shows `D754I`. + +/// `(code, inverted)` — `false` is normal polarity (`N`), `true` inverted (`I`). +pub(crate) const DCS_TABLE: [(u16, bool); 210] = [ + (23, false), + (25, false), + (26, false), + (31, false), + (32, false), + (36, false), + (43, false), + (47, false), + (51, false), + (53, false), + (54, false), + (65, false), + (71, false), + (72, false), + (73, false), + (74, false), + (114, false), + (115, false), + (116, false), + (122, false), + (125, false), + (131, false), + (132, false), + (134, false), + (143, false), + (145, false), + (152, false), + (155, false), + (156, false), + (162, false), + (165, false), + (172, false), + (174, false), + (205, false), + (212, false), + (223, false), + (225, false), + (226, false), + (243, false), + (244, false), + (245, false), + (246, false), + (251, false), + (252, false), + (255, false), + (261, false), + (263, false), + (265, false), + (266, false), + (271, false), + (274, false), + (306, false), + (311, false), + (315, false), + (325, false), + (331, false), + (332, false), + (343, false), + (346, false), + (351, false), + (356, false), + (364, false), + (365, false), + (371, false), + (411, false), + (412, false), + (413, false), + (423, false), + (431, false), + (432, false), + (445, false), + (446, false), + (452, false), + (454, false), + (455, false), + (462, false), + (464, false), + (465, false), + (466, false), + (503, false), + (506, false), + (516, false), + (523, false), + (526, false), + (532, false), + (546, false), + (565, false), + (606, false), + (612, false), + (624, false), + (627, false), + (631, false), + (632, false), + (645, false), + (654, false), + (662, false), + (664, false), + (703, false), + (712, false), + (723, false), + (731, false), + (732, false), + (734, false), + (743, false), + (754, false), + (23, true), + (25, true), + (26, true), + (31, true), + (32, true), + (36, true), + (43, true), + (47, true), + (51, true), + (53, true), + (54, true), + (65, true), + (71, true), + (72, true), + (73, true), + (74, true), + (114, true), + (115, true), + (116, true), + (122, true), + (125, true), + (131, true), + (132, true), + (134, true), + (143, true), + (145, true), + (152, true), + (155, true), + (156, true), + (162, true), + (165, true), + (172, true), + (174, true), + (205, true), + (212, true), + (223, true), + (225, true), + (226, true), + (243, true), + (244, true), + (245, true), + (246, true), + (251, true), + (252, true), + (255, true), + (261, true), + (263, true), + (265, true), + (266, true), + (271, true), + (274, true), + (306, true), + (311, true), + (315, true), + (325, true), + (331, true), + (332, true), + (343, true), + (346, true), + (351, true), + (356, true), + (364, true), + (365, true), + (371, true), + (411, true), + (412, true), + (413, true), + (423, true), + (431, true), + (432, true), + (445, true), + (446, true), + (452, true), + (454, true), + (455, true), + (462, true), + (464, true), + (465, true), + (466, true), + (503, true), + (506, true), + (516, true), + (523, true), + (526, true), + (532, true), + (546, true), + (565, true), + (606, true), + (612, true), + (624, true), + (627, true), + (631, true), + (632, true), + (645, true), + (654, true), + (662, true), + (664, true), + (703, true), + (712, true), + (723, true), + (731, true), + (732, true), + (734, true), + (743, true), + (754, true), +]; + +/// The stored byte for a DCS code, or `None` if this radio has no such code. +/// Returns the 1-based table position: index 0 is reserved for "no tone". +pub(crate) fn dcs_to_byte(code: u16, inverted: bool) -> Option { + DCS_TABLE + .iter() + .position(|&(c, i)| c == code && i == inverted) + .map(|p| (p + 1) as u8) +} + +/// Decode a stored DCS byte back to `(code, inverted)`. +pub(crate) fn byte_to_dcs(byte: u8) -> Option<(u16, bool)> { + if byte == 0 { + return None; + } + DCS_TABLE.get(byte as usize - 1).copied() +} diff --git a/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs b/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs new file mode 100644 index 0000000..67d5d35 --- /dev/null +++ b/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs @@ -0,0 +1,217 @@ +//! THROWAWAY (issue #43): hardware ladder steps 3 and 4 for the BT-9000. +//! +//! Steps 1 and 2 — identity write and a one-name write — were run with the +//! scratch tooling and passed; they proved the container and that there is no +//! checksum. What they did NOT prove is that *this driver's* encoder produces +//! an image the radio accepts, so everything here goes through the shipping +//! code: [`patch_image`], [`handshake`], [`upload`], [`download`]. A harness +//! with its own encoder would show the radio accepts something; it would not +//! show that this driver can program it. +//! +//! ```sh +//! CPM_BT9000_PORT=/dev/cu.usbserial-10 \ +//! cargo test --lib binteradio_bt9000::hw_ladder -- --ignored --nocapture +//! ``` +//! +//! ⚠ Both tests WRITE to the radio. Each takes its own backup first and prints +//! the path. ⚠ And an ACK from this radio does not mean a commit — every +//! assertion below is made against a fresh read-back, never against the write. + +use std::path::PathBuf; + +use super::*; +use crate::models::Channel; + +fn port() -> String { + std::env::var("CPM_BT9000_PORT").expect("set CPM_BT9000_PORT to the radio's serial port") +} + +fn backup_dir() -> PathBuf { + PathBuf::from(std::env::var("CPM_BT9000_DIR").unwrap_or_else(|_| ".".to_string())) +} + +fn chan(rx: f64, tx: f64) -> Channel { + Channel { + rx_freq: rx, + tx_freq: Some(tx), + mode: Some("FM".to_string()), + power: Some("High".to_string()), + ..Default::default() + } +} + +fn slot(slot: usize, name: &str, rx: f64, tx: f64) -> SlotChannel { + SlotChannel { slot, name: name.to_string(), channel: chan(rx, tx) } +} + +/// Read, back up, patch with `slots`, write, and read back. Returns the image +/// the radio holds afterwards. +fn program(slots: &[SlotChannel], tag: &str) -> Vec { + let port = port(); + let mut p = open_port(&port).expect("open the port"); + + let hs = handshake(&mut *p).expect("handshake"); + println!(" model {:?}, F blob {}", hs.model, hex(&hs.probe)); + let base = download(&mut *p, &hs).expect("download"); + + let backup = backup_dir().join(format!( + "bt9000-hwladder-{tag}-{}.img", + chrono::Local::now().format("%Y%m%d-%H%M%S") + )); + std::fs::write(&backup, &base).expect("write the backup"); + println!(" backup: {}", backup.display()); + + let mut image = base.clone(); + patch_image(&mut image, slots); + + std::thread::sleep(SETTLE); + let hs = handshake(&mut *p).expect("re-handshake before writing"); + upload(&mut *p, &hs, &image).expect("upload"); + + std::thread::sleep(SETTLE); + let hs = handshake(&mut *p).expect("re-handshake before reading back"); + let after = download(&mut *p, &hs).expect("read back"); + + // Only the regions we own. The VFO journal is the radio's, and comparing it + // would report a difference after every single write. + for seg in WRITE_SEGMENTS { + let r = seg.file_offset..seg.file_offset + seg.length; + assert_eq!( + image[r.clone()], + after[r], + "segment {} did not come back as written", + seg.name + ); + } + after +} + +/// Ladder step 3 — a full codeplug, spread so that **every zone** is exercised. +/// +/// The step-3 rule is to check the memory list in every zone, not just the one +/// the radio powers up on. Here that is mechanical: this radio's zones are +/// index arithmetic, so a channel in the first and last slot of each of the 15 +/// zones proves the whole 960-slot map at once. +#[test] +#[ignore = "writes to a real BT-9000 on the cable"] +fn step3_full_codeplug_reaches_every_zone() { + let mut slots = Vec::new(); + for zone in 0..ZONE_COUNT { + let base = zone * CHANNELS_PER_ZONE; + // First and last slot of the zone, on distinguishable frequencies so a + // misplaced channel is visible on the radio rather than merely absent. + slots.push(slot(base, &format!("Z{:02}FIRST", zone + 1), 145.0 + zone as f64 * 0.1, 145.0 + zone as f64 * 0.1)); + slots.push(slot( + base + CHANNELS_PER_ZONE - 1, + &format!("Z{:02}LAST", zone + 1), + 440.0 + zone as f64 * 0.1, + 440.0 + zone as f64 * 0.1, + )); + } + let after = program(&slots, "step3"); + + let decoded = decode_channels(&after); + assert_eq!(decoded.len(), slots.len(), "channel count on the radio"); + + for zone in 1..=ZONE_COUNT { + let in_zone: Vec<_> = decoded.iter().filter(|c| c.zone == zone).collect(); + assert_eq!(in_zone.len(), 2, "zone {zone} should hold 2 channels"); + println!( + " zone {zone:2}: {} @ {:.4} {} @ {:.4}", + in_zone[0].name, in_zone[0].rx_mhz, in_zone[1].name, in_zone[1].rx_mhz + ); + } + println!("\n CHECK ON THE RADIO: every one of the {ZONE_COUNT} zones holds ZFIRST and ZLAST."); +} + +/// Ladder step 4 — the band probe, and ★ the measurement that turned out to be +/// impossible from the image. +/// +/// The usual shape of this step is: write a channel at each band edge, read +/// back, and see which became a silently empty slot. On most radios an +/// out-of-coverage frequency is dropped, and that is how coverage gets mapped. +/// +/// **Not on this one.** All 13 probes survive, 27.5 MHz and 580 MHz included — +/// frequencies no source claims this radio can even receive. That is the same +/// behaviour measured in the settings block, where it stored `127` in fields +/// whose maxima are 9, 2, 3 and 1: **this radio validates nothing it is +/// written.** It is a store, not a filter. +/// +/// So a passing run here proves the encoder and transport round-trip cleanly, +/// and says *nothing whatever* about band coverage. `tx_bands` can only be +/// widened by selecting each channel on the radio and confirming it tunes and +/// keys up. Until then the seed stays deliberately narrow: an over-claimed +/// `tx_bands` writes a memory the radio cannot use while reporting success. +#[test] +#[ignore = "writes to a real BT-9000 on the cable"] +fn step4_band_probe() { + // (frequency, what it tests) + let probes: &[(f64, &str)] = &[ + (27.500, "CB — the vendor's web copy claims TX here"), + (50.125, "6 m — inside the claimed 18-64 of the reference driver"), + (108.000, "airband bottom — RX only if present at all"), + (136.000, "VHF low edge, manual-stated"), + (145.100, "2 m, known good (the radio shipped with it)"), + (174.000, "VHF high edge, manual-stated"), + (200.000, "F-blob third pair, low edge"), + (223.500, "1.25 m — the band the 'Work Band' menu hints at"), + (260.000, "F-blob third pair, high edge"), + (400.000, "UHF low edge, manual-stated"), + (431.100, "70 cm, known good (the radio shipped with it)"), + (520.000, "UHF high edge, manual-stated"), + (580.000, "above every claim — expected to fail"), + ]; + let slots: Vec = probes + .iter() + .enumerate() + .map(|(i, (mhz, _))| slot(i, &format!("B{:03}", *mhz as u32), *mhz, *mhz)) + .collect(); + + let after = program(&slots, "step4"); + let decoded = decode_channels(&after); + + println!("\n landed frequency note"); + let mut landed = Vec::new(); + for (i, (mhz, note)) in probes.iter().enumerate() { + let got = decoded.iter().find(|c| c.index == i); + let ok = got.map(|c| (c.rx_mhz - mhz).abs() < 1e-6).unwrap_or(false); + if ok { + landed.push(*mhz); + } + println!( + " {:^6} {mhz:>9.3} {note}{}", + if ok { "yes" } else { "NO" }, + match got { + Some(c) if !ok => format!(" [stored as {:.3}]", c.rx_mhz), + None => " [slot is empty]".to_string(), + _ => String::new(), + } + ); + } + println!( + "\n {} of {} probes survived the round trip.", + landed.len(), + probes.len() + ); + + // The two frequencies the radio itself shipped with must always survive. + assert!(landed.contains(&145.100), "2 m round-trip"); + assert!(landed.contains(&431.100), "70 cm round-trip"); + + // ★ The finding, asserted so it cannot quietly stop being true: this radio + // accepts EVERYTHING, so the image is not a band filter and this test can + // never map coverage. If a probe ever does fail to survive, the radio has + // started validating and the band question becomes answerable here — which + // is worth knowing loudly rather than passing silently. + assert_eq!( + landed.len(), + probes.len(), + "this radio has always stored every frequency written to it, in or out of band; \ + a failure here means that changed and the band map can now be measured" + ); + println!( + " ★ Every probe survived, 27.5 and 580 MHz included. This radio stores what it is\n \ + given without validating it, so the IMAGE cannot settle tx_bands. Only selecting\n \ + each channel on the radio and keying up can." + ); +} diff --git a/src-tauri/src/radios/binteradio_bt9000/mod.rs b/src-tauri/src/radios/binteradio_bt9000/mod.rs new file mode 100644 index 0000000..59deeed --- /dev/null +++ b/src-tauri/src/radios/binteradio_bt9000/mod.rs @@ -0,0 +1,1062 @@ +//! Binteradio BT-9000 (issue #43) — clone-mode cable radio. +//! +//! ## What this radio is +//! +//! One badge on an OEM platform sold as the Radtel RT-950 Pro, Bajeton BJ-9000 +//! and Tenway TP-900 Pro. The platform's own name is in the protocol: the clone +//! session opens with the ASCII string `PROGRAMBT9000U`. The radio nonetheless +//! reports its model as `RT-950`, so [`MODEL_TOKEN`] is what the handshake +//! checks — never the badge on the case. +//! +//! 960 channels in 15 fixed zones of 64. Zones have **no names in the radio**: +//! membership is `index / 64`, and the vendor CPS keeps zone labels only in its +//! own `.dat` file. There is nowhere in the clone image to put them. +//! +//! ## Protocol +//! +//! 115200 8N1. Handshake, then a negotiated 4-byte XOR key obfuscates every +//! 0x80-byte payload. Read `0x52` / write `0x57` over six segments, plus an +//! APRS block reached by `0x54` / `0x55` in its own address space. +//! +//! ## Three things measured on the radio that the published map got wrong +//! +//! 1. **A block ACK can take 15 seconds.** The first write here died at +//! `0x8080` with a 3 s timeout and nothing wrong with the data — a flash +//! erase at a segment boundary. See [`ACK_TIMEOUT`]. +//! 2. **`0x8080`–`0x80FF` is a firmware journal, not VFO storage.** Writing it +//! makes the radio append a snapshot of its own VFO state there and discard +//! ours. [`WRITE_SEGMENTS`] stops the VFO segment at `0x80` for this reason, +//! and [`assert_writable`] refuses the address outright. +//! 3. **The APRS block is not writable by any sequence found so far.** Its +//! payload must go unobfuscated to draw any response at all, and even then +//! the `0x06` it answers is a lie — the block never changes. APRS is +//! therefore READ-ONLY here; see [`APRS_WRITE_UNPROVEN`]. +//! +//! ⚠ **An ACK from this radio does not mean the data landed.** Every claim in +//! this module was verified by reading the image back, never by the ACK. +//! +//! ⚠ **The radio does not validate settings writes.** It stored `127` in four +//! fields whose real maxima are 9, 2, 3 and 1. There is no hardware backstop; +//! every bound has to be enforced here. + +pub(crate) mod dcs; +#[cfg(test)] +mod hw_ladder; + +use std::time::Duration; + +use serde::Serialize; +use serialport::{ClearBuffer, SerialPort}; + +use crate::commands::export::SlotChannel; +use crate::models::{Channel, RadioModel}; +use crate::radios::driver::{ + CodeplugProgramReport, DecodedChannelSample, ImageProgramRequest, ImageProgrammer, + RadioDriver, RadioIdentity, +}; + +const BAUD: u32 = 115_200; + +/// Ordinary read timeout. Generous next to the other drivers because this radio +/// answers a block header only after it has served the whole 0x80-byte payload. +const TIMEOUT: Duration = Duration::from_secs(3); + +/// How long a *write* block ACK may take. Measured, not guessed: at 3 s the +/// identity write died at `0x8080`; at 15 s the identical write completed. The +/// stall is a flash erase at a segment boundary, so it is rare but real, and a +/// driver that gives up early leaves the radio half-programmed. +const ACK_TIMEOUT: Duration = Duration::from_secs(15); + +const HANDSHAKE: &[u8] = b"PROGRAMBT9000U"; +const ACK: u8 = 0x06; +const END: u8 = b'E'; +const BLOCK: usize = 0x80; + +/// What the radio answers to `M`, whatever the badge says. A Binteradio-branded +/// BT-9000 reports `RT-950`. +pub(crate) const MODEL_TOKEN: &str = "RT-950"; + +/// Exact clone payload length. A longer buffer is refused: streaming a full +/// `0x0000`–`0xFFFF` dump into the clone space is what permanently degraded the +/// transmit path of another radio on this platform. +pub(crate) const IMAGE_LEN: usize = 33_152; + +const CHANNEL_COUNT: usize = 960; +const ENTRY_LEN: usize = 32; +const NAME_LEN: usize = 12; +pub(crate) const CHANNELS_PER_ZONE: usize = 64; +pub(crate) const ZONE_COUNT: usize = CHANNEL_COUNT / CHANNELS_PER_ZONE; // 15 + +/// The 960 memories are 15 fixed zones of 64, with no zone names anywhere in +/// the image. Held as an invariant so a future edit to any one of these three +/// cannot quietly disagree with the other two. +const _: () = assert!(ZONE_COUNT * CHANNELS_PER_ZONE == CHANNEL_COUNT); + +// ============================================================ +// Segment tables +// ============================================================ + +/// One contiguous clone region. `name` exists because "channels" and "aprs" +/// both start at address `0x0000` — in different command spaces — so an address +/// alone does not identify a segment. +#[derive(Clone, Copy)] +pub(crate) struct Segment { + pub name: &'static str, + pub command: u8, + /// Address as the radio sees it, inside this command's space. + pub address: u16, + /// Offset of this segment's bytes within the assembled image. + pub file_offset: usize, + pub length: usize, +} + +/// Read layout. Reads are permissive on this radio — it will serve any address +/// to `0x52` — so this table defines the image, not the radio's limits. +pub(crate) const READ_SEGMENTS: [Segment; 7] = [ + Segment { name: "channels", command: 0x52, address: 0x0000, file_offset: 0x0000, length: 0x7800 }, + Segment { name: "vfo", command: 0x52, address: 0x8000, file_offset: 0x7800, length: 0x0100 }, + Segment { name: "function", command: 0x52, address: 0x9000, file_offset: 0x7900, length: 0x0100 }, + Segment { name: "dtmf", command: 0x52, address: 0xA000, file_offset: 0x7A00, length: 0x0200 }, + Segment { name: "mod_param", command: 0x52, address: 0xB000, file_offset: 0x7C00, length: 0x0200 }, + Segment { name: "mod_names", command: 0x52, address: 0xD000, file_offset: 0x7E00, length: 0x0300 }, + Segment { name: "aprs", command: 0x54, address: 0x0000, file_offset: 0x8100, length: 0x0080 }, +]; + +/// Write layout. Deliberately NOT the read layout: +/// +/// - `vfo` stops at `0x80`. The second block is the firmware journal. +/// - `aprs` is absent. It does not commit, and a control that runs and fails is +/// worse than one that is not offered. +pub(crate) const WRITE_SEGMENTS: [Segment; 6] = [ + Segment { name: "channels", command: 0x57, address: 0x0000, file_offset: 0x0000, length: 0x7800 }, + Segment { name: "vfo", command: 0x57, address: 0x8000, file_offset: 0x7800, length: 0x0080 }, + Segment { name: "function", command: 0x57, address: 0x9000, file_offset: 0x7900, length: 0x0100 }, + Segment { name: "dtmf", command: 0x57, address: 0xA000, file_offset: 0x7A00, length: 0x0200 }, + Segment { name: "mod_param", command: 0x57, address: 0xB000, file_offset: 0x7C00, length: 0x0200 }, + Segment { name: "mod_names", command: 0x57, address: 0xD000, file_offset: 0x7E00, length: 0x0300 }, +]; + +/// Address ranges that must never be written in the `0x52`/`0x57` space. +/// +/// `0x7800`–`0x7FFF` is the gap the vendor CPS skips. `0x8080`–`0x80FF` is the +/// VFO journal. Both were implicated in the damage report on this platform. +fn forbidden(address: u16) -> bool { + (0x7800..0x8000).contains(&address) || (0x8080..0x8100).contains(&address) +} + +/// Panics if any write segment reaches a forbidden address. Called by a test, +/// so the table cannot drift back to the unsafe layout unnoticed. +#[cfg(test)] +fn assert_writable() { + for seg in WRITE_SEGMENTS { + if seg.command != 0x57 { + continue; + } + for off in (0..seg.length).step_by(BLOCK) { + let addr = seg.address + off as u16; + assert!( + !forbidden(addr), + "write segment {} reaches forbidden address 0x{addr:04X}", + seg.name + ); + } + } +} + +// ============================================================ +// XOR obfuscation +// ============================================================ + +/// The radio's 20 keystream symbols. The negotiation frame we send picks one; +/// the radio derives the same choice from the same bytes. +const ENCRYPT_STRINGS: [&[u8; 4]; 20] = [ + b"BHT ", b"CO 7", b"A ES", b" EIY", b"M PQ", + b"XN Y", b"RVB ", b" HQP", b"W RC", b"MS N", + b" SAT", b"K DH", b"ZO R", b"C SL", b"6RB ", + b" JCG", b"PN V", b"J PK", b"EK L", b"I LZ", +]; + +/// Build the 25-byte `SEND` frame and the key it selects. +/// +/// The vendor software randomises this. We do not, deliberately: the radio +/// derives the key from bytes we choose, so a fixed frame is as valid as a +/// random one and makes a session reproducible when something goes wrong. That +/// two *different* keys decoded the same radio image byte-for-byte is what +/// proved this derivation correct in the first place. +fn encryption_frame() -> ([u8; 25], [u8; 4]) { + let mut frame = [0u8; 25]; + frame[0..4].copy_from_slice(b"SEND"); + // Low nibble 0, high nibble 1 -> the selector lands on frame[5]. + frame[4] = 0x10; + frame[5] = 1; // table row 1, "CO 7" + let code = frame[4]; + let idx = if code & 0x20 != 0 { + (code as usize - 0x20) * 2 + 1 + } else { + (code as usize - 0x10) * 2 + } + 1; + let key = *ENCRYPT_STRINGS[frame[4 + idx] as usize]; + (frame, key) +} + +/// Apply the keystream. Symmetric — the same call encodes and decodes. +/// +/// The skips are the radio's own rule, not an optimisation: a key byte of +/// `0x20`, or a payload byte of `0x00`/`0xFF`/`k`/`k ^ 0xFF`, passes through +/// untouched. Getting this wrong corrupts an image while still round-tripping +/// against itself, which is why it was checked against two different keys. +fn apply_xor(payload: &mut [u8], key: &[u8; 4]) { + for (i, value) in payload.iter_mut().enumerate() { + let k = key[i % 4]; + if k != 0x20 && *value != 0x00 && *value != 0xFF && *value != k && *value != (k ^ 0xFF) { + *value ^= k; + } + } +} + +// ============================================================ +// Serial protocol +// ============================================================ + +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(TIMEOUT) + .open() + .map_err(|e| format!("could not open {port}: {e}")) +} + +fn read_exact(p: &mut dyn SerialPort, n: usize) -> Result, String> { + let mut buf = vec![0u8; n]; + std::io::Read::read_exact(p, &mut buf) + .map_err(|e| format!("timed out reading {n} bytes from the radio: {e}"))?; + Ok(buf) +} + +fn write_all(p: &mut dyn SerialPort, data: &[u8]) -> Result<(), String> { + std::io::Write::write_all(p, data).map_err(|e| format!("serial write failed: {e}")) +} + +/// What the radio reports during the clone handshake. +pub(crate) struct Handshake { + /// The 12-byte model string, trimmed. `RT-950` on every unit seen. + pub model: String, + /// The 16-byte blob the `F` probe returns. Its leading bytes read as packed + /// BCD band edges (`0136 0174 0400 0520 0200 0260 …`), which would make it + /// the radio's own band table — **unproven**, so it is carried as evidence + /// rather than parsed. + pub probe: Vec, + key: [u8; 4], +} + +/// Open a clone session. Harmless: reads nothing but identity. +pub(crate) fn handshake(p: &mut dyn SerialPort) -> Result { + let _ = p.clear(ClearBuffer::All); + + write_all(p, HANDSHAKE)?; + if read_exact(p, 1)?[0] != ACK { + return Err("radio did not acknowledge the clone handshake".into()); + } + + write_all(p, b"F")?; + let probe = read_exact(p, 16)?; + + write_all(p, b"M")?; + let raw = read_exact(p, 12)?; + let model = String::from_utf8_lossy(&raw) + .trim_matches(|c: char| c == '\0' || c == ' ') + .to_string(); + if model != MODEL_TOKEN { + return Err(format!( + "expected a {MODEL_TOKEN} (the BT-9000 reports that model); radio said {model:?}" + )); + } + + let (frame, key) = encryption_frame(); + write_all(p, &frame)?; + if read_exact(p, 1)?[0] != ACK { + return Err("radio did not accept the keystream negotiation".into()); + } + + Ok(Handshake { model, probe, key }) +} + +/// Read the whole clone image. Always exactly [`IMAGE_LEN`] bytes. +pub(crate) fn download(p: &mut dyn SerialPort, hs: &Handshake) -> Result, String> { + let mut image = vec![0u8; IMAGE_LEN]; + for seg in READ_SEGMENTS { + for off in (0..seg.length).step_by(BLOCK) { + let addr = seg.address + off as u16; + let header = [seg.command, (addr >> 8) as u8, addr as u8, BLOCK as u8]; + write_all(p, &header)?; + let reply = read_exact(p, 4 + BLOCK)?; + let start = seg.file_offset + off; + let slice = &mut image[start..start + BLOCK]; + slice.copy_from_slice(&reply[4..]); + apply_xor(slice, &hs.key); + } + } + write_all(p, &[END])?; + Ok(image) +} + +/// Write an image back. Only [`WRITE_SEGMENTS`] is addressed, so the CPS gap, +/// the VFO journal and the APRS block are never touched. +/// +/// Aborts on the first block the radio does not acknowledge. That is not +/// caution for its own sake: streaming past a missing ACK desynchronises the +/// radio's write pointer, and doing so is what damaged a radio on this platform. +pub(crate) fn upload(p: &mut dyn SerialPort, hs: &Handshake, image: &[u8]) -> Result<(), String> { + if image.len() != IMAGE_LEN { + return Err(format!( + "refusing to write a {}-byte image; a BT-9000 clone is exactly {IMAGE_LEN} bytes", + image.len() + )); + } + p.set_timeout(ACK_TIMEOUT) + .map_err(|e| format!("could not extend the serial timeout for writing: {e}"))?; + + for seg in WRITE_SEGMENTS { + for off in (0..seg.length).step_by(BLOCK) { + let addr = seg.address + off as u16; + if forbidden(addr) { + return Err(format!( + "internal error: refusing to write 0x{addr:04X}, a firmware-managed address" + )); + } + let start = seg.file_offset + off; + let mut payload = image[start..start + BLOCK].to_vec(); + apply_xor(&mut payload, &hs.key); + let header = [seg.command, (addr >> 8) as u8, addr as u8, BLOCK as u8]; + write_all(p, &header)?; + write_all(p, &payload)?; + let ack = read_exact(p, 1)?[0]; + if ack != ACK { + return Err(format!( + "radio rejected the block at 0x{addr:04X} (answered 0x{ack:02X}); \ + write stopped there" + )); + } + } + } + write_all(p, &[END])?; + let _ = p.set_timeout(TIMEOUT); + Ok(()) +} + +// ============================================================ +// Container +// ============================================================ + +/// Reject anything that is not a BT-9000 clone image before a byte of it +/// reaches the radio. +pub(crate) fn validate_image(image: &[u8]) -> Result<(), String> { + if image.len() != IMAGE_LEN { + return Err(format!( + "not a BT-9000 image: {} bytes, expected exactly {IMAGE_LEN}", + image.len() + )); + } + Ok(()) +} + +// ============================================================ +// Channel records +// ============================================================ + +#[derive(Serialize, PartialEq, Debug, Clone)] +pub struct Bt9000DecodedChannel { + pub index: usize, + /// 1-based zone, `index / 64 + 1`. The radio stores no zone names. + pub zone: usize, + pub name: String, + pub rx_mhz: f64, + pub tx_mhz: f64, + pub rx_tone: String, + pub tx_tone: String, + pub power: String, + pub narrow: bool, + pub tx_enabled: bool, +} + +/// `0 = High, 1 = Middle, 2 = Low` — confirmed on the radio's own screen. +/// Not the order the manual prints them in, which is the usual trap. +const POWER_LEVELS: [&str; 3] = ["High", "Middle", "Low"]; + +/// Decode a frequency: packed BCD, **least-significant byte first**, in units of +/// 10 Hz. `00 00 51 14` is 145.100 MHz. The published note reads these +/// big-endian, which is wrong for this radio. +fn lbcd_to_hz(b: &[u8]) -> u64 { + let mut v: u64 = 0; + for byte in b.iter().rev() { + v = v * 100 + u64::from((byte >> 4) * 10 + (byte & 0x0F)); + } + v * 10 +} + +fn hz_to_lbcd(hz: u64) -> [u8; 4] { + let mut units = hz / 10; + let mut out = [0u8; 4]; + for slot in out.iter_mut() { + let pair = (units % 100) as u8; + *slot = ((pair / 10) << 4) | (pair % 10); + units /= 100; + } + out +} + +/// Decode a two-byte tone field. +/// +/// `00 00` is off. A zero *second* byte means DCS, and the first byte is a +/// 1-based index into [`dcs::DCS_TABLE`]. Otherwise it is a little-endian u16 +/// of Hz×10. The two cannot collide: the lowest CTCSS tone, 67.0 Hz, is 670, +/// whose high byte is already non-zero. +fn decode_tone(raw: &[u8]) -> String { + match (raw[0], raw[1]) { + (0, 0) => "—".to_string(), + (idx, 0) => match dcs::byte_to_dcs(idx) { + Some((code, inverted)) => format!("DTCS {code:03} {}", if inverted { "I" } else { "N" }), + None => "—".to_string(), + }, + (lo, hi) => { + let value = u16::from(lo) | (u16::from(hi) << 8); + if value == 0xFFFF { + "—".to_string() + } else { + format!("T {:.1}", f64::from(value) / 10.0) + } + } + } +} + +fn encode_ctcss(hz: f64) -> [u8; 2] { + let v = (hz * 10.0).round() as u16; + [v as u8, (v >> 8) as u8] +} + +fn encode_dcs(code: &str, inverted: bool) -> Option<[u8; 2]> { + let numeric: u16 = code.trim().parse().ok()?; + dcs::dcs_to_byte(numeric, inverted).map(|b| [b, 0x00]) +} + +pub(crate) fn decode_channels(image: &[u8]) -> Vec { + let mut out = Vec::new(); + for i in 0..CHANNEL_COUNT { + let rec = &image[i * ENTRY_LEN..(i + 1) * ENTRY_LEN]; + // An empty slot reads as all-0xFF in its frequency field. + if rec[0..4] == [0xFF; 4] { + continue; + } + let rx = lbcd_to_hz(&rec[0..4]); + if rx == 0 { + continue; + } + let flags = rec[15]; + out.push(Bt9000DecodedChannel { + index: i, + zone: i / CHANNELS_PER_ZONE + 1, + name: decode_name(&rec[20..32]), + rx_mhz: rx as f64 / 1e6, + tx_mhz: lbcd_to_hz(&rec[4..8]) as f64 / 1e6, + rx_tone: decode_tone(&rec[8..10]), + tx_tone: decode_tone(&rec[10..12]), + power: POWER_LEVELS[usize::from(rec[14] & 0x0F).min(2)].to_string(), + narrow: flags & 0x40 != 0, + tx_enabled: flags & 0x02 != 0, + }); + } + out +} + +/// Names are plain ASCII. **Two sentinels, not one**: a channel that has never +/// been named is twelve `0x00` bytes, while a named channel is padded with +/// `0xFF`. The published note mentions only `0xFF`, and an encoder that pads a +/// blank channel with it is not reproducing what the radio writes. +fn decode_name(raw: &[u8]) -> String { + raw.iter() + .take_while(|&&b| b != 0xFF && b != 0x00) + .map(|&b| b as char) + .collect::() + .trim_end() + .to_string() +} + +fn name_bytes(name: &str) -> [u8; NAME_LEN] { + let mut out = [0xFFu8; NAME_LEN]; + if name.is_empty() { + return [0x00; NAME_LEN]; + } + for (slot, ch) in out.iter_mut().zip(name.chars().take(NAME_LEN)) { + *slot = if ch.is_ascii() && !ch.is_control() { ch as u8 } else { b' ' }; + } + out +} + +/// `0 = High, 1 = Middle, 2 = Low`, confirmed on the radio's screen for all +/// three. Note this is the reverse of the TD-H3's mapping in this same crate — +/// the reason each driver measures its own rather than sharing a helper. +fn power_index(c: &Channel) -> u8 { + match c.power.as_deref() { + Some(p) if p.eq_ignore_ascii_case("Low") => 2, + Some(p) + if p.eq_ignore_ascii_case("Med") + || p.eq_ignore_ascii_case("Medium") + || p.eq_ignore_ascii_case("Mid") + || p.eq_ignore_ascii_case("Middle") => + { + 1 + } + _ => 0, + } +} + +fn tone_off() -> [u8; 2] { + [0x00, 0x00] +} + +/// DCS for one direction. An unknown code falls back to no tone rather than to +/// a neighbouring table entry: a silently *different* valid tone is worse on a +/// repeater than no tone at all. +fn tone_dtcs(code: &Option, inverted: bool) -> [u8; 2] { + code.as_deref() + .and_then(|c| encode_dcs(c, inverted)) + .unwrap_or_else(tone_off) +} + +/// Same tone-mode vocabulary the other drivers use, so a channel means the same +/// thing on every radio in the library. Returns `(rx, tx)`. +fn encode_tones(c: &Channel) -> ([u8; 2], [u8; 2]) { + let pol = c.dcs_polarity.as_bytes(); + let tx_rev = pol.first() == Some(&b'R'); + let rx_rev = pol.get(1) == Some(&b'R'); + let ctcss = |t: Option| t.map(encode_ctcss).unwrap_or_else(tone_off); + + let mode = c.tone_mode.as_deref().unwrap_or("off"); + if mode.eq_ignore_ascii_case("Tone") { + (tone_off(), ctcss(c.ctcss_uplink)) + } else if mode.eq_ignore_ascii_case("TSQL") { + let t = ctcss(c.ctcss_downlink); + (t, t) + } else if mode.eq_ignore_ascii_case("DTCS") { + ( + tone_dtcs(&c.dcs_code, rx_rev), + tone_dtcs(&c.dcs_code, tx_rev), + ) + } else if mode.eq_ignore_ascii_case("Cross") { + let (txmode, rxmode) = c.cross_mode.split_once("->").unwrap_or(("", "")); + let tx = if txmode.eq_ignore_ascii_case("Tone") { + ctcss(c.ctcss_uplink) + } else if txmode.eq_ignore_ascii_case("DTCS") { + tone_dtcs(&c.dcs_code, tx_rev) + } else { + tone_off() + }; + let rx = if rxmode.eq_ignore_ascii_case("Tone") { + ctcss(c.ctcss_downlink) + } else if rxmode.eq_ignore_ascii_case("DTCS") { + tone_dtcs(&c.dcs_rx_code, rx_rev) + } else { + tone_off() + }; + (rx, tx) + } else { + (tone_off(), tone_off()) + } +} + +/// Build one 32-byte channel record. +/// +/// The TX shift is carried entirely by the stored TX frequency — there is no +/// separate direction field, confirmed against the radio (a −0.600 repeater +/// channel differs from a simplex one only in bytes 4-7). +fn encode_channel(c: &Channel, name: &str, tx_hz: u64) -> [u8; ENTRY_LEN] { + let mut m = [0u8; ENTRY_LEN]; + + m[0..4].copy_from_slice(&hz_to_lbcd((c.rx_freq * 1e6).round() as u64)); + m[4..8].copy_from_slice(&hz_to_lbcd(tx_hz)); + + let (rx_tone, tx_tone) = encode_tones(c); + m[8..10].copy_from_slice(&rx_tone); + m[10..12].copy_from_slice(&tx_tone); + + // 12 = signalling group, 13 = PTT-ID. Both left at "none": neither is a + // channel property this app models, and the radio's own default is 0. + m[14] = power_index(c) & 0x0F; // high nibble is the scrambler, left off + + // bit 1 = TX enable, bit 6 = narrow. Everything else (FHSS, encryption, + // busy lockout, scan-add, AM) stays off — measured defaults, not guesses. + let narrow = !matches!(c.mode.as_deref(), Some(m) if m.eq_ignore_ascii_case("FM")); + m[15] = 0x02 | if narrow { 0x40 } else { 0x00 }; + + // 16-19 = FHSS code, left zero. + m[20..32].copy_from_slice(&name_bytes(name)); + m +} + +/// Patch resolved channel slots into a freshly-read image. +/// +/// Only the channel segment is touched. Slots the codeplug does not fill are +/// **cleared to the radio's own empty form** rather than left alone, so +/// programming a shorter codeplug does not leave stale channels behind. +pub(crate) fn patch_image(image: &mut [u8], slots: &[SlotChannel]) { + for i in 0..CHANNEL_COUNT { + image[i * ENTRY_LEN..(i + 1) * ENTRY_LEN].fill(0xFF); + } + for s in slots { + if s.slot >= CHANNEL_COUNT { + continue; + } + let tx_hz = (crate::commands::export::tx_frequency(&s.channel) * 1e6).round() as u64; + let rec = encode_channel(&s.channel, &s.name, tx_hz); + image[s.slot * ENTRY_LEN..(s.slot + 1) * ENTRY_LEN].copy_from_slice(&rec); + } +} + + +// ============================================================ +// Driver +// ============================================================ + +pub(crate) struct BinteradioBt9000; + +/// Registry entry (see `radios/registry.rs`). +pub(crate) static DRIVER: BinteradioBt9000 = BinteradioBt9000; + +impl RadioDriver for BinteradioBt9000 { + fn key(&self) -> &'static str { + "binteradio_bt9000" + } + + fn display_name(&self) -> &'static str { + "Binteradio BT-9000" + } + + fn baud(&self) -> u32 { + BAUD + } + + fn identify(&self, port: &str) -> Result { + let mut p = open_port(port)?; + let hs = handshake(&mut *p)?; + // Leave the session cleanly. An aborted clone session leaves bytes in + // the radio's buffer that surface as bogus answers to the NEXT command + // — two early reads here returned 0x54 and 0x52 for exactly that + // reason, and were briefly mistaken for protocol findings. + let _ = write_all(&mut *p, &[END]); + Ok(RadioIdentity { + matched: hs.model.clone(), + ident_hex: hex(&hs.probe), + ident_ascii: Some(hs.model), + }) + } + + fn as_image_programmer(&self) -> Option<&dyn ImageProgrammer> { + Some(self) + } +} + +impl ImageProgrammer for BinteradioBt9000 { + fn download_image(&self, port: &str) -> Result<(RadioIdentity, Vec), String> { + let mut p = open_port(port)?; + let hs = handshake(&mut *p)?; + let image = download(&mut *p, &hs)?; + Ok(( + RadioIdentity { + matched: hs.model.clone(), + ident_hex: hex(&hs.probe), + ident_ascii: Some(hs.model), + }, + image, + )) + } + + fn decode_sample(&self, image: &[u8]) -> Vec { + decode_channels(image).into_iter().map(decoded_to_sample).collect() + } + + fn upload_image(&self, port: &str, image: &[u8]) -> Result<(), String> { + validate_image(image)?; + let mut p = open_port(port)?; + let hs = handshake(&mut *p)?; + upload(&mut *p, &hs, image) + } + + fn build_image( + &self, + _model: &RadioModel, + channels: &[SlotChannel], + base: &[u8], + ) -> Result, String> { + validate_image(base)?; + if channels.len() > CHANNEL_COUNT { + return Err(format!( + "{} channels exceed the BT-9000's {CHANNEL_COUNT} memories.", + channels.len() + )); + } + let mut image = base.to_vec(); + patch_image(&mut image, channels); + Ok(image) + } + + /// Download + back up, patch channels into that image, write it back, read + /// back and verify. + /// + /// `req.settings` is ignored on purpose: a channel program leaves every + /// setting exactly as the radio had it. That matters more here than on most + /// radios — this one does not validate a settings write, so an unintended + /// value would be stored rather than rejected. + fn program_codeplug( + &self, + port: &str, + req: &ImageProgramRequest, + ) -> Result { + if req.channels.len() > CHANNEL_COUNT { + return Err(format!( + "Codeplug has {} programmable channels, but the BT-9000 holds only {CHANNEL_COUNT}.", + req.channels.len() + )); + } + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let slug = slug_label(req.label); + let backup_path = req.backup_dir.join(if slug.is_empty() { + format!("bt9000-prewrite-{stamp}.img") + } else { + format!("bt9000-prewrite-{slug}-{stamp}.img") + }); + + let mut p = open_port(port)?; + + // 1. Download + back up. + let hs = handshake(&mut *p)?; + let mut image = download(&mut *p, &hs)?; + std::fs::write(&backup_path, &image) + .map_err(|e| format!("could not write backup {}: {e}", backup_path.display()))?; + + // 2. Patch channels into the image we just read, so every byte we do + // not own goes back exactly as it came. + let channels_written = req.channels.len(); + patch_image(&mut image, req.channels); + + let restore_hint = |e: String| { + crate::radios::driver::with_restore_hint( + e, + &backup_path, + "Keep that file. It is the only copy of what was on the radio before \ + this write, and it can be uploaded back over the same cable.", + ) + }; + + // 3. Write. A fresh session: the radio needs a moment to settle after a + // full read before it will answer again. + std::thread::sleep(SETTLE); + let hs = handshake(&mut *p).map_err(|e| restore_hint(e.to_string()))?; + upload(&mut *p, &hs, &image).map_err(restore_hint)?; + + // 4. Read back and verify. Non-fatal: every block was acknowledged. + // ⚠ But an ACK on this radio does not prove a commit, so the + // read-back is the only real evidence and its absence is reported. + std::thread::sleep(SETTLE); + let (verified, note) = match verify_after_write(&mut *p, &image) { + Ok(result) => result, + Err(e) => ( + false, + Some(format!( + "Write completed, but read-back verification could not run ({e}). \ + This radio acknowledges blocks it does not always commit, so \ + power-cycle it and use Download to confirm before trusting it." + )), + ), + }; + + Ok(CodeplugProgramReport { + channels_written, + slots_cleared: CHANNEL_COUNT - channels_written, + settings_written: None, + verified: Some(verified), + note, + backup_path: backup_path.to_string_lossy().to_string(), + channels: decode_channels(&image).into_iter().map(decoded_to_sample).collect(), + zones_written: 0, + zones_cleared: 0, + scan_lists_written: 0, + scan_lists_cleared: 0, + contacts_written: 0, + contacts_cleared: 0, + expected_path: None, + windows_written: Vec::new(), + skipped: Vec::new(), + warnings: Vec::new(), + }) + } +} + +/// How long the radio needs between a completed session and the next one. A +/// read issued immediately after a write times out; measured at three seconds, +/// given headroom here. +const SETTLE: Duration = Duration::from_secs(5); + +/// Read the image back and compare only the regions we actually wrote. +/// +/// The VFO journal and the APRS block are excluded because the radio owns them: +/// comparing them would report a difference on every single write. +fn verify_after_write( + p: &mut dyn SerialPort, + expected: &[u8], +) -> Result<(bool, Option), String> { + let hs = handshake(p)?; + let actual = download(p, &hs)?; + let mut mismatched = Vec::new(); + for seg in WRITE_SEGMENTS { + let range = seg.file_offset..seg.file_offset + seg.length; + if expected[range.clone()] != actual[range] { + mismatched.push(seg.name); + } + } + if mismatched.is_empty() { + Ok((true, None)) + } else { + Ok(( + false, + Some(format!( + "Read-back does not match what was written ({}). The radio \ + acknowledged every block, which on this model is not proof of a \ + commit — restore from the backup and try again.", + mismatched.join(", ") + )), + )) + } +} + +fn decoded_to_sample(c: Bt9000DecodedChannel) -> DecodedChannelSample { + DecodedChannelSample { + index: c.index, + name: c.name, + rx_mhz: c.rx_mhz, + shift: Some(if !c.tx_enabled { + "RX-only".to_string() + } else if (c.tx_mhz - c.rx_mhz).abs() < 1e-9 { + String::new() + } else { + format!("{:+.3}", c.tx_mhz - c.rx_mhz) + }), + tone: c.rx_tone, + power: c.power, + mode: Some(if c.narrow { "NFM".into() } else { "FM".into() }), + } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02X}")).collect() +} + +/// Filesystem-safe slug for a codeplug label, so several codeplugs for one +/// radio stay distinguishable among the backups. +fn slug_label(label: &str) -> String { + label + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::() + .trim_matches('-') + .to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Two channel records taken verbatim off Tim's radio on 2026-09-01, plus + /// the three the encoding probe wrote and the radio confirmed on its own + /// screen. These are the anchor: everything else in this module is checked + /// against bytes the radio actually authored. + const RADIO_CH1: [u8; 32] = [ + 0x00, 0x00, 0x51, 0x14, 0x00, 0x00, 0x51, 0x14, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x50, 0x4C, 0x55, 0x47, + 0x37, 0x33, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + ]; + /// TONEA: 146.520 simplex, CTCSS 88.5 both, High, Wide. + const PROBE_TONEA: [u8; 32] = [ + 0x00, 0x20, 0x65, 0x14, 0x00, 0x20, 0x65, 0x14, 0x75, 0x03, 0x75, 0x03, + 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x54, 0x4F, 0x4E, 0x45, + 0x41, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + ]; + /// TONEB: 146.940 / −0.600, DCS 023N both, Low, Narrow. + const PROBE_TONEB: [u8; 32] = [ + 0x00, 0x40, 0x69, 0x14, 0x00, 0x40, 0x63, 0x14, 0x01, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x02, 0x42, 0x00, 0x00, 0x00, 0x00, 0x54, 0x4F, 0x4E, 0x45, + 0x42, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + ]; + /// TONEC: 442.000 simplex, RX CTCSS 141.3 / TX DCS 754I, Middle, Wide. + const PROBE_TONEC: [u8; 32] = [ + 0x00, 0x00, 0x20, 0x44, 0x00, 0x00, 0x20, 0x44, 0x85, 0x05, 0xD2, 0x00, + 0x00, 0x00, 0x01, 0x02, 0x00, 0x00, 0x00, 0x00, 0x54, 0x4F, 0x4E, 0x45, + 0x43, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + ]; + + fn image_with(records: &[(usize, [u8; 32])]) -> Vec { + let mut image = vec![0xFFu8; IMAGE_LEN]; + for (slot, rec) in records { + image[slot * ENTRY_LEN..(slot + 1) * ENTRY_LEN].copy_from_slice(rec); + } + image + } + + /// The step-3 gate: decode the radio's own bytes and get the values the + /// radio shows on its screen. Every field here was read off the radio. + #[test] + fn decodes_the_radios_own_records() { + let image = image_with(&[ + (0, RADIO_CH1), + (1, PROBE_TONEA), + (2, PROBE_TONEB), + (3, PROBE_TONEC), + ]); + let ch = decode_channels(&image); + assert_eq!(ch.len(), 4); + + assert_eq!(ch[0].name, "PLUG73"); + assert_eq!(ch[0].rx_mhz, 145.100); + assert_eq!(ch[0].zone, 1); + + assert_eq!(ch[1].name, "TONEA"); + assert_eq!(ch[1].rx_mhz, 146.520); + assert_eq!(ch[1].rx_tone, "T 88.5"); + assert_eq!(ch[1].power, "High"); + assert!(!ch[1].narrow); + + assert_eq!(ch[2].rx_mhz, 146.940); + assert_eq!(ch[2].tx_mhz, 146.340); + assert_eq!(ch[2].rx_tone, "DTCS 023 N"); + assert_eq!(ch[2].power, "Low"); + assert!(ch[2].narrow); + + assert_eq!(ch[3].rx_tone, "T 141.3"); + assert_eq!(ch[3].tx_tone, "DTCS 754 I"); + assert_eq!(ch[3].power, "Middle"); + } + + /// The most valuable test in the process: re-encode the radio's own records + /// from decoded values and get the radio's own bytes back. + #[test] + fn frequency_codec_round_trips_the_radios_bytes() { + for rec in [RADIO_CH1, PROBE_TONEA, PROBE_TONEB, PROBE_TONEC] { + let rx = lbcd_to_hz(&rec[0..4]); + let tx = lbcd_to_hz(&rec[4..8]); + assert_eq!(hz_to_lbcd(rx), rec[0..4], "rx re-encode"); + assert_eq!(hz_to_lbcd(tx), rec[4..8], "tx re-encode"); + } + } + + /// The probe wrote both ends of the 210-entry table and the radio displayed + /// `D023N` and `D754I`. Lock that, because an off-by-one here puts a + /// different but entirely plausible tone on every DCS channel. + #[test] + fn dcs_table_ends_match_the_radio() { + assert_eq!(dcs::dcs_to_byte(23, false), Some(1)); + assert_eq!(dcs::dcs_to_byte(754, true), Some(210)); + assert_eq!(dcs::byte_to_dcs(1), Some((23, false))); + assert_eq!(dcs::byte_to_dcs(210), Some((754, true))); + assert_eq!(dcs::byte_to_dcs(0), None); + assert_eq!(dcs::DCS_TABLE.len(), 210); + } + + /// CTCSS and DCS share two bytes and are told apart by the high byte being + /// zero. Prove they cannot collide across the radio's whole CTCSS range: + /// the lowest tone, 67.0 Hz, already has a non-zero high byte. + #[test] + fn ctcss_and_dcs_encodings_cannot_collide() { + for tenths in 670..=2541 { + let raw = encode_ctcss(f64::from(tenths) / 10.0); + assert_ne!(raw[1], 0, "CTCSS {tenths} would decode as DCS"); + } + assert_eq!(encode_ctcss(88.5), [0x75, 0x03]); + assert_eq!(encode_ctcss(141.3), [0x85, 0x05]); + } + + /// Blank and named channels use *different* pad bytes on this radio. + #[test] + fn names_use_the_radios_two_sentinels() { + assert_eq!(name_bytes(""), [0x00; NAME_LEN]); + assert_eq!(&name_bytes("PLUG73")[..], &RADIO_CH1[20..32]); + assert_eq!(decode_name(&RADIO_CH1[20..32]), "PLUG73"); + assert_eq!(decode_name(&[0x00; NAME_LEN]), ""); + } + + /// The guard that keeps a future edit from restoring the segment table that + /// damaged a radio on this platform. + #[test] + fn write_segments_never_reach_a_firmware_managed_address() { + assert_writable(); + assert!(forbidden(0x7800)); + assert!(forbidden(0x7FFF)); + assert!(forbidden(0x8080)); + assert!(forbidden(0x80FF)); + assert!(!forbidden(0x8000)); + assert!(!forbidden(0x807F)); + } + + /// The read layout defines the image; the write layout must be a strict + /// subset of it, never reaching a byte the read never filled. + #[test] + fn segment_tables_agree() { + assert_eq!( + READ_SEGMENTS.iter().map(|s| s.length).sum::(), + IMAGE_LEN + ); + for w in WRITE_SEGMENTS { + let r = READ_SEGMENTS + .iter() + .find(|r| r.name == w.name) + .expect("every write segment is also read"); + assert_eq!(r.file_offset, w.file_offset); + assert!(w.length <= r.length, "{} writes more than it reads", w.name); + assert!(w.file_offset + w.length <= IMAGE_LEN); + } + assert!( + !WRITE_SEGMENTS.iter().any(|s| s.name == "aprs"), + "the BT-9000's APRS block is read-only: 0x55 answers 0x06 and never commits" + ); + } + + /// Two different keys decoded the same radio image byte-for-byte, which is + /// what proved this rule. Lock its symmetry and its skip conditions. + #[test] + fn xor_is_symmetric_and_skips_what_the_radio_skips() { + let (_, key) = encryption_frame(); + assert_eq!(&key, b"CO 7"); + let original: Vec = (0..=255u8).collect(); + let mut buf = original.clone(); + apply_xor(&mut buf, &key); + apply_xor(&mut buf, &key); + assert_eq!(buf, original, "XOR must be its own inverse"); + + // 0x00 and 0xFF pass through untouched wherever they appear. + let mut sentinels = vec![0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF]; + apply_xor(&mut sentinels, &key); + assert_eq!(sentinels, vec![0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF]); + } + + #[test] + fn image_length_is_enforced() { + assert!(validate_image(&vec![0u8; IMAGE_LEN]).is_ok()); + assert!(validate_image(&vec![0u8; IMAGE_LEN + 1]).is_err()); + assert!(validate_image(&vec![0u8; 0x10000]).is_err()); + } + + #[test] + fn zones_are_positional_only() { + let mut records = Vec::new(); + for slot in [0usize, 63, 64, 959] { + records.push((slot, RADIO_CH1)); + } + let image = image_with(&records); + let ch = decode_channels(&image); + assert_eq!(ch[0].zone, 1); + assert_eq!(ch[1].zone, 1); + assert_eq!(ch[2].zone, 2); + assert_eq!(ch[3].zone, ZONE_COUNT); + } +} diff --git a/src-tauri/src/radios/mod.rs b/src-tauri/src/radios/mod.rs index 63b3121..ca5876e 100644 --- a/src-tauri/src/radios/mod.rs +++ b/src-tauri/src/radios/mod.rs @@ -27,6 +27,7 @@ pub(crate) mod anytone_atd890uv; pub(crate) mod baofeng_uv5r; +pub(crate) mod binteradio_bt9000; pub(crate) mod driver; #[cfg(test)] pub(crate) mod fake_port; diff --git a/src-tauri/src/radios/registry.rs b/src-tauri/src/radios/registry.rs index b0a9ac3..988b913 100644 --- a/src-tauri/src/radios/registry.rs +++ b/src-tauri/src/radios/registry.rs @@ -23,8 +23,9 @@ 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; 7] = [ +static DRIVERS: [&dyn RadioDriver; 8] = [ &super::baofeng_uv5r::DRIVER, + &super::binteradio_bt9000::DRIVER, &super::tidradio_tdh3::DRIVER, &super::anytone_atd890uv::DRIVER, &super::yaesu_ft5d::DRIVER, @@ -75,6 +76,7 @@ mod tests { fn every_driver_key_resolves_and_is_unique() { for key in [ "baofeng_uv5r", + "binteradio_bt9000", "tidradio_tdh3", "anytone_atd890uv", "yaesu_ft5d", @@ -114,6 +116,13 @@ mod tests { // `MU` ASCII command — no clone session involved, which is why // it claims both halves where the card radios claim neither. "kenwood_thd72" => (true, true), + // BT-9000: neither, yet. Its settings block decodes and 16 + // fields are screen-confirmed on the radio, but nothing carries + // them to it. Claiming the capability here would put a settings + // action in front of the operator that reads and never writes + // — the dead-write-path trap this project has already shipped + // once (issue #43). + "binteradio_bt9000" => (false, false), _ => (true, true), }; assert_eq!( @@ -174,8 +183,10 @@ mod tests { #[test] fn all_drivers_identify_but_only_clone_radios_download_images() { for d in all_drivers() { - let expect_image = - matches!(d.key(), "baofeng_uv5r" | "tidradio_tdh3" | "kenwood_thd72"); + let expect_image = matches!( + d.key(), + "baofeng_uv5r" | "tidradio_tdh3" | "kenwood_thd72" | "binteradio_bt9000" + ); assert_eq!( d.as_image_programmer().is_some(), expect_image, diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index 4ffe3e7..9623a4f 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -299,6 +299,69 @@ fn models() -> Vec { ]"#, }, // -------------------------------------------------------- + // 9. Binteradio BT-9000 (issue #43) — analog FM/NFM/AM clone-mode HT. + // One badge on an OEM platform also sold as the Radtel RT-950 Pro, + // Bajeton BJ-9000 and Tenway TP-900 Pro; the radio reports its model + // as "RT-950" whatever the case says. + // + // 960 channels in 15 FIXED zones of 64. Zones carry no names in the + // radio — membership is index/64 and the vendor CPS keeps labels + // only in its own file — so `zones_supported` is false: this app + // would be offering a name the radio cannot store. + // -------------------------------------------------------- + ModelSeed { + manufacturer: "Binteradio", + model: "BT-9000", + driver_key: Some("binteradio_bt9000"), + programming_ui: Some("generic"), + display_name: "Binteradio BT-9000", + analog_capable: true, + dmr_capable: false, + dstar_capable: false, + ysf_capable: false, + nxdn_capable: false, + p25_capable: false, + m17_capable: false, + // The radio HAS APRS and GPS, and its APRS block decodes correctly. + // The flag stays false because the block cannot be WRITTEN: 0x55 + // answers 0x06 and never commits (issue #43, measured four times). + // Claiming the capability would put an APRS form in front of the + // operator that silently does nothing. + aprs_capable: false, + covers_hf: false, + covers_vhf: true, + covers_uhf: true, + covers_220: false, + covers_900: false, + freq_min: 136.0, + freq_max: 520.0, + // ⚠ DELIBERATELY CONSERVATIVE, pending the band probe (ladder step + // 4). The manual states 136-174 and 400-520, and the first two + // pairs of the radio's `F` handshake blob agree. That blob's third + // pair reads 200-260, and the vendor's web copy claims TX on CB and + // 18-32 MHz — none of it measured. Under-claiming excludes a + // channel with a reason the operator can see; over-claiming writes + // a SILENTLY EMPTY memory slot while reporting success. + tx_bands: Some("[[136.0,174.0],[400.0,520.0]]"), + rx_bands: Some("[[136.0,174.0],[400.0,520.0]]"), + memory_channels: 960, + zones_supported: false, + max_zones: None, + channels_per_zone: None, + // Per-channel scan-add flag (byte 15 bit 2), not named scan lists. + scan_lists_supported: false, + max_scan_lists: None, + banks_supported: false, + max_name_length: 12, + export_format: "chirp_csv", + connection_type: "Kenwood K1 (2-pin)", + // Empty on purpose. Sixteen function-block fields are measured and + // screen-confirmed (see scratchpad FINDINGS.md), but the settings + // read/write path is not wired yet, and seeding a schema whose + // fields nothing carries to the radio is the dead-write-path trap. + non_channel_settings_schema: r#"[]"#, + }, + // -------------------------------------------------------- // 2. TIDRADIO TD-H3 — analog FM/NFM/AM, 200 ch, no zones, 8 char. // Wide-RX (18-600) dual/tri-band HT; TX 136-600 on the unlocked // variant (220 MHz behind the TX-220 toggle). Export only (CHIRP From 8b8043061d6a2722250d49b892dec09eabec9541 Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 07:09:06 -0600 Subject: [PATCH 02/15] BT-9000: settings read/write, and the write control that was missing (#43) Wires the BT-9000's non-channel settings through SettingsReader and SettingsWriter, generated from a graded measurement sheet. The sheet is the source. `scratchpad/binteradio_bt9000/MEASURED.md` names ~43 candidate fields in the function block and grades each one, and `gen_bt9000_settings.py` emits the Rust field table and the profile-form schema from one parse of it. Rows that are not settled are reported on stderr and left out. That bar is higher here than on other radios in this crate because this one validates nothing -- it stored 127 in four fields whose maxima are 9, 2, 3 and 1 -- so a wrong encoding is stored rather than refused, and the encoder is the only backstop there is. Three fields are emitted, not sixteen. The earlier claim that "sixteen function fields are measured and screen-confirmed" was wrong twice over: * Only eight ever reached the radio's screen. Two probe batches were written and read back; FINDINGS.md records screen readings for the first batch and none for the second. * An ordered list confirmed at ONE index is not settled. `3 -> DEEP` is equally consistent with the printed order and with any permutation putting DEEP last, which is how the TH-D75 shipped a control that wrote Volume Link when the operator picked Level 1. An enum now needs a second, non-endpoint index before it is emitted. `pass_a.py`, `probe_reverse.py` and `SCREEN-CHECK.md` are the campaign that closes the gap: one function-block write settles 27 fields in a single menu walk, five more are probed alone because live VOX, a short backlight, a keypad lock or a Chinese menu would sabotage the walk, and a reverse diff locates the fields with no known byte -- including Work Band, which is the one desk-reachable lead on tx_bands and 220 MHz. Two defects found while wiring it: * A settings write would have rewritten every channel. `upload` addresses all six write segments -- 33 KB, about four minutes -- to change a squelch level. Added `upload_segments`/`download_segments` and SETTINGS_SEGMENTS so a settings write touches the one 256-byte function segment: 0.35 s, with a test asserting the target. * `write_settings` had no caller anywhere in the UI. The capability was declared, the command registered and the api.ts binding present, but the only two controls reaching `writeRadioSettings` are the TD-H3's and the AnyTone's own program dialogs -- so a radio on the generic UI could read its settings into the form and had no way to send them back. That was ALSO true of the TH-D72, whose settings write is hardware-proven. WriteToRadioBar is gated on `caps.write_settings`, so both radios get it and any future one does automatically. The encoder refuses an out-of-range value rather than clamping, because the radio refuses nothing. A `select` label this app cannot name is skipped with a note instead, so a value another tool left on the radio survives a round trip. Verified in dev against the seeded model: both bars render, the three fields group by menu, and the write is blocked with an explanation while the profile has unsaved edits -- the command sends the stored profile. Nothing new was run against the radio; the hardware ladder is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- src-tauri/src/bt9000_settings_schema.json | 40 ++ .../bt9000_settings_table.rs | 91 ++++ src-tauri/src/radios/binteradio_bt9000/mod.rs | 56 +- .../src/radios/binteradio_bt9000/settings.rs | 487 ++++++++++++++++++ src-tauri/src/radios/registry.rs | 20 +- src-tauri/src/seed.rs | 25 +- src/components/profiles/ProfileEditor.tsx | 160 +++++- 7 files changed, 864 insertions(+), 15 deletions(-) create mode 100644 src-tauri/src/bt9000_settings_schema.json create mode 100644 src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs create mode 100644 src-tauri/src/radios/binteradio_bt9000/settings.rs diff --git a/src-tauri/src/bt9000_settings_schema.json b/src-tauri/src/bt9000_settings_schema.json new file mode 100644 index 0000000..9fbbd0f --- /dev/null +++ b/src-tauri/src/bt9000_settings_schema.json @@ -0,0 +1,40 @@ +[ + { + "key": "section-vox", + "label": "VOX", + "type": "section" + }, + { + "key": "vox-level", + "label": "VOX Level", + "type": "integer", + "min": 1, + "max": 9 + }, + { + "key": "section-radio", + "label": "Radio", + "type": "section" + }, + { + "key": "squelch", + "label": "SQL", + "type": "integer", + "min": 1, + "max": 9 + }, + { + "key": "section-setting", + "label": "Setting", + "type": "section" + }, + { + "key": "power-on-display", + "label": "Power On Display", + "type": "select", + "options": [ + "Picture", + "Voltage" + ] + } +] diff --git a/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs b/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs new file mode 100644 index 0000000..c357fe7 --- /dev/null +++ b/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs @@ -0,0 +1,91 @@ +//! BT-9000 settings field table — GENERATED, do not edit. +//! +//! Source: `scratchpad/binteradio_bt9000/MEASURED.md`, via +//! `gen_bt9000_settings.py`. The profile-form schema at +//! `src/bt9000_settings_schema.json` comes from the same parse, and +//! `settings.rs` asserts the two still describe the same fields. +//! +//! Every field here is graded `screen` in the sheet: its encoding was +//! settled on the radio's own screen, not taken from the source +//! inventory or the manual. That bar is higher here than on other +//! radios in this crate because THIS RADIO VALIDATES NOTHING — it +//! stored 127 in four fields whose maxima are 9, 2, 3 and 1 — so a +//! wrong encoding is stored rather than refused, and this table is the +//! only thing standing between the operator and a bad value. +//! +//! 3 field(s). The sheet's other rows are measured but not +//! settled; see its Tally section for what is still owed. + +/// How a field's value is carried in the byte. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) enum Enc { + /// Stored byte is the value: an enum index, or the displayed number. + Direct, + /// Stored byte is the displayed number minus one. Real on this radio: + /// SQL at 0x00 stores the level, VOX Level at 0x02 stores level − 1, + /// and they sit two bytes apart with the same printed "Level 1-9". + Minus1, +} + +/// What the form draws, and what the encoder may write. +/// +/// ⚠ No settled field is currently a bool, so that +/// variant is unconstructed today. It is kept because the sheet has rows +/// of that kind waiting on a screen check, and deleting it would mean +/// rewriting the encoder when they land. +#[allow(dead_code)] +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) enum Kind { + /// `0 = OFF`, `1 = ON`. + Bool, + /// Zero-based index into `options`, which is in STORED order. + Enum, + /// Displayed number, inclusive of both bounds. + Int { lo: u8, hi: u8 }, +} + +/// One settings field. +pub(crate) struct SF { + pub key: &'static str, + pub label: &'static str, + /// This radio's own menu path. NOT the RT-950 Pro manual's: every + /// item in the Radio group is numbered one higher here, because the + /// BT-9000 inserts Work Band at Radio → 1. + pub menu: &'static str, + /// Offset within the function block (file offset = 0x7900 + addr). + pub addr: usize, + pub kind: Kind, + pub enc: Enc, + /// Enum labels in stored-index order; empty for the other kinds. + pub options: &'static [&'static str], +} + +pub(crate) const FIELDS: [SF; 3] = [ + SF { + key: "vox-level", + label: "VOX Level", + menu: "VOX → 2. VOX Level", + addr: 0x02, + kind: Kind::Int { lo: 1, hi: 9 }, + enc: Enc::Minus1, + options: &[], + }, + SF { + key: "squelch", + label: "SQL", + menu: "Radio → 2. SQL", + addr: 0x00, + kind: Kind::Int { lo: 1, hi: 9 }, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "power-on-display", + label: "Power On Display", + menu: "Setting → 6. Power On Display", + addr: 0x1C, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["Picture", "Voltage"], + }, +]; diff --git a/src-tauri/src/radios/binteradio_bt9000/mod.rs b/src-tauri/src/radios/binteradio_bt9000/mod.rs index 59deeed..e6dab1e 100644 --- a/src-tauri/src/radios/binteradio_bt9000/mod.rs +++ b/src-tauri/src/radios/binteradio_bt9000/mod.rs @@ -39,7 +39,9 @@ //! fields whose real maxima are 9, 2, 3 and 1. There is no hardware backstop; //! every bound has to be enforced here. +pub(crate) mod bt9000_settings_table; pub(crate) mod dcs; +pub(crate) mod settings; #[cfg(test)] mod hw_ladder; @@ -286,8 +288,22 @@ pub(crate) fn handshake(p: &mut dyn SerialPort) -> Result { /// Read the whole clone image. Always exactly [`IMAGE_LEN`] bytes. pub(crate) fn download(p: &mut dyn SerialPort, hs: &Handshake) -> Result, String> { + download_segments(p, hs, &READ_SEGMENTS) +} + +/// Read `segments` only, into an otherwise-zeroed full-length image. +/// +/// The buffer stays [`IMAGE_LEN`] so that a caller reading one segment indexes +/// it with the same offsets as one reading the whole radio. Bytes outside +/// `segments` are zero and must not be written back — [`upload_segments`] with +/// the matching segment list is the only safe partner for this. +pub(crate) fn download_segments( + p: &mut dyn SerialPort, + hs: &Handshake, + segments: &[Segment], +) -> Result, String> { let mut image = vec![0u8; IMAGE_LEN]; - for seg in READ_SEGMENTS { + for seg in segments.iter().copied() { for off in (0..seg.length).step_by(BLOCK) { let addr = seg.address + off as u16; let header = [seg.command, (addr >> 8) as u8, addr as u8, BLOCK as u8]; @@ -303,6 +319,19 @@ pub(crate) fn download(p: &mut dyn SerialPort, hs: &Handshake) -> Result Ok(image) } +/// The function-configuration segment, on its own. +/// +/// A settings write must not go out through the whole-image [`upload`]: that +/// rewrites all 960 channel records to change a squelch level, taking four +/// minutes instead of a third of a second and putting the operator's memories +/// at risk for a change that never touched them. Narrowing a transport that has +/// the reach to write everything is a deliberate act, not an optimisation. +pub(crate) const SETTINGS_SEGMENTS: [Segment; 1] = [WRITE_SEGMENTS[2]]; + +/// Where the function block sits in the assembled image, and how long it is. +pub(crate) const FUNCTION_OFFSET: usize = 0x7900; +pub(crate) const FUNCTION_LEN: usize = 0x0100; + /// Write an image back. Only [`WRITE_SEGMENTS`] is addressed, so the CPS gap, /// the VFO journal and the APRS block are never touched. /// @@ -310,6 +339,21 @@ pub(crate) fn download(p: &mut dyn SerialPort, hs: &Handshake) -> Result /// caution for its own sake: streaming past a missing ACK desynchronises the /// radio's write pointer, and doing so is what damaged a radio on this platform. pub(crate) fn upload(p: &mut dyn SerialPort, hs: &Handshake, image: &[u8]) -> Result<(), String> { + upload_segments(p, hs, image, &WRITE_SEGMENTS) +} + +/// Write `segments` of `image` back, and nothing else. +/// +/// `segments` must be drawn from [`WRITE_SEGMENTS`] — the address guard below +/// is the backstop, not the policy. The whole image is still required as the +/// argument so that every offset means the same thing everywhere in this +/// driver, and so a caller cannot hand over a buffer that has been shifted. +pub(crate) fn upload_segments( + p: &mut dyn SerialPort, + hs: &Handshake, + image: &[u8], + segments: &[Segment], +) -> Result<(), String> { if image.len() != IMAGE_LEN { return Err(format!( "refusing to write a {}-byte image; a BT-9000 clone is exactly {IMAGE_LEN} bytes", @@ -319,7 +363,7 @@ pub(crate) fn upload(p: &mut dyn SerialPort, hs: &Handshake, image: &[u8]) -> Re p.set_timeout(ACK_TIMEOUT) .map_err(|e| format!("could not extend the serial timeout for writing: {e}"))?; - for seg in WRITE_SEGMENTS { + for seg in segments.iter().copied() { for off in (0..seg.length).step_by(BLOCK) { let addr = seg.address + off as u16; if forbidden(addr) { @@ -656,6 +700,14 @@ impl RadioDriver for BinteradioBt9000 { fn as_image_programmer(&self) -> Option<&dyn ImageProgrammer> { Some(self) } + + 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) + } } impl ImageProgrammer for BinteradioBt9000 { diff --git a/src-tauri/src/radios/binteradio_bt9000/settings.rs b/src-tauri/src/radios/binteradio_bt9000/settings.rs new file mode 100644 index 0000000..700a0f0 --- /dev/null +++ b/src-tauri/src/radios/binteradio_bt9000/settings.rs @@ -0,0 +1,487 @@ +//! BT-9000 non-channel settings, read from and written to the function block. +//! +//! ## The write is deliberately narrow +//! +//! Settings live in one 256-byte segment at radio `0x9000`. The driver *can* +//! write the whole 33 KB clone image, and doing so to change a squelch level +//! would rewrite all 960 channel records — four minutes instead of a third of a +//! second, with the operator's memories in the write path for a change that +//! never touched them. So this module uses [`super::SETTINGS_SEGMENTS`] and +//! addresses nothing else. A transport that gains reach has to be narrowed on +//! purpose. +//! +//! ## An ACK is not a commit +//! +//! This radio answers `0x06` to blocks it does not always commit — the APRS +//! block acknowledges every write and never changes, verified four times. So a +//! settings write is followed by a read-back in the same session, and the +//! read-back is the only evidence reported. It costs 0.35 s here. +//! +//! ## The radio validates nothing +//! +//! It stored `127` in four function fields whose real maxima are 9, 2, 3 and 1, +//! and read them back unchanged. There is no hardware backstop: every bound has +//! to be enforced here, because a value written out of range is *stored*, not +//! rejected. [`encode_field`] is that backstop and refuses rather than clamps — +//! a silently adjusted value is how an operator ends up transmitting on a +//! setting they did not choose. +//! +//! ## What is NOT here +//! +//! Three fields of the ~43 the sheet names. That is not the radio's limit, it +//! is the measurement's: `scratchpad/binteradio_bt9000/MEASURED.md` grades every +//! row, and only rows settled on the radio's own screen are emitted. The rest +//! are measured but not settled — an option list confirmed at a single index +//! cannot tell the printed order from a reversed one, which is exactly how the +//! TH-D75 shipped a control that wrote Volume Link when the operator picked +//! Level 1. `SCREEN-CHECK.md` is the runbook that closes the gap. + +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::radios::driver::{SettingsCapture, SettingsReader, SettingsWriteReport, SettingsWriter}; + +use super::bt9000_settings_table::{Enc, Kind, FIELDS, SF}; +use super::{ + download_segments, handshake, open_port, upload_segments, FUNCTION_LEN, FUNCTION_OFFSET, + READ_SEGMENTS, SETTINGS_SEGMENTS, SETTLE, +}; + +// ============================================================ +// Encode / decode one field +// ============================================================ + +/// The field's value as the form carries it: a number for `Int`, the option +/// label for `Enum`, a bool for `Bool`. +fn decode_field(f: &SF, block: &[u8]) -> Value { + let raw = block[f.addr]; + match f.kind { + Kind::Bool => json!(raw != 0), + Kind::Enum => match f.options.get(raw as usize) { + Some(label) => json!(label), + // The radio stores whatever it was handed, including by some other + // tool. Surface the raw byte rather than inventing a label or + // failing the whole read for one field. + None => json!(format!("(unknown value {raw})")), + }, + Kind::Int { lo, hi } => { + let shown = match f.enc { + Enc::Direct => i32::from(raw), + Enc::Minus1 => i32::from(raw) + 1, + }; + if (i32::from(lo)..=i32::from(hi)).contains(&shown) { + json!(shown) + } else { + json!(null) + } + } + } +} + +/// Why a value could not be written. +#[derive(Debug)] +pub(crate) enum Reject { + /// A `select` label this app's option list does not carry. That is not an + /// error: it is how a value read off a radio this app cannot name survives + /// a round trip (see `settings_bounds`' own note on selects). The field is + /// left exactly as the radio has it, and the caller gets a note. + Unnameable(String), + /// The wrong shape, or outside the field's range. This radio would store + /// either one — it stored 127 in a field whose maximum is 1 — so the write + /// stops here rather than being clamped into something plausible. + Invalid(String), +} + +/// The byte to store. +/// +/// Refuses out-of-range rather than clamping: this radio stores a clamped value +/// just as happily as the right one, and the operator would never see the +/// difference. +fn encode_field(f: &SF, v: &Value) -> Result { + match f.kind { + Kind::Bool => v + .as_bool() + .map(u8::from) + .ok_or_else(|| Reject::Invalid(format!("{}: expected true or false, got {v}", f.key))), + Kind::Enum => { + let s = v.as_str().ok_or_else(|| { + Reject::Invalid(format!("{}: expected one of its options, got {v}", f.key)) + })?; + f.options + .iter() + .position(|o| *o == s) + .map(|i| i as u8) + .ok_or_else(|| { + Reject::Unnameable(format!( + "{} [{}] holds {s:?}, which this app cannot name; left as the radio had it", + f.label, f.menu + )) + }) + } + Kind::Int { lo, hi } => { + let n = v + .as_i64() + .ok_or_else(|| Reject::Invalid(format!("{}: expected a number, got {v}", f.key)))?; + if !(i64::from(lo)..=i64::from(hi)).contains(&n) { + return Err(Reject::Invalid(format!( + "{}: {n} is outside {lo}..={hi}", + f.key + ))); + } + Ok(match f.enc { + Enc::Direct => n as u8, + Enc::Minus1 => (n - 1) as u8, + }) + } + } +} + +/// Decode every field out of a full clone image. +pub(crate) fn decode_settings(image: &[u8]) -> Value { + let block = &image[FUNCTION_OFFSET..FUNCTION_OFFSET + FUNCTION_LEN]; + let mut out = serde_json::Map::new(); + for f in &FIELDS { + out.insert(f.key.to_string(), decode_field(f, block)); + } + Value::Object(out) +} + +/// Patch the profile's settings into `image`'s function block. Returns how many +/// fields were written, and a note for each one that was deliberately left +/// alone. +/// +/// Named `apply_profile_settings` rather than `apply_settings` to sit in the +/// right architectural bucket, and `radios/wiring.rs` keys on the difference. +/// `apply_settings` belongs to the CARD radios, whose settings ride out inside +/// an exported file — there the export path has to call the encoder, and twice +/// it did not, which is the guard that test exists to be. This radio is shaped +/// like the TD-H3 instead: settings go over the cable through `SettingsWriter` +/// as their own acknowledged operation, so the caller below IS the write path. +/// +/// A key the profile does not carry is left exactly as the radio had it — this +/// is a patch, not a replace. On a radio that stores anything, writing a default +/// over a field the operator set and this app never measured would be a silent +/// change to a working radio. +pub(crate) fn apply_profile_settings( + image: &mut [u8], + settings: &Value, +) -> Result<(usize, Vec), String> { + let obj = settings + .as_object() + .ok_or_else(|| "profile settings are not a JSON object".to_string())?; + let (mut written, mut notes) = (0, Vec::new()); + for f in &FIELDS { + let Some(v) = obj.get(f.key) else { continue }; + if v.is_null() { + continue; + } + match encode_field(f, v) { + Ok(byte) => { + image[FUNCTION_OFFSET + f.addr] = byte; + written += 1; + } + Err(Reject::Unnameable(note)) => notes.push(note), + Err(Reject::Invalid(e)) => return Err(e), + } + } + Ok((written, notes)) +} + +// ============================================================ +// SettingsReader / SettingsWriter +// ============================================================ + +impl SettingsReader for super::BinteradioBt9000 { + /// Read the whole clone image and decode the settings out of it. + /// + /// The *read* is the full image rather than the one segment, because the + /// command layer saves the capture as the session's backup and a + /// function-block-only file is not a backup of anything. Reading everything + /// is harmless; it is the write that has to be narrow. + fn read_settings(&self, port: &str, _schema_json: &str) -> Result { + let mut p = open_port(port)?; + let hs = handshake(&mut *p)?; + let image = download_segments(&mut *p, &hs, &READ_SEGMENTS)?; + Ok(SettingsCapture { + settings: decode_settings(&image), + backup: image, + backup_ext: "img", + }) + } +} + +impl SettingsWriter for super::BinteradioBt9000 { + /// Read + back up the whole image, patch the settings into its function + /// block, write **only that block**, then read it back and compare. + fn write_settings( + &self, + port: &str, + settings: &Value, + schema_json: &str, + backup_dir: &Path, + ) -> Result { + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let backup_path = backup_dir.join(format!("bt9000-presettings-{stamp}.img")); + + let mut p = open_port(port)?; + + // 1. Read and back up everything, so the file beside the write is a + // whole radio and not just the part being changed. + let hs = handshake(&mut *p)?; + let mut image = download_segments(&mut *p, &hs, &READ_SEGMENTS)?; + std::fs::write(&backup_path, &image) + .map_err(|e| format!("could not write backup {}: {e}", backup_path.display()))?; + + // 2. Patch. The shared range check runs first so a stale profile value + // is dropped with a note rather than blocking the write; this + // driver's own encoder is then the backstop, because the radio is + // not one. + let mut settings = settings.clone(); + let mut notes = crate::radios::settings_bounds::strip_out_of_range( + schema_json, + &mut settings, + ); + let (fields_written, skipped) = apply_profile_settings(&mut image, &settings)?; + notes.extend(skipped); + let expected: Vec = + image[FUNCTION_OFFSET..FUNCTION_OFFSET + FUNCTION_LEN].to_vec(); + + // 3. Write the function segment alone. Fresh session: this radio needs + // a moment after a full read before it answers again. + std::thread::sleep(SETTLE); + let hs = handshake(&mut *p).map_err(|e| { + crate::radios::driver::with_restore_hint( + e, + &backup_path, + "Nothing was written. That file is the radio as it was read.", + ) + .to_string() + })?; + upload_segments(&mut *p, &hs, &image, &SETTINGS_SEGMENTS).map_err(|e| { + crate::radios::driver::with_restore_hint( + e, + &backup_path, + "Keep that file. It is the only copy of what was on the radio \ + before this write, and it can be uploaded back over the same cable.", + ) + })?; + + // 4. Read the block back. On this radio that is the ONLY evidence the + // write committed — it acknowledges blocks it does not always store. + std::thread::sleep(SETTLE); + let (verified, verify_note) = match verify(&mut *p, &expected) { + Ok(v) => v, + Err(e) => ( + false, + Some(format!( + "Settings written, but read-back verification could not run ({e}). \ + This radio acknowledges blocks it does not always commit, so \ + power-cycle it and use Read to confirm before trusting it." + )), + ), + }; + notes.extend(verify_note); + let note = (!notes.is_empty()).then(|| notes.join(" ")); + + Ok(SettingsWriteReport { + fields_written, + verified: Some(verified), + note, + backup_path: backup_path.to_string_lossy().to_string(), + expected_path: None, + windows_written: Vec::new(), + }) + } +} + +/// Re-read the function block and compare it with what was sent. +fn verify( + p: &mut dyn serialport::SerialPort, + expected: &[u8], +) -> Result<(bool, Option), String> { + let hs = handshake(p)?; + let back = download_segments(p, &hs, &SETTINGS_SEGMENTS)?; + let got = &back[FUNCTION_OFFSET..FUNCTION_OFFSET + FUNCTION_LEN]; + if got == expected { + return Ok((true, None)); + } + let bad: Vec = FIELDS + .iter() + .filter(|f| got[f.addr] != expected[f.addr]) + .map(|f| { + format!( + "{} [{}] (wrote {}, read {})", + f.label, f.menu, expected[f.addr], got[f.addr] + ) + }) + .collect(); + let detail = if bad.is_empty() { + "the differences are outside the fields this app writes".to_string() + } else { + bad.join(", ") + }; + Ok(( + false, + Some(format!( + "The radio acknowledged the write but read back differently: {detail}. \ + The settings on the radio are NOT what was sent." + )), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The table and the form schema come from one parse of one sheet, and a + /// drift between them is invisible at runtime: a form field with no table + /// entry silently does nothing, and a table entry with no form field is a + /// setting nobody can reach. + #[test] + fn table_and_schema_describe_the_same_fields() { + let schema: Vec = serde_json::from_str(crate::seed::BT9000_SETTINGS_SCHEMA).unwrap(); + let form: Vec<&str> = schema + .iter() + .filter(|f| f["type"] != "section") + .map(|f| f["key"].as_str().unwrap()) + .collect(); + let table: Vec<&str> = FIELDS.iter().map(|f| f.key).collect(); + assert_eq!(form, table, "regenerate both with gen_bt9000_settings.py"); + } + + /// Every enum option in the schema must exist in the table in the same + /// order, because the table's index IS the byte written to the radio. + #[test] + fn schema_options_match_the_stored_order() { + let schema: Vec = serde_json::from_str(crate::seed::BT9000_SETTINGS_SCHEMA).unwrap(); + for f in &FIELDS { + if f.kind != Kind::Enum { + continue; + } + let entry = schema + .iter() + .find(|e| e["key"] == f.key) + .unwrap_or_else(|| panic!("{} missing from the schema", f.key)); + let opts: Vec<&str> = entry["options"] + .as_array() + .unwrap() + .iter() + .map(|o| o.as_str().unwrap()) + .collect(); + assert_eq!(opts, f.options, "{}: schema and table disagree", f.key); + } + } + + /// The narrowed write must address the function segment and nothing else. + /// `SETTINGS_SEGMENTS` is an index into `WRITE_SEGMENTS`, so a reordering + /// there would silently retarget every settings write at the channels. + #[test] + fn settings_write_addresses_only_the_function_block() { + assert_eq!(SETTINGS_SEGMENTS.len(), 1); + let seg = SETTINGS_SEGMENTS[0]; + assert_eq!(seg.name, "function"); + assert_eq!(seg.address, 0x9000); + assert_eq!(seg.file_offset, FUNCTION_OFFSET); + assert_eq!(seg.length, FUNCTION_LEN); + } + + /// Round-trip every field through the form's own representation. + #[test] + fn every_field_round_trips() { + let mut image = vec![0u8; super::super::IMAGE_LEN]; + for f in &FIELDS { + let probes: Vec = match f.kind { + Kind::Bool => vec![json!(false), json!(true)], + Kind::Enum => f.options.iter().map(|o| json!(o)).collect(), + Kind::Int { lo, hi } => (lo..=hi).map(|n| json!(n)).collect(), + }; + for v in probes { + let raw = encode_field(f, &v).unwrap(); + image[FUNCTION_OFFSET + f.addr] = raw; + let back = decode_field(f, &image[FUNCTION_OFFSET..FUNCTION_OFFSET + FUNCTION_LEN]); + assert_eq!(back, v, "{} did not round-trip through byte {raw}", f.key); + } + } + } + + /// ⚠ The radio stores whatever it is handed — 127 into a field whose + /// maximum is 1 — so a value the form should never produce has to be + /// refused HERE. Nothing downstream will catch it. + #[test] + fn out_of_range_is_refused_not_clamped() { + for f in &FIELDS { + let bad = match f.kind { + Kind::Bool => json!("yes"), + Kind::Enum => json!(42), + Kind::Int { hi, .. } => json!(i64::from(hi) + 1), + }; + match encode_field(f, &bad) { + Err(Reject::Invalid(e)) => assert!(e.contains(f.key), "{}: {e}", f.key), + other => panic!("{} accepted {bad}: {other:?}", f.key), + } + } + } + + /// A select label this app cannot name must SKIP, not fail. That is how a + /// value some other tool put on the radio survives being read into a + /// profile and written back — the byte keeps whatever the radio had. + #[test] + fn an_unnameable_select_leaves_the_radio_alone() { + let f = FIELDS + .iter() + .find(|f| f.kind == Kind::Enum) + .expect("a settled enum field"); + assert!(matches!( + encode_field(f, &json!("something this app has never heard of")), + Err(Reject::Unnameable(_)) + )); + + let mut image = vec![0x77u8; super::super::IMAGE_LEN]; + let (written, notes) = + apply_profile_settings(&mut image, &json!({f.key: "something else"})).unwrap(); + assert_eq!(written, 0); + assert_eq!(notes.len(), 1); + assert_eq!(image[FUNCTION_OFFSET + f.addr], 0x77, "the byte was touched"); + } + + /// An index the radio holds that this app has no label for must survive + /// being decoded, or the round trip above has nothing to carry. + #[test] + fn an_unmapped_stored_value_decodes_to_something_writable_back() { + let f = FIELDS.iter().find(|f| f.kind == Kind::Enum).unwrap(); + let mut image = vec![0u8; super::super::IMAGE_LEN]; + image[FUNCTION_OFFSET + f.addr] = 200; + let decoded = decode_settings(&image); + assert!(matches!( + encode_field(f, &decoded[f.key]), + Err(Reject::Unnameable(_)) + )); + } + + /// The two "Level 1-9" fields two bytes apart do NOT share a convention: + /// SQL stores the level, VOX Level stores the level minus one. Both were + /// read off the radio's screen; a generator that assumed one rule for both + /// would ship one of them silently off by one. + #[test] + fn the_two_level_fields_keep_their_different_conventions() { + let sql = FIELDS.iter().find(|f| f.key == "squelch").unwrap(); + let vox = FIELDS.iter().find(|f| f.key == "vox-level").unwrap(); + assert_eq!(encode_field(sql, &json!(9)).unwrap(), 9); + assert_eq!(encode_field(vox, &json!(7)).unwrap(), 6); + } + + /// A key the profile does not carry must come back off the radio untouched. + #[test] + fn unknown_keys_are_left_alone() { + let mut image = vec![0x5Au8; super::super::IMAGE_LEN]; + let (n, notes) = apply_profile_settings(&mut image, &json!({"squelch": 4})).unwrap(); + assert_eq!(n, 1); + assert!(notes.is_empty()); + assert_eq!(image[FUNCTION_OFFSET], 4); + // Every other byte of the block is as it was read. + for i in 1..FUNCTION_LEN { + assert_eq!(image[FUNCTION_OFFSET + i], 0x5A, "byte 0x{i:02X} was touched"); + } + } +} diff --git a/src-tauri/src/radios/registry.rs b/src-tauri/src/radios/registry.rs index 988b913..1d6c18b 100644 --- a/src-tauri/src/radios/registry.rs +++ b/src-tauri/src/radios/registry.rs @@ -116,13 +116,19 @@ mod tests { // `MU` ASCII command — no clone session involved, which is why // it claims both halves where the card radios claim neither. "kenwood_thd72" => (true, true), - // BT-9000: neither, yet. Its settings block decodes and 16 - // fields are screen-confirmed on the radio, but nothing carries - // them to it. Claiming the capability here would put a settings - // action in front of the operator that reads and never writes - // — the dead-write-path trap this project has already shipped - // once (issue #43). - "binteradio_bt9000" => (false, false), + // BT-9000: both. Like the TH-D72 it reads and writes its own + // settings, but through a clone session rather than an ASCII + // command — and the WRITE is narrowed to the one 256-byte + // function segment, because the same transport could rewrite + // all 960 channel records to change a squelch level. + // + // ⚠ The schema behind this is deliberately SHORT. It carries + // only the fields whose encoding was settled on the radio's own + // screen; the measurement sheet grades ~43 candidates and the + // generator withholds the rest. A wrong encoding here is not + // caught anywhere downstream — this radio stored 127 in fields + // whose maxima are 9, 2, 3 and 1 (issue #43). + "binteradio_bt9000" => (true, true), _ => (true, true), }; assert_eq!( diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index 9623a4f..66b6def 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -176,6 +176,19 @@ pub const THD75_SETTINGS_SCHEMA: &str = include_str!("thd75_settings_schema.json /// for the six-of-six cross-check that showed the two carry the same values. pub const THD72_SETTINGS_SCHEMA: &str = include_str!("thd72_settings_schema.json"); +/// The BT-9000 profile-settings schema, GENERATED alongside the Rust field +/// table by `scratchpad/binteradio_bt9000/gen_bt9000_settings.py` from that +/// folder's `MEASURED.md`. `radios/binteradio_bt9000/settings.rs` asserts the +/// two halves still describe the same fields. +/// +/// ⚠ It is SHORT on purpose. The sheet grades ~43 candidate fields and this +/// carries only the ones whose encoding was settled on the radio's own screen. +/// This radio validates nothing — it stored 127 in fields whose maxima are 9, +/// 2, 3 and 1 — so an unsettled encoding would be stored rather than refused, +/// and the schema is where that decision is enforced. +pub const BT9000_SETTINGS_SCHEMA: &str = + include_str!("bt9000_settings_schema.json"); + fn models() -> Vec { vec![ @@ -355,11 +368,13 @@ fn models() -> Vec { max_name_length: 12, export_format: "chirp_csv", connection_type: "Kenwood K1 (2-pin)", - // Empty on purpose. Sixteen function-block fields are measured and - // screen-confirmed (see scratchpad FINDINGS.md), but the settings - // read/write path is not wired yet, and seeding a schema whose - // fields nothing carries to the radio is the dead-write-path trap. - non_channel_settings_schema: r#"[]"#, + // Generated from scratchpad/binteradio_bt9000/MEASURED.md, and + // carrying only the fields whose encoding was settled on the + // radio's own screen. The sheet names ~43 candidates; the rest are + // measured but not settled, and are listed by the generator on + // stderr rather than emitted. SCREEN-CHECK.md is the runbook that + // closes the gap. + non_channel_settings_schema: BT9000_SETTINGS_SCHEMA, }, // -------------------------------------------------------- // 2. TIDRADIO TD-H3 — analog FM/NFM/AM, 200 ch, no zones, 8 char. diff --git a/src/components/profiles/ProfileEditor.tsx b/src/components/profiles/ProfileEditor.tsx index ed641da..6096c8b 100644 --- a/src/components/profiles/ProfileEditor.tsx +++ b/src/components/profiles/ProfileEditor.tsx @@ -3,7 +3,14 @@ import { confirm as confirmDialog, open as openDialog, } from "@tauri-apps/plugin-dialog"; -import { Trash2, Save, DownloadCloud, RefreshCw, HardDrive } from "lucide-react"; +import { + Trash2, + Save, + DownloadCloud, + UploadCloud, + RefreshCw, + HardDrive, +} from "lucide-react"; import clsx from "clsx"; import { toast } from "sonner"; import { api, withToast } from "../../lib/api"; @@ -120,6 +127,135 @@ function RadioSyncBar({ ); } +/** + * Push this profile's saved settings to the radio. The exact inverse of + * RadioSyncBar, and gated the same way — on the DRIVER's `write_settings` + * capability, never on a model name. + * + * ⚠ It exists because the capability did not have a caller. `SettingsWriter` + * was implemented and hardware-proven on two radios, `write_radio_settings` was + * registered, and the only controls that reached it were the TD-H3's and the + * AnyTone's own program dialogs — so a radio using the generic UI could read + * its settings into this form and had no way to send them back. That is the + * dead-write-path trap one layer up from where it was caught before: the read + * half works, so nothing looks broken. + * + * It writes what is SAVED, not what is typed. The command reads the profile out + * of the database, so an unsaved edit in this form would not go to the radio — + * hence the Save prompt rather than a silent partial write. + */ +function WriteToRadioBar({ + profileId, + modelLabel, + dirty, +}: { + profileId: number; + modelLabel: string; + dirty: boolean; +}) { + const [ports, setPorts] = useState([]); + const [port, setPort] = useState(""); + const [busy, setBusy] = useState(false); + const [confirming, setConfirming] = useState(false); + + const refresh = async () => { + try { + const list = await api.listSerialPorts(); + setPorts(list); + const usb = list.find((p) => p.kind === "usb"); + setPort((cur) => cur || usb?.name || list[0]?.name || ""); + } catch { + /* surfaced on write */ + } + }; + + useEffect(() => { + refresh(); + }, []); + + const upload = async () => { + if (!port) return; + setConfirming(false); + setBusy(true); + const res = await withToast(api.writeRadioSettings(port, profileId), { + error: "Could not write settings to the radio", + }); + setBusy(false); + if (!res) return; + const { toast } = await import("sonner"); + const n = res.fields_written; + const applied = `Wrote ${n} setting${n === 1 ? "" : "s"} to the radio`; + // `verified` is the only evidence that matters on a radio that + // acknowledges blocks it does not always commit, so it leads the message + // and an unverified write is a warning rather than a success. + if (res.verified === true) { + toast.success(`${applied} · read back and verified ✓`); + } else { + toast.warning(res.note || `${applied}, but the read-back did not confirm it.`); + } + }; + + return ( +
+
+
+ + Write these settings to a connected {modelLabel} + +
+ + +
+
+ +
+ {dirty && ( +

+ This profile has unsaved changes. The radio is written from the saved + profile, so Save first. +

+ )} + {confirming && ( +
+ + This changes settings on the radio. Channels are left untouched, and a + backup of the radio is written beside the app's data first. + + + +
+ )} +
+ ); +} + /// The card file each media format's settings are decoded out of. A radio /// programmed from a card gets a settings loader the moment its format has a /// reader here — no new branch in the editor. @@ -761,6 +897,18 @@ export function ProfileEditor({ const setValue = (key: string, v: string | number | boolean) => setValues((s) => ({ ...s, [key]: v })); + // `write_radio_settings` sends the profile as STORED, so an unsaved edit + // would not reach the radio. Comparing against `baseline` rather than the + // profile row keeps a value just read off the radio from counting as an edit, + // the same way `rangeErrors` does. + const dirty = useMemo( + () => + name !== profile.display_name || + notes !== (profile.notes ?? "") || + fields.some((f) => values[f.key] !== baseline[f.key]), + [name, notes, values, baseline, fields, profile], + ); + // Values that came off the radio (or its card) are the new starting point, // not an edit — a radio is allowed to hold a value this app's schema does not // describe, and it must stay saveable. @@ -897,6 +1045,16 @@ export function ProfileEditor({ onLoaded={loadFromRadio} /> )} + {/* The inverse, gated on the driver's write capability for the same + reason. Card radios never get it: their settings are patched + into the file the export writes, not sent over a cable. */} + {caps?.write_settings && fields.length > 0 && ( + + )} {/* A card radio's settings come off its microSD rather than a cable, so it gets a file picker where the others get a port picker. Keyed on the export format — the same key that names the From b8b657133442aa40f6a632cbdb264977d867df77 Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 07:13:27 -0600 Subject: [PATCH 03/15] BT-9000: the settings read-back was reading with a WRITE opcode (#43) Found reviewing what the last commit made possible rather than what it changed. `SETTINGS_SEGMENTS` was derived from `WRITE_SEGMENTS`, and a `Segment` carries its own command byte -- so `verify()` handed it to `download_segments`, which would have put `0x57` on the wire as a block header with nothing behind it. On the platform whose reverse-engineering notes document a radio with permanently degraded transmit after a desynchronised write stream, that is not a cosmetic mix-up. The read and write tables describe the SAME blocks with different opcodes, which is exactly what made the mistake easy. So: * `SETTINGS_READ_SEGMENTS`, drawn from `READ_SEGMENTS`, and the read-back uses it. * `check_commands` runs at the top of both transports, so each refuses a segment from the other's table before any I/O. * The shape test now asserts the opcode on both constants, not just the address and length -- the address and length were identical between the two, which is why they alone caught nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- src-tauri/src/radios/binteradio_bt9000/mod.rs | 38 ++++++++++++++ .../src/radios/binteradio_bt9000/settings.rs | 51 +++++++++++++++---- 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/radios/binteradio_bt9000/mod.rs b/src-tauri/src/radios/binteradio_bt9000/mod.rs index e6dab1e..c946980 100644 --- a/src-tauri/src/radios/binteradio_bt9000/mod.rs +++ b/src-tauri/src/radios/binteradio_bt9000/mod.rs @@ -70,6 +70,12 @@ const TIMEOUT: Duration = Duration::from_secs(3); const ACK_TIMEOUT: Duration = Duration::from_secs(15); const HANDSHAKE: &[u8] = b"PROGRAMBT9000U"; + +/// Block commands. Read and write are distinct opcodes in both the main space +/// and the APRS one, and a `Segment` carries whichever its table is for. +const CMD_READ: u8 = 0x52; +const CMD_READ_APRS: u8 = 0x54; +const CMD_WRITE: u8 = 0x57; const ACK: u8 = 0x06; const END: u8 = b'E'; const BLOCK: usize = 0x80; @@ -138,6 +144,28 @@ pub(crate) const WRITE_SEGMENTS: [Segment; 6] = [ Segment { name: "mod_names", command: 0x57, address: 0xD000, file_offset: 0x7E00, length: 0x0300 }, ]; +/// Refuse a segment whose opcode does not belong to the transport being asked +/// to carry it. +/// +/// A [`Segment`] carries its own command byte, and [`READ_SEGMENTS`] and +/// [`WRITE_SEGMENTS`] describe the SAME blocks with different ones. Handing a +/// write segment to the reader would put write opcodes on the wire with nothing +/// behind them, on the platform where a desynchronised write stream has already +/// permanently degraded one radio's transmit. Cheap to check, so it is checked +/// rather than left to the caller picking the right constant. +fn check_commands(segments: &[Segment], allowed: &[u8], verb: &str) -> Result<(), String> { + for seg in segments { + if !allowed.contains(&seg.command) { + return Err(format!( + "internal error: refusing to {verb} segment {} with command 0x{:02X}, \ + which is not a {verb} command", + seg.name, seg.command + )); + } + } + Ok(()) +} + /// Address ranges that must never be written in the `0x52`/`0x57` space. /// /// `0x7800`–`0x7FFF` is the gap the vendor CPS skips. `0x8080`–`0x80FF` is the @@ -303,6 +331,7 @@ pub(crate) fn download_segments( segments: &[Segment], ) -> Result, String> { let mut image = vec![0u8; IMAGE_LEN]; + check_commands(segments, &[CMD_READ, CMD_READ_APRS], "read")?; for seg in segments.iter().copied() { for off in (0..seg.length).step_by(BLOCK) { let addr = seg.address + off as u16; @@ -328,6 +357,14 @@ pub(crate) fn download_segments( /// the reach to write everything is a deliberate act, not an optimisation. pub(crate) const SETTINGS_SEGMENTS: [Segment; 1] = [WRITE_SEGMENTS[2]]; +/// The same block, for READING. Deliberately a separate constant drawn from +/// [`READ_SEGMENTS`]: a `Segment` carries its command byte, and this radio's +/// read and write commands are different (`0x52` vs `0x57`). Handing the write +/// segment to [`download_segments`] would put a WRITE opcode on the wire with +/// no payload behind it — on the platform that has already had a radio's +/// transmit permanently degraded by a desynchronised write stream. +pub(crate) const SETTINGS_READ_SEGMENTS: [Segment; 1] = [READ_SEGMENTS[2]]; + /// Where the function block sits in the assembled image, and how long it is. pub(crate) const FUNCTION_OFFSET: usize = 0x7900; pub(crate) const FUNCTION_LEN: usize = 0x0100; @@ -363,6 +400,7 @@ pub(crate) fn upload_segments( p.set_timeout(ACK_TIMEOUT) .map_err(|e| format!("could not extend the serial timeout for writing: {e}"))?; + check_commands(segments, &[CMD_WRITE], "write")?; for seg in segments.iter().copied() { for off in (0..seg.length).step_by(BLOCK) { let addr = seg.address + off as u16; diff --git a/src-tauri/src/radios/binteradio_bt9000/settings.rs b/src-tauri/src/radios/binteradio_bt9000/settings.rs index 700a0f0..d9ea339 100644 --- a/src-tauri/src/radios/binteradio_bt9000/settings.rs +++ b/src-tauri/src/radios/binteradio_bt9000/settings.rs @@ -45,7 +45,7 @@ use crate::radios::driver::{SettingsCapture, SettingsReader, SettingsWriteReport use super::bt9000_settings_table::{Enc, Kind, FIELDS, SF}; use super::{ download_segments, handshake, open_port, upload_segments, FUNCTION_LEN, FUNCTION_OFFSET, - READ_SEGMENTS, SETTINGS_SEGMENTS, SETTLE, + READ_SEGMENTS, SETTINGS_READ_SEGMENTS, SETTINGS_SEGMENTS, SETTLE, }; // ============================================================ @@ -301,7 +301,7 @@ fn verify( expected: &[u8], ) -> Result<(bool, Option), String> { let hs = handshake(p)?; - let back = download_segments(p, &hs, &SETTINGS_SEGMENTS)?; + let back = download_segments(p, &hs, &SETTINGS_READ_SEGMENTS)?; let got = &back[FUNCTION_OFFSET..FUNCTION_OFFSET + FUNCTION_LEN]; if got == expected { return Ok((true, None)); @@ -374,16 +374,45 @@ mod tests { } /// The narrowed write must address the function segment and nothing else. - /// `SETTINGS_SEGMENTS` is an index into `WRITE_SEGMENTS`, so a reordering - /// there would silently retarget every settings write at the channels. + /// Both constants are INDEXES into their tables, so a reordering of either + /// would silently retarget every settings write at the channel records. #[test] - fn settings_write_addresses_only_the_function_block() { - assert_eq!(SETTINGS_SEGMENTS.len(), 1); - let seg = SETTINGS_SEGMENTS[0]; - assert_eq!(seg.name, "function"); - assert_eq!(seg.address, 0x9000); - assert_eq!(seg.file_offset, FUNCTION_OFFSET); - assert_eq!(seg.length, FUNCTION_LEN); + fn settings_segments_address_only_the_function_block() { + for (segs, cmd, what) in [ + (&SETTINGS_SEGMENTS[..], 0x57u8, "write"), + (&SETTINGS_READ_SEGMENTS[..], 0x52u8, "read"), + ] { + assert_eq!(segs.len(), 1, "{what}"); + let seg = segs[0]; + assert_eq!(seg.name, "function", "{what}"); + assert_eq!(seg.address, 0x9000, "{what}"); + assert_eq!(seg.file_offset, FUNCTION_OFFSET, "{what}"); + assert_eq!(seg.length, FUNCTION_LEN, "{what}"); + // ⚠ The read and write tables describe the SAME block with + // different opcodes. Reading with the write segment would put a + // write command on the wire with nothing behind it, on a platform + // where a desynchronised write stream has permanently degraded a + // radio's transmit. + assert_eq!(seg.command, cmd, "{what} segment carries the wrong opcode"); + } + } + + /// And each transport refuses a segment from the other table outright, so a + /// future caller reaching for the wrong constant gets an error instead of + /// the wire. Checked before any I/O, which is why it needs no port. + #[test] + fn a_transport_refuses_a_segment_from_the_other_table() { + let err = super::super::check_commands(&SETTINGS_SEGMENTS, &[0x52, 0x54], "read") + .expect_err("reading with the write segment must be refused"); + assert!(err.contains("not a read command"), "{err}"); + + let err = super::super::check_commands(&SETTINGS_READ_SEGMENTS, &[0x57], "write") + .expect_err("writing with the read segment must be refused"); + assert!(err.contains("not a write command"), "{err}"); + + // The pairings the driver actually uses are accepted. + super::super::check_commands(&SETTINGS_READ_SEGMENTS, &[0x52, 0x54], "read").unwrap(); + super::super::check_commands(&SETTINGS_SEGMENTS, &[0x57], "write").unwrap(); } /// Round-trip every field through the form's own representation. From 2754263b37abdd71516ecb9aa948114eea4a08a7 Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 07:22:57 -0600 Subject: [PATCH 04/15] BT-9000: ladder step 5 through the driver's own settings writer (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scratch Python has already put values in the function block and read them back, so this is not asking whether the radio stores settings. It asks whether THIS DRIVER's narrowed write does -- the same distinction that made step 3 worth re-running for channels after the Python had already proved the radio accepts an image. Three assertions, all against a fresh read-back and never against an ACK, because this radio acknowledges blocks it does not commit: * `write_settings` reaches the radio and its own read-back verdict is true. * `read_settings` decodes back exactly what was asked for, including the two "Level 1-9" fields two bytes apart that store their values differently. * ★ Every segment except `function` is byte-identical to the pre-write image. That is what the narrowed write exists for: the whole-image upload would rewrite 960 channel records to change a squelch level, and this fails if a single one of them moves. It restores the settings the radio started with before returning, so the measurement campaign's baseline survives the run. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- .../src/radios/binteradio_bt9000/hw_ladder.rs | 100 +++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs b/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs index 67d5d35..ac78eac 100644 --- a/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs +++ b/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs @@ -1,4 +1,4 @@ -//! THROWAWAY (issue #43): hardware ladder steps 3 and 4 for the BT-9000. +//! THROWAWAY (issue #43): hardware ladder steps 3, 4 and 5 for the BT-9000. //! //! Steps 1 and 2 — identity write and a one-name write — were run with the //! scratch tooling and passed; they proved the container and that there is no @@ -13,7 +13,7 @@ //! cargo test --lib binteradio_bt9000::hw_ladder -- --ignored --nocapture //! ``` //! -//! ⚠ Both tests WRITE to the radio. Each takes its own backup first and prints +//! ⚠ All three tests WRITE to the radio. Each takes its own backup first and prints //! the path. ⚠ And an ACK from this radio does not mean a commit — every //! assertion below is made against a fresh read-back, never against the write. @@ -215,3 +215,99 @@ fn step4_band_probe() { each channel on the radio and keying up can." ); } + +/// Ladder step 5 — the settings spot check, through the driver's own +/// `SettingsWriter` rather than the scratch Python. +/// +/// The Python tooling in `scratchpad/binteradio_bt9000/` has already put values +/// in this block and read them back, so this is not asking whether the radio +/// stores settings — it is asking whether *this driver's* narrowed write does. +/// That is a different question, and it is the one step 3 had to be re-run to +/// answer for channels. +/// +/// What it proves, all against a fresh read-back and never against an ACK: +/// +/// 1. `write_settings` reaches the radio at all. +/// 2. It writes **only the function block** — every other segment is compared +/// against the pre-write backup and must be untouched. On a driver whose +/// whole-image `upload` would rewrite 960 channel records to change a +/// squelch level, that is the assertion that matters. +/// 3. `read_settings` decodes back exactly what was asked for, including the +/// two "Level 1-9" fields two bytes apart that store their values +/// differently. +/// +/// ⚠ It restores the settings it found before returning, so the radio is left +/// as it was even though the backup would also serve. +#[test] +#[ignore = "writes to a real BT-9000 on the cable"] +fn step5_settings_write_through_the_driver() { + use crate::radios::driver::{SettingsReader, SettingsWriter}; + use serde_json::json; + + let port = port(); + let dir = backup_dir(); + let schema = crate::seed::BT9000_SETTINGS_SCHEMA; + + // 1. What the radio holds now, decoded by the shipping reader, plus a whole + // image to compare every untouched segment against later. + let before = DRIVER.read_settings(&port, schema).expect("read settings"); + println!(" before: {}", before.settings); + let base = before.backup.clone(); + + // 2. A value in every settled field that is NOT what the radio has, so a + // field that did not move is distinguishable from one that did. + let want = json!({ + "squelch": if before.settings["squelch"] == json!(3) { 7 } else { 3 }, + "vox-level": if before.settings["vox-level"] == json!(4) { 8 } else { 4 }, + "power-on-display": + if before.settings["power-on-display"] == json!("Voltage") { "Picture" } else { "Voltage" }, + }); + println!(" writing: {want}"); + + std::thread::sleep(SETTLE); + let report = DRIVER + .write_settings(&port, &want, schema, &dir) + .expect("write settings"); + println!( + " wrote {} field(s), verified {:?}, backup {}", + report.fields_written, report.verified, report.backup_path + ); + assert_eq!(report.fields_written, 3, "every settled field should be written"); + // ⚠ This radio acknowledges blocks it does not always commit — the APRS + // block answers 0x06 forever and never changes — so the driver's own + // read-back verdict is the claim under test, not a formality. + assert_eq!(report.verified, Some(true), "the driver's read-back must confirm the write"); + + // 3. Read it back independently of the write session. + std::thread::sleep(SETTLE); + let after = DRIVER.read_settings(&port, schema).expect("read settings back"); + println!(" after: {}", after.settings); + for key in ["squelch", "vox-level", "power-on-display"] { + assert_eq!(after.settings[key], want[key], "{key} did not come back as written"); + } + + // 4. ★ Nothing outside the function block moved. This is the assertion that + // a narrowed write exists for: the operator's 960 channels, the VFO, the + // DTMF codes and the modulation memories are all still exactly as read. + for seg in READ_SEGMENTS { + if seg.name == "function" { + continue; + } + let r = seg.file_offset..seg.file_offset + seg.length; + assert_eq!( + base[r.clone()], + after.backup[r], + "segment {} changed during a SETTINGS write", + seg.name + ); + } + println!(" ★ every segment but `function` is byte-identical to the pre-write image."); + + // 5. Put back what the radio had, so the campaign's own baseline survives. + std::thread::sleep(SETTLE); + let restored = DRIVER + .write_settings(&port, &before.settings, schema, &dir) + .expect("restore the original settings"); + assert_eq!(restored.verified, Some(true), "restore must verify"); + println!(" restored the settings the radio started with."); +} From 40e09022567d2907beb38d3cc3c526b6e093d118 Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 07:27:06 -0600 Subject: [PATCH 05/15] BT-9000: a tripwire for the receive-only channel the band work will create (#43) Found by reviewing what the code will be able to do after the NEXT planned change rather than what it does now. `encode_channel` sets the per-channel TX-enable bit unconditionally (`m[15] = 0x02 | narrow`). That is inert today only because the BT-9000's `rx_bands` and `tx_bands` are the same two spans, so `channel_fit` can never return `ReceiveOnly` for this radio. Widening `rx_bands` is an expected outcome of the band work: this radio receives broadcast FM, AM and SSB, and its `F` handshake blob hints at a third span at 200-260 MHz. The moment the two lists diverge, every out-of-TX channel starts being programmed transmit-enabled -- on a radio that validates nothing and will key up wherever it is told. Not fixed, deliberately. The fix depends on byte 15 bit 1, which the inherited reverse-engineering calls "TX enable" and nobody has measured, and clearing an unverified bit is how radios have been damaged on this platform. The FT5D's opposite choice does not transfer either: it encodes receive-only channels as ordinary memories because "the radio polices its own TX bands", and this one polices nothing. So instead: a test beside the band lists that fires the moment they diverge and says what has to be settled first, and a measurement added to the campaign -- program a channel with the bit cleared, select it, press PTT. If the radio refuses, the bit means what the source says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- src-tauri/src/seed.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index 66b6def..6bece9d 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -892,6 +892,44 @@ mod tests { /// ⚠️ `UIS` mirrors the keys of `PROGRAM_DIALOGS` in /// `src/components/codeplugs/programDialogs.ts`; adding a bespoke dialog /// means adding its key in both places. + /// ⚠ TRIPWIRE, not an invariant anybody wants to keep. + /// + /// The BT-9000's `rx_bands` and `tx_bands` are currently the SAME two + /// spans, which is the only reason `channel_fit` can never return + /// `ReceiveOnly` for this radio — and the only reason its encoder gets + /// away with setting the per-channel TX-enable bit unconditionally + /// (`m[15] = 0x02 | narrow` in `radios/binteradio_bt9000/mod.rs`). + /// + /// Widening `rx_bands` is an EXPECTED outcome of issue #43's band work: + /// this radio receives broadcast FM, AM and SSB, and its `F` handshake + /// blob hints at a third span at 200-260 MHz. The moment `rx_bands` grows + /// past `tx_bands`, every out-of-TX channel starts being programmed + /// transmit-enabled — on a radio that validates NOTHING and will key up + /// wherever it is told. + /// + /// So before widening them, settle what byte 15 bit 1 actually does. It is + /// claimed as "TX enable" by the inherited reverse-engineering and has + /// never been measured here, and clearing an unverified bit on this + /// platform is how radios get damaged. `scratchpad/binteradio_bt9000/` + /// carries the campaign; the FT5D's opposite choice + /// (`receive_only_channels_encode_as_ordinary_memories`) does NOT transfer, + /// because its reasoning is "the radio polices its own TX bands" and this + /// one does not police anything. + #[test] + fn bt9000_receive_only_channels_cannot_arise_yet() { + let bt = models() + .into_iter() + .find(|m| m.driver_key == Some("binteradio_bt9000")) + .expect("the BT-9000 is seeded"); + assert_eq!( + bt.rx_bands, bt.tx_bands, + "BT-9000 rx_bands and tx_bands have diverged, so a receive-only channel \ + is now possible — decide what the encoder does with byte 15 bit 1 \ + (claimed TX-enable, never measured) BEFORE shipping this. See the \ + comment on this test." + ); + } + #[test] fn every_seeded_driver_key_resolves_and_pairs_with_a_known_ui() { const UIS: [&str; 3] = ["generic", "tdh3", "anytone"]; From 4c31e94891cc3ed617402a37c8799fa4abf41b7c Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 07:40:06 -0600 Subject: [PATCH 06/15] BT-9000: fix seven findings from a review of the branch (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran /code-review over main...HEAD before the merge rather than after, which is the lesson from the CSV importer: a screen check does not sample the input, and two channel-breaking parser bugs shipped that way. The worst two were in the settings write control added earlier today. * ★★★ "Write to radio" was ENABLED right after "Download from radio", and would have written the previously saved values back over the ones just read. `dirty` compared the form against `baseline`, which exists to mean "not typed by the operator" and therefore absorbs a radio read on purpose -- so the one path that most needed the guard was the one path it did not cover. Now compared against a separate snapshot of what the database actually holds, updated only by a successful save. Verified on screen: after a download, the button is disabled and says to Save first. * A blank number field aborted the ENTIRE write. The form stores a cleared input as "", `strip_out_of_range` only inspects numbers, and `encode_field` rejected it as Invalid -- so clearing SQL stopped VOX Level and Power On Display reaching the radio too. Treated as "leave it alone", like a missing key. Three more in the same control: * A verified write discarded `res.note`, which is where the list of DROPPED fields lives. Reporting "verified ✓" while silently binning the record of what never made it is worse than not reporting. * `verified: null` was treated as failure. It means the radio offers no in-session read-back, not that one disagreed. * The bar rendered for the AnyTone, whose settings commit reboots the radio and re-enumerates USB -- so it reports `verified: null` plus an `expected_path` to diff in a fresh session, and a generic bar showed neither. Now limited to radios on the generic programming UI; a radio with its own dialog already offers this where the specifics can be explained. And two in the driver: * The failure hints promised the operator that the pre-write backup "can be uploaded back over the same cable", and no control in the app could do it -- `restore_image` was false. Implemented `ImageRestorer` rather than weakening the sentence: this is the radio whose platform has a documented unit with permanently degraded transmit, and hardware ladder step 1 already proved the operation. The restore verifies by read-back, because an ACK here is not a commit. * `mode: None` encoded as NARROW while `export::channel_fit` resolves the same NULL to "FM" when deciding the channel is programmable, so a channel admitted as wide FM was programmed narrow. `mode` is nullable and reachable from a CSV import with no mode column. Narrow now requires an explicit narrow mode. AM remains a documented gap: byte 15 bit 0 is claimed to select it and has never been measured here. Last one is shared UI the BT-9000 newly reaches: the generic Program dialog's safety banner promised "your profile's radio settings" on all six radios it serves, and only the UV-5R's `program_codeplug` carries them. Added an explicit `carries_profile_settings` on ImageProgrammer -- declared, not inferred, since populating `req.settings` says nothing about whether a driver uses it -- and the banner now names the separate Write to radio control instead when the radio has one. Also adds the step-4 gate the process asks for and this radio never had: a test that runs the app's OWN pipeline against a real database. Four channels in, three out -- the 220 MHz repeater is excluded rather than written, which on a radio that stores whatever it is handed is the only thing between it and a memory that keys up out of band. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- src-tauri/src/commands/export.rs | 126 ++++++++++++++++++ src-tauri/src/radios/baofeng_uv5r/mod.rs | 8 ++ src-tauri/src/radios/binteradio_bt9000/mod.rs | 116 +++++++++++++++- .../src/radios/binteradio_bt9000/settings.rs | 23 +++- src-tauri/src/radios/driver.rs | 23 ++++ src-tauri/src/radios/icom_id52/mod.rs | 1 + src-tauri/src/radios/kenwood_thd75/mod.rs | 1 + src-tauri/src/radios/yaesu_ft5d/mod.rs | 1 + .../codeplugs/ProgramRadioDialog.tsx | 18 ++- src/components/profiles/ProfileEditor.tsx | 90 +++++++++---- src/lib/types.ts | 4 + 11 files changed, 381 insertions(+), 30 deletions(-) diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index f85edf2..7f8d121 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -1323,6 +1323,132 @@ mod tests { let _ = std::fs::remove_file(&db_path); } + /// Issue #43, the step-4 gate: run the app's OWN pipeline against a real + /// database and count what came out against what went in. The BT-9000's + /// encoder has unit tests and a hardware ladder, and neither of those goes + /// through `resolve_codeplug_slots` — the thing that decides which channels + /// reach the radio at all, what they are called, and which slot each lands + /// in. + /// + /// What this pins for that radio specifically: + /// + /// * an out-of-band channel is EXCLUDED, not silently dropped or written — + /// this radio stores whatever it is handed, so the pipeline is the only + /// thing standing between a 222 MHz channel and a memory that keys up + /// there; + /// * names are cut to 12 characters, the radio's real field width; + /// * slots are dense and sequential from 0, which is what makes the zones + /// work at all — they are index arithmetic (`slot / 64 + 1`) and there is + /// nowhere in the image to store a zone name; + /// * and the image decodes back to exactly the channels that went in. + #[tokio::test] + async fn a_bt9000_codeplug_reaches_the_image_through_the_apps_own_pipeline() { + let dir = std::env::temp_dir().join(format!("cpm_bt9000_{}", std::process::id())); + let db_path = dir.join("test.sqlite3"); + let _ = std::fs::remove_file(&db_path); + let pool = crate::db::init_pool(&db_path).await.expect("init_pool"); + + // Four channels. The third is inside the radio's coverage on RX but its + // repeater INPUT is not, and the fourth is out of band at both ends. + sqlx::query( + "INSERT INTO channels (id, name_long, name_short, rx_freq, offset, duplex, mode, source) + VALUES (1, 'A Very Long Repeater Name', NULL, 146.940, 0.6, '-', 'FM', 'manual'), + (2, 'Simplex', 'SIMP', 146.520, NULL, NULL, 'FM', 'manual'), + (3, 'UHF Machine', 'UHF', 442.000, 5.0, '+', 'FM', 'manual'), + (4, '220 Repeater', '220', 223.500, 1.6, '-', 'FM', 'manual')", + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query("INSERT INTO channel_lists (id, name) VALUES (10, 'Local')") + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO channel_list_entries (channel_list_id, channel_id, position) + VALUES (10, 1, 0), (10, 2, 1), (10, 3, 2), (10, 4, 3)", + ) + .execute(&pool) + .await + .unwrap(); + + let model_id: (i64,) = + sqlx::query_as("SELECT id FROM radio_models WHERE model = 'BT-9000'") + .fetch_one(&pool) + .await + .expect("the BT-9000 is seeded"); + // A codeplug reaches its model through a radio PROFILE, which is also + // how the settings this radio now carries get to the same place. + sqlx::query( + "INSERT INTO radio_profiles (id, display_name, radio_model_id, non_channel_settings) + VALUES (1, 'BT-9000 test', ?1, '{\"squelch\": 4}')", + ) + .bind(model_id.0) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO codeplugs (id, name, radio_profile_id) VALUES (1, 'Test', 1)") + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO codeplug_channel_lists (codeplug_id, channel_list_id, position) VALUES (1, 10, 0)") + .execute(&pool) + .await + .unwrap(); + + let (model, slots) = resolve_codeplug_slots(&pool, 1).await.expect("resolve slots"); + assert_eq!(model.driver_key.as_deref(), Some("binteradio_bt9000")); + + // 4 in, 3 out: the 220 MHz repeater is outside both band lists, and the + // pipeline refuses it rather than handing the driver a channel the radio + // would happily store and transmit on. + assert_eq!(slots.len(), 3, "the 220 MHz channel must not reach the radio"); + assert!( + slots.iter().all(|s| s.channel.rx_freq != 223.500), + "223.500 reached the slot list" + ); + + // Dense and sequential, which is what makes zone = slot / 64 + 1 mean + // anything on a radio with no zone names. + for (i, s) in slots.iter().enumerate() { + assert_eq!(s.slot, i, "slots must be dense from 0"); + assert!( + s.name.chars().count() <= 12, + "{:?} is longer than the radio's 12-character field", + s.name + ); + } + + // Through the driver's own image builder and back out again. + let driver = crate::radios::registry::driver_for_model(&model) + .expect("driver") + .as_image_programmer() + .expect("image programmer"); + let base = vec![0u8; crate::radios::binteradio_bt9000::IMAGE_LEN]; + let image = driver.build_image(&model, &slots, &base).expect("build_image"); + let decoded = crate::radios::binteradio_bt9000::decode_channels(&image); + + assert_eq!(decoded.len(), slots.len(), "channels in the image"); + for (d, s) in decoded.iter().zip(&slots) { + // `trim_end` is not slack: the shared truncator cuts to the field + // width without regard for where words end, so a long name can + // arrive as "A Very Long " — 12 characters ending in a space — and + // this driver's decoder trims trailing blanks on the way back. The + // round trip is stable apart from that space, which is invisible on + // the radio. Asserted rather than papered over so the day the + // truncator learns to trim, this says so. + assert_eq!(d.name, s.name.trim_end(), "name round trip"); + assert_eq!(d.zone, 1, "the first 64 slots are zone 1"); + } + // The repeater's transmit frequency is the shifted one, not the input + // frequency repeated -- the pipeline resolves the shift, not the driver. + assert_eq!(decoded[0].rx_mhz, 146.940); + assert_eq!(decoded[0].tx_mhz, 146.340); + + let _ = std::fs::remove_file(&db_path); + } + /// Issue #83: CHIRP reads the Offset column of a `split` row as the /// absolute TRANSMIT FREQUENCY, not a shift. Writing the magnitude there /// gave a 33 cm pair (927.5 RX / 902.5 TX) the row `split, 25.000000` — a diff --git a/src-tauri/src/radios/baofeng_uv5r/mod.rs b/src-tauri/src/radios/baofeng_uv5r/mod.rs index 5545a41..ee99d33 100644 --- a/src-tauri/src/radios/baofeng_uv5r/mod.rs +++ b/src-tauri/src/radios/baofeng_uv5r/mod.rs @@ -186,6 +186,14 @@ impl ImageRestorer for BaofengUv5r { } impl ImageProgrammer for BaofengUv5r { + /// The UV-5R is the only radio here that answers yes. It has no standalone + /// settings-write path — `SettingsWriter` is deliberately not implemented — + /// so the profile's settings ride out inside the image this uploads, which + /// is exactly why the two halves are separate traits. + fn carries_profile_settings(&self) -> bool { + true + } + fn download_image(&self, port: &str) -> Result<(RadioIdentity, Vec), String> { let mut p = open_port(port)?; let (matched, ident) = ident_radio(&mut *p)?; diff --git a/src-tauri/src/radios/binteradio_bt9000/mod.rs b/src-tauri/src/radios/binteradio_bt9000/mod.rs index c946980..8ada5cb 100644 --- a/src-tauri/src/radios/binteradio_bt9000/mod.rs +++ b/src-tauri/src/radios/binteradio_bt9000/mod.rs @@ -54,7 +54,7 @@ use crate::commands::export::SlotChannel; use crate::models::{Channel, RadioModel}; use crate::radios::driver::{ CodeplugProgramReport, DecodedChannelSample, ImageProgramRequest, ImageProgrammer, - RadioDriver, RadioIdentity, + ImageRestorer, RadioDriver, RadioIdentity, }; const BAUD: u32 = 115_200; @@ -670,7 +670,19 @@ fn encode_channel(c: &Channel, name: &str, tx_hz: u64) -> [u8; ENTRY_LEN] { // bit 1 = TX enable, bit 6 = narrow. Everything else (FHSS, encryption, // busy lockout, scan-add, AM) stays off — measured defaults, not guesses. - let narrow = !matches!(c.mode.as_deref(), Some(m) if m.eq_ignore_ascii_case("FM")); + // + // ⚠ Narrow ONLY on an explicit narrow mode. `mode` is nullable in the + // schema and reachable from a CSV import with no mode column, and + // `export::channel_fit` resolves a NULL to "FM" when it decides the channel + // is programmable — so treating NULL as *not* FM narrowed a channel that + // the fit logic had just admitted as wide FM. The two now agree. + // + // ⚠ AM is a known gap, deliberately left. This radio receives AM and byte + // 15 bit 0 is claimed to select it, but that bit has never been measured + // here, and an AM channel inside 136-174 MHz therefore goes out as wide FM. + // `scratchpad/binteradio_bt9000/SCREEN-CHECK.md` carries the measurement; + // guessing the bit is how radios get damaged on this platform. + let narrow = matches!(c.mode.as_deref(), Some(m) if m.eq_ignore_ascii_case("NFM")); m[15] = 0x02 | if narrow { 0x40 } else { 0x00 }; // 16-19 = FHSS code, left zero. @@ -739,6 +751,10 @@ impl RadioDriver for BinteradioBt9000 { Some(self) } + fn as_image_restorer(&self) -> Option<&dyn ImageRestorer> { + Some(self) + } + fn as_settings_reader(&self) -> Option<&dyn crate::radios::driver::SettingsReader> { Some(self) } @@ -748,6 +764,74 @@ impl RadioDriver for BinteradioBt9000 { } } +/// Putting a backup back on the radio. +/// +/// Offered because this driver's own error messages promise it. Every failure +/// path in here hands the operator a pre-write backup and tells them it can go +/// back over the same cable — and until this existed, no control in the app +/// could do that, on the one radio in this crate whose platform has a +/// documented unit with permanently degraded transmit. +/// +/// It is not new risk: hardware ladder step 1 was exactly this operation — a +/// byte-identical image written back and read back — and it passed. +impl ImageRestorer for BinteradioBt9000 { + /// Refuse a file that is not a BT-9000 clone image before a byte of it + /// reaches the radio. + /// + /// Length is what separates the formats in `radio-backups/`, which holds + /// images for every radio this app talks to and is where the picker opens. + /// 33,152 bytes is this radio's exact clone payload and is shared with none + /// of the others. + /// + /// ⚠ The check is deliberately shape-only, and does NOT try to prove which + /// unit the image came from. Restoring a backup taken from another radio of + /// the same model is a normal thing to do, and this is the path reached for + /// after a bad write. There is also nothing in the image to key on: it + /// carries no serial number, no ident prefix and — measured in s127 — no + /// checksum anywhere in its 33,152 bytes. + fn check_restore_image(&self, image: &[u8]) -> Result<(), String> { + if image.len() != IMAGE_LEN { + return Err(format!( + "this file is {} bytes — a BT-9000 backup is exactly {IMAGE_LEN}. Pick a \ + .img taken from a BT-9000 (radio-backups/ also holds images for other \ + radios, which must not be written to this one).", + image.len() + )); + } + Ok(()) + } + + /// Write the whole backup back in one session, then read it back. + /// + /// ⚠ The read-back is not decoration on this radio: it acknowledges blocks + /// it does not always commit, so an all-ACKs write is not evidence. A + /// restore that cannot be confirmed says so rather than reporting success — + /// this is the path somebody reaches for when a write has already gone + /// wrong, and it is the worst possible place to be optimistic. + fn restore_image(&self, port: &str, image: &[u8]) -> Result<(), String> { + self.check_restore_image(image)?; + let mut p = open_port(port)?; + let hs = handshake(&mut *p)?; + upload(&mut *p, &hs, image)?; + + std::thread::sleep(SETTLE); + let hs = handshake(&mut *p)?; + let back = download(&mut *p, &hs)?; + for seg in WRITE_SEGMENTS { + let r = seg.file_offset..seg.file_offset + seg.length; + if image[r.clone()] != back[r] { + return Err(format!( + "the radio acknowledged the restore but segment {} read back \ + differently. The radio does NOT hold this backup. Power-cycle it \ + and try the restore again.", + seg.name + )); + } + } + Ok(()) + } +} + impl ImageProgrammer for BinteradioBt9000 { fn download_image(&self, port: &str) -> Result<(RadioIdentity, Vec), String> { let mut p = open_port(port)?; @@ -1067,6 +1151,34 @@ mod tests { } /// Blank and named channels use *different* pad bytes on this radio. + /// A channel with no mode is wide FM, because that is what + /// `export::channel_fit` decided when it let the channel through. The two + /// used to disagree, and the encoder silently narrowed it. + #[test] + fn a_null_mode_encodes_as_wide_fm() { + let wide = Channel { rx_freq: 146.52, ..Default::default() }; + assert_eq!(encode_channel(&wide, "NOMODE", 146_520_000)[15] & 0x40, 0x00); + + let fm = Channel { rx_freq: 146.52, mode: Some("FM".into()), ..Default::default() }; + assert_eq!(encode_channel(&fm, "FM", 146_520_000)[15] & 0x40, 0x00); + + let nfm = Channel { rx_freq: 146.52, mode: Some("NFM".into()), ..Default::default() }; + assert_eq!(encode_channel(&nfm, "NFM", 146_520_000)[15] & 0x40, 0x40); + + // Round-trips through the decoder, which knows only these two. + for (mode, want) in [(None, "FM"), (Some("FM"), "FM"), (Some("NFM"), "NFM")] { + let c = Channel { + rx_freq: 146.52, + mode: mode.map(str::to_string), + ..Default::default() + }; + let rec = encode_channel(&c, "X", 146_520_000); + let mut image = vec![0xFFu8; IMAGE_LEN]; + image[..ENTRY_LEN].copy_from_slice(&rec); + assert_eq!(decode_channels(&image)[0].narrow, want == "NFM", "{mode:?}"); + } + } + #[test] fn names_use_the_radios_two_sentinels() { assert_eq!(name_bytes(""), [0x00; NAME_LEN]); diff --git a/src-tauri/src/radios/binteradio_bt9000/settings.rs b/src-tauri/src/radios/binteradio_bt9000/settings.rs index d9ea339..185873a 100644 --- a/src-tauri/src/radios/binteradio_bt9000/settings.rs +++ b/src-tauri/src/radios/binteradio_bt9000/settings.rs @@ -173,7 +173,12 @@ pub(crate) fn apply_profile_settings( let (mut written, mut notes) = (0, Vec::new()); for f in &FIELDS { let Some(v) = obj.get(f.key) else { continue }; - if v.is_null() { + // ⚠ A CLEARED number input is stored as `""`, not null — the form's + // number field maps an empty box to the empty string. Treated as + // "leave it alone" like a missing key, because the alternative is what + // it used to do: reject the whole write, so clearing one field stopped + // every OTHER field from reaching the radio too. + if v.is_null() || v.as_str() == Some("") { continue; } match encode_field(f, v) { @@ -500,6 +505,22 @@ mod tests { assert_eq!(encode_field(vox, &json!(7)).unwrap(), 6); } + /// Clearing one field in the form must not stop the others reaching the + /// radio. The form stores an empty number box as `""`, and rejecting that + /// as "expected a number" used to fail the entire write. + #[test] + fn a_cleared_field_is_skipped_not_fatal() { + let mut image = vec![0u8; super::super::IMAGE_LEN]; + let (n, notes) = apply_profile_settings( + &mut image, + &json!({"squelch": "", "vox-level": 5, "power-on-display": null}), + ) + .expect("a cleared field must not fail the write"); + assert_eq!(n, 1, "vox-level should still have been written"); + assert!(notes.is_empty()); + assert_eq!(image[FUNCTION_OFFSET + 0x02], 4, "vox-level 5 stores as 4"); + } + /// A key the profile does not carry must come back off the radio untouched. #[test] fn unknown_keys_are_left_alone() { diff --git a/src-tauri/src/radios/driver.rs b/src-tauri/src/radios/driver.rs index 851d409..664e9cc 100644 --- a/src-tauri/src/radios/driver.rs +++ b/src-tauri/src/radios/driver.rs @@ -192,6 +192,22 @@ pub(crate) trait ImageProgrammer: Send + Sync { port: &str, req: &ImageProgramRequest, ) -> Result; + + /// Whether `program_codeplug` writes the profile's non-channel settings + /// alongside the channels. + /// + /// It is NOT implied by `req.settings` being populated — the command layer + /// fills that for every driver, and most deliberately ignore it. A radio + /// with its own `SettingsWriter` generally treats settings as a separate, + /// explicitly-acknowledged operation and leaves the radio's own settings + /// alone during a channel program (the TD-H3 and the BT-9000 both do). + /// + /// Declared rather than inferred because the generic Program dialog states + /// in a safety banner what the operation will change, and that sentence was + /// promising settings on five of the six radios it serves. + fn carries_profile_settings(&self) -> bool { + false + } } /// Attach the pre-write backup to an error raised DURING the write phase. @@ -591,6 +607,10 @@ pub struct DriverCapabilities { pub write_settings: bool, pub write_channels: bool, pub program_codeplug: bool, + /// Whether a codeplug program also writes the profile's settings. Only the + /// UV-5R does: it has no standalone settings-write path, so its settings + /// ride out inside the image `program_codeplug` uploads. + pub programs_settings: bool, pub write_callsign_db: bool, pub export: bool, pub diagnostics: bool, @@ -608,6 +628,9 @@ impl DriverCapabilities { write_settings: driver.as_settings_writer().is_some(), write_channels: driver.as_channel_writer().is_some(), program_codeplug: driver.as_codeplug_programmer().is_some(), + programs_settings: driver + .as_image_programmer() + .is_some_and(ImageProgrammer::carries_profile_settings), write_callsign_db: driver.as_callsign_db_writer().is_some(), export: driver.as_codeplug_exporter().is_some(), diagnostics: driver.as_diagnostics().is_some(), diff --git a/src-tauri/src/radios/icom_id52/mod.rs b/src-tauri/src/radios/icom_id52/mod.rs index 924f496..e386bb8 100644 --- a/src-tauri/src/radios/icom_id52/mod.rs +++ b/src-tauri/src/radios/icom_id52/mod.rs @@ -188,6 +188,7 @@ mod tests { write_settings: false, write_channels: false, program_codeplug: false, + programs_settings: false, write_callsign_db: false, export: true, diagnostics: false, diff --git a/src-tauri/src/radios/kenwood_thd75/mod.rs b/src-tauri/src/radios/kenwood_thd75/mod.rs index 6fe32ae..2c4c09d 100644 --- a/src-tauri/src/radios/kenwood_thd75/mod.rs +++ b/src-tauri/src/radios/kenwood_thd75/mod.rs @@ -124,6 +124,7 @@ mod tests { write_settings: false, write_channels: false, program_codeplug: false, + programs_settings: false, write_callsign_db: false, export: true, diagnostics: false, diff --git a/src-tauri/src/radios/yaesu_ft5d/mod.rs b/src-tauri/src/radios/yaesu_ft5d/mod.rs index e235009..72a5353 100644 --- a/src-tauri/src/radios/yaesu_ft5d/mod.rs +++ b/src-tauri/src/radios/yaesu_ft5d/mod.rs @@ -283,6 +283,7 @@ mod tests { write_settings: false, write_channels: false, program_codeplug: false, + programs_settings: false, write_callsign_db: false, export: true, diagnostics: false, diff --git a/src/components/codeplugs/ProgramRadioDialog.tsx b/src/components/codeplugs/ProgramRadioDialog.tsx index ebdec9d..4539e40 100644 --- a/src/components/codeplugs/ProgramRadioDialog.tsx +++ b/src/components/codeplugs/ProgramRadioDialog.tsx @@ -426,9 +426,21 @@ export function ProgramRadioDialog({ Programming downloads a full backup first, then - writes the channels, names, and your profile’s radio settings, and - reads the channels back to verify. Only the values your profile - defines change — everything else is written back as it was read. + writes the channels and names + {caps?.programs_settings + ? " and your profile’s radio settings" + : ""} + , and reads the channels back to verify. Only the values your + profile defines change — everything else is written back as it + was read. + {caps?.programs_settings === false && caps?.write_settings && ( + <> + {" "} + This radio’s settings are written separately — open the + profile under Radios and use{" "} + Write to radio. + + )} )} diff --git a/src/components/profiles/ProfileEditor.tsx b/src/components/profiles/ProfileEditor.tsx index 6096c8b..0be7fcf 100644 --- a/src/components/profiles/ProfileEditor.tsx +++ b/src/components/profiles/ProfileEditor.tsx @@ -148,10 +148,15 @@ function WriteToRadioBar({ profileId, modelLabel, dirty, + neverSaved, }: { profileId: number; modelLabel: string; dirty: boolean; + /// The profile row carries no settings yet, so there is nothing to send and + /// the command would only error. Treated like `dirty`: same button, same + /// instruction. + neverSaved: boolean; }) { const [ports, setPorts] = useState([]); const [port, setPort] = useState(""); @@ -185,11 +190,20 @@ function WriteToRadioBar({ const { toast } = await import("sonner"); const n = res.fields_written; const applied = `Wrote ${n} setting${n === 1 ? "" : "s"} to the radio`; - // `verified` is the only evidence that matters on a radio that - // acknowledges blocks it does not always commit, so it leads the message - // and an unverified write is a warning rather than a success. + // ⚠ `note` carries the fields that were DROPPED — out of range for the + // schema, or a select value this app cannot name — so it has to be shown + // whether or not the write verified. Reporting "verified ✓" while silently + // discarding the list of what never made it is worse than not reporting. + const suffix = res.note ? ` — ${res.note}` : ""; + // Three states, not two. `verified: null` means the radio offers no + // in-session read-back (the AnyTone reboots on commit), which is not the + // same as a read-back that disagreed. if (res.verified === true) { - toast.success(`${applied} · read back and verified ✓`); + toast.success(`${applied} · read back and verified ✓${suffix}`); + } else if (res.verified === null) { + toast.info( + `${applied}. This radio cannot be read back in the same session, so the write is unverified${suffix}`, + ); } else { toast.warning(res.note || `${applied}, but the read-back did not confirm it.`); } @@ -225,17 +239,22 @@ function WriteToRadioBar({ - {dirty && ( + {(dirty || neverSaved) && (

- This profile has unsaved changes. The radio is written from the saved - profile, so Save first. + {neverSaved + ? "This profile has not been saved yet. The radio is written from the saved profile, so Save first." + : "This profile has unsaved changes. The radio is written from the saved profile, so Save first."}

)} {confirming && ( @@ -875,6 +894,11 @@ export function ProfileEditor({ // from a radio rather than from the operator, and blocking the save over it // would strand a profile they never typed into (see below). const [baseline, setBaseline] = useState({}); + // What the DATABASE holds, which is a different question from `baseline`. + // `baseline` means "not typed by the operator" and deliberately absorbs a + // read from the radio, so it cannot answer "is this profile saved?" — and + // `write_radio_settings` sends the SAVED row, not the form. + const [saved, setSaved] = useState({}); const [lastId, setLastId] = useState(null); if (profile.id !== lastId) { setName(profile.display_name); @@ -889,6 +913,7 @@ export function ProfileEditor({ ); setValues(seeded); setBaseline(seeded); + setSaved(seeded); setLastId(profile.id); setTab("settings"); setSubTab(null); @@ -897,16 +922,21 @@ export function ProfileEditor({ const setValue = (key: string, v: string | number | boolean) => setValues((s) => ({ ...s, [key]: v })); - // `write_radio_settings` sends the profile as STORED, so an unsaved edit - // would not reach the radio. Comparing against `baseline` rather than the - // profile row keeps a value just read off the radio from counting as an edit, - // the same way `rangeErrors` does. + // `write_radio_settings` sends the profile as STORED, so anything the form + // holds that the database does not must block the write. + // + // ⚠ This compares against `saved`, NOT `baseline`. `baseline` absorbs a read + // from the radio on purpose, so using it here made the button live again the + // instant "Download from radio" finished — and that write would have sent the + // PREVIOUSLY SAVED values straight back over the ones just read, while the + // form went on displaying the radio's. The one path that most needs the guard + // was the one path it did not cover. const dirty = useMemo( () => name !== profile.display_name || notes !== (profile.notes ?? "") || - fields.some((f) => values[f.key] !== baseline[f.key]), - [name, notes, values, baseline, fields, profile], + fields.some((f) => values[f.key] !== saved[f.key]), + [name, notes, values, saved, fields, profile], ); // Values that came off the radio (or its card) are the new starting point, @@ -964,7 +994,10 @@ export function ProfileEditor({ { success: "Profile saved" }, ); setSaving(false); - if (updated) onSaved(updated); + if (updated) { + setSaved(values); + onSaved(updated); + } }; const remove = async () => { @@ -1047,14 +1080,23 @@ export function ProfileEditor({ )} {/* The inverse, gated on the driver's write capability for the same reason. Card radios never get it: their settings are patched - into the file the export writes, not sent over a cable. */} - {caps?.write_settings && fields.length > 0 && ( - - )} + into the file the export writes, not sent over a cable. + ⚠ And only for radios on the GENERIC programming UI. A radio + with a bespoke dialog already offers this write there, in a + place that can speak to what makes it unusual — the AnyTone's + settings commit reboots the radio and re-enumerates USB, so it + reports `verified: null` plus an `expected_path` to diff in a + fresh session, and a generic bar would show neither. */} + {caps?.write_settings && + fields.length > 0 && + (model.programming_ui ?? "generic") === "generic" && ( + + )} {/* A card radio's settings come off its microSD rather than a cable, so it gets a file picker where the others get a port picker. Keyed on the export format — the same key that names the diff --git a/src/lib/types.ts b/src/lib/types.ts index 6f6c16c..5489fe9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -184,6 +184,10 @@ export interface DriverCapabilities { write_settings: boolean; write_channels: boolean; program_codeplug: boolean; + /// Whether a codeplug program ALSO writes the profile's settings. Only the + /// UV-5R does; every other radio here treats settings as a separate, + /// explicitly-acknowledged write and leaves the radio's own alone. + programs_settings: boolean; write_callsign_db: boolean; export: boolean; diagnostics: boolean; From 0620cbcc984c72a01f5d0c588ea1cfcb6dd17f87 Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 08:55:02 -0600 Subject: [PATCH 07/15] =?UTF-8?q?BT-9000:=20the=20settings=20schema,=20mea?= =?UTF-8?q?sured=20on=20the=20radio=20=E2=80=94=203=20fields=20to=2031=20(?= =?UTF-8?q?#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran Pass A through C with Tim at the radio. Every field in the schema now has its encoding read off the radio's own screen; the generator still withholds everything else. Four things the radio contradicted, each of which would have shipped a wrong value on a radio that validates nothing: * ROGRE is not a switch. The manual prints `OFF/ON` and the source calls it a 0/1 flag; the radio offers OFF, Beep, Tone 1200. * The MDF display fields are `NAME, FREQUENCY, CHANNEL NUM.` -- the exact REVERSE of the source's `Channel/Freq/Name`. All three display lines would have been wrong. * RP-STE and RPT-RL are 11-entry millisecond lists, not booleans. * Back Light and Keypad Lock both start at 5 sec. The manual prints "0.5sec" for both, and a schema built from it would have offered an option the radio does not have, twice. And one refuted outright: `0x0B` is NOT PTT-ID. The image verifiably held 2, the radio's own list is `OFF, BOT, EOT, BOTH` so index 2 is EOT, and the menu read OFF. A reverse probe then found S-CODE moving CHANNEL record byte 12 -- so both of those Signaling items edit the current channel, not the radio, and neither belongs in a radio profile. The menu census was wrong in three separate ways, not the one the sheet knew about: Radio is +1 (Work Band), Signaling has SIX items (three DTMF timing entries the manual omits, pushing DTMFST from 3 to 6), and VFO&CH changes its CONTENTS with the radio's mode. Never derive a menu number for this radio from the manual. Work Band is in NO segment of the clone image -- channels, VFO, function, DTMF, modulation and APRS were all byte-identical across a change. It cannot be a profile field. Its options are frequency RANGES (18-64 MHz, 64-999), which is the receiver describing coverage far beyond the seeded 136-174/400-520, but that bears on rx_bands only and the tripwire added earlier still gates widening it. Two fixes to code shipped this morning, both found on hardware: * ★ `verify()` compared all 256 bytes of the function segment. Only 0x00-0x45 is live settings: 0x46-0x7F is 0xFF filler and 0x80-0xFF is a firmware-maintained SHADOW of the live block (+0xD0 onward is byte-for-byte identical to it) which moves on its own. A restore that landed perfectly was reported as a 13-byte mismatch. Narrowed to FUNCTION_LIVE_LEN. * A settings write acknowledged every block and did not commit; an identical second write landed. Since the read-back is what caught it and the write is idempotent and takes 0.35 s, it now retries ONCE and says so in the report. Exactly once -- hammering a write path is how radios have been damaged on this platform. ⚠ Tested and REJECTED: narrowing the settings write to the live half to leave the firmware's shadow alone, by analogy with the VFO journal. The half-segment write commits but is NOT acknowledged -- the inverse of the APRS block -- and this driver treats a missing ACK as a hard abort, so it would stop mid-write on data that had already landed. The full-segment write stays. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- src-tauri/src/bt9000_settings_schema.json | 329 ++++++++++++++++++ .../bt9000_settings_table.rs | 262 +++++++++++++- src-tauri/src/radios/binteradio_bt9000/mod.rs | 15 + .../src/radios/binteradio_bt9000/settings.rs | 41 ++- 4 files changed, 635 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/bt9000_settings_schema.json b/src-tauri/src/bt9000_settings_schema.json index 9fbbd0f..c13e500 100644 --- a/src-tauri/src/bt9000_settings_schema.json +++ b/src-tauri/src/bt9000_settings_schema.json @@ -11,6 +11,34 @@ "min": 1, "max": 9 }, + { + "key": "vox-delay", + "label": "VOX Delay", + "type": "select", + "options": [ + "0.5 sec", + "0.6 sec", + "0.7 sec", + "0.8 sec", + "0.9 sec", + "1.0 sec", + "1.1 sec", + "1.2 sec", + "1.3 sec", + "1.4 sec", + "1.5 sec", + "1.6 sec", + "1.7 sec", + "1.8 sec", + "1.9 sec", + "2.0 sec" + ] + }, + { + "key": "vox-switch", + "label": "VOX Switch", + "type": "boolean" + }, { "key": "section-radio", "label": "Radio", @@ -23,11 +51,207 @@ "min": 1, "max": 9 }, + { + "key": "battery-save", + "label": "Battery Save", + "type": "select", + "options": [ + "OFF", + "Normal", + "Super", + "DEEP" + ] + }, + { + "key": "standby-set", + "label": "Standby Set", + "type": "boolean" + }, + { + "key": "tot", + "label": "TOT", + "type": "select", + "options": [ + "OFF", + "30sec", + "60sec", + "90sec", + "120sec", + "150sec", + "180sec", + "210sec", + "240sec" + ] + }, + { + "key": "scan-mode", + "label": "Scan Mode", + "type": "select", + "options": [ + "Time", + "Carrier", + "Search" + ] + }, + { + "key": "sos-mode", + "label": "SOS Mode", + "type": "select", + "options": [ + "On Site", + "Send Sound", + "Send Code" + ] + }, + { + "key": "tall", + "label": "TALL", + "type": "boolean" + }, + { + "key": "rp-ste", + "label": "RP-STE", + "type": "select", + "options": [ + "OFF", + "100ms", + "200ms", + "300ms", + "400ms", + "500ms", + "600ms", + "700ms", + "800ms", + "900ms", + "1000ms" + ] + }, + { + "key": "rpt-rl", + "label": "RPT-RL", + "type": "select", + "options": [ + "OFF", + "100ms", + "200ms", + "300ms", + "400ms", + "500ms", + "600ms", + "700ms", + "800ms", + "900ms", + "1000ms" + ] + }, + { + "key": "roger", + "label": "ROGRE", + "type": "select", + "options": [ + "OFF", + "Beep", + "Tone 1200" + ] + }, + { + "key": "r-tone", + "label": "R-TONE", + "type": "select", + "options": [ + "1000hz", + "1450hz", + "1750hz", + "2100hz" + ] + }, + { + "key": "ab-rpt-mode", + "label": "AB RPT-Mode", + "type": "boolean" + }, + { + "key": "rpt-speaker", + "label": "RPT-Speaker", + "type": "boolean" + }, + { + "key": "section-vfo-ch", + "label": "VFO&CH", + "type": "section" + }, + { + "key": "mdf-a", + "label": "MDF-A", + "type": "select", + "options": [ + "NAME", + "FREQUENCY", + "CHANNEL NUM." + ] + }, + { + "key": "mdf-b", + "label": "MDF-B", + "type": "select", + "options": [ + "NAME", + "FREQUENCY", + "CHANNEL NUM." + ] + }, + { + "key": "mdf-c", + "label": "MDF-C", + "type": "select", + "options": [ + "NAME", + "FREQUENCY", + "CHANNEL NUM." + ] + }, { "key": "section-setting", "label": "Setting", "type": "section" }, + { + "key": "backlight", + "label": "Back Light", + "type": "select", + "options": [ + "Bright", + "5 sec", + "10 sec", + "15 sec", + "20 sec", + "30 sec", + "1 min", + "2 min", + "3 min" + ] + }, + { + "key": "beep", + "label": "Beep Prompt", + "type": "boolean" + }, + { + "key": "voice", + "label": "Voice", + "type": "boolean" + }, + { + "key": "keypad-lock", + "label": "Keypad Lock", + "type": "select", + "options": [ + "OFF", + "5 sec", + "10 sec", + "15 sec" + ] + }, { "key": "power-on-display", "label": "Power On Display", @@ -36,5 +260,110 @@ "Picture", "Voltage" ] + }, + { + "key": "menu-timeout", + "label": "Menu OutTime", + "type": "select", + "options": [ + "5 sec", + "10 sec", + "15 sec", + "20 sec", + "25 sec", + "30 sec", + "35 sec", + "40 sec", + "45 sec", + "50 sec", + "60 sec" + ] + }, + { + "key": "section-bluetooth", + "label": "Bluetooth", + "type": "section" + }, + { + "key": "bluetooth", + "label": "Bluetooth", + "type": "boolean" + }, + { + "key": "section-signaling", + "label": "Signaling", + "type": "section" + }, + { + "key": "dtmfst", + "label": "DTMFST", + "type": "select", + "options": [ + "OFF", + "DT-ST", + "ANI-ST", + "DT+ANI" + ] + }, + { + "key": "section-user-key", + "label": "User Key", + "type": "section" + }, + { + "key": "pf1-short", + "label": "PF1", + "type": "select", + "options": [ + "RADIO", + "MONI", + "SCAN", + "SEARCH", + "SOS", + "SPECTRUM", + "Beacon TX" + ] + }, + { + "key": "pf1-long", + "label": "Long press PF1", + "type": "select", + "options": [ + "RADIO", + "MONI", + "SCAN", + "SEARCH", + "SOS", + "SPECTRUM", + "Beacon TX" + ] + }, + { + "key": "pf2-short", + "label": "PF2", + "type": "select", + "options": [ + "RADIO", + "MONI", + "SCAN", + "SEARCH", + "SOS", + "SPECTRUM", + "Beacon TX" + ] + }, + { + "key": "pf2-long", + "label": "Long press PF2", + "type": "select", + "options": [ + "RADIO", + "MONI", + "SCAN", + "SEARCH", + "SOS", + "SPECTRUM", + "Beacon TX" + ] } ] diff --git a/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs b/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs index c357fe7..61c87f0 100644 --- a/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs +++ b/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs @@ -13,7 +13,7 @@ //! wrong encoding is stored rather than refused, and this table is the //! only thing standing between the operator and a bad value. //! -//! 3 field(s). The sheet's other rows are measured but not +//! 31 field(s). The sheet's other rows are measured but not //! settled; see its Tally section for what is still owed. /// How a field's value is carried in the byte. @@ -28,12 +28,6 @@ pub(crate) enum Enc { } /// What the form draws, and what the encoder may write. -/// -/// ⚠ No settled field is currently a bool, so that -/// variant is unconstructed today. It is kept because the sheet has rows -/// of that kind waiting on a screen check, and deleting it would mean -/// rewriting the encoder when they land. -#[allow(dead_code)] #[derive(Clone, Copy, PartialEq, Debug)] pub(crate) enum Kind { /// `0 = OFF`, `1 = ON`. @@ -60,7 +54,7 @@ pub(crate) struct SF { pub options: &'static [&'static str], } -pub(crate) const FIELDS: [SF; 3] = [ +pub(crate) const FIELDS: [SF; 31] = [ SF { key: "vox-level", label: "VOX Level", @@ -70,6 +64,24 @@ pub(crate) const FIELDS: [SF; 3] = [ enc: Enc::Minus1, options: &[], }, + SF { + key: "vox-delay", + label: "VOX Delay", + menu: "VOX → 3. VOX Delay", + addr: 0x20, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["0.5 sec", "0.6 sec", "0.7 sec", "0.8 sec", "0.9 sec", "1.0 sec", "1.1 sec", "1.2 sec", "1.3 sec", "1.4 sec", "1.5 sec", "1.6 sec", "1.7 sec", "1.8 sec", "1.9 sec", "2.0 sec"], + }, + SF { + key: "vox-switch", + label: "VOX Switch", + menu: "VOX → 1. VOX Switch", + addr: 0x28, + kind: Kind::Bool, + enc: Enc::Direct, + options: &[], + }, SF { key: "squelch", label: "SQL", @@ -79,6 +91,177 @@ pub(crate) const FIELDS: [SF; 3] = [ enc: Enc::Direct, options: &[], }, + SF { + key: "battery-save", + label: "Battery Save", + menu: "Radio → 7. Battery Save", + addr: 0x01, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["OFF", "Normal", "Super", "DEEP"], + }, + SF { + key: "standby-set", + label: "Standby Set", + menu: "Radio → 6. Standby Set", + addr: 0x04, + kind: Kind::Bool, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "tot", + label: "TOT", + menu: "Radio → 9. TOT", + addr: 0x05, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["OFF", "30sec", "60sec", "90sec", "120sec", "150sec", "180sec", "210sec", "240sec"], + }, + SF { + key: "scan-mode", + label: "Scan Mode", + menu: "Radio → 12. Scan Mode", + addr: 0x0A, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["Time", "Carrier", "Search"], + }, + SF { + key: "sos-mode", + label: "SOS Mode", + menu: "Radio → 18. SOS Mode", + addr: 0x11, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["On Site", "Send Sound", "Send Code"], + }, + SF { + key: "tall", + label: "TALL", + menu: "Radio → 10. TALL", + addr: 0x14, + kind: Kind::Bool, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "rp-ste", + label: "RP-STE", + menu: "Radio → 14. RP-STE", + addr: 0x15, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["OFF", "100ms", "200ms", "300ms", "400ms", "500ms", "600ms", "700ms", "800ms", "900ms", "1000ms"], + }, + SF { + key: "rpt-rl", + label: "RPT-RL", + menu: "Radio → 15. RPT-RL", + addr: 0x16, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["OFF", "100ms", "200ms", "300ms", "400ms", "500ms", "600ms", "700ms", "800ms", "900ms", "1000ms"], + }, + SF { + key: "roger", + label: "ROGRE", + menu: "Radio → 13. ROGRE", + addr: 0x17, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["OFF", "Beep", "Tone 1200"], + }, + SF { + key: "r-tone", + label: "R-TONE", + menu: "Radio → 11. R-TONE", + addr: 0x1E, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["1000hz", "1450hz", "1750hz", "2100hz"], + }, + SF { + key: "ab-rpt-mode", + label: "AB RPT-Mode", + menu: "Radio → 16. AB RPT-Mode", + addr: 0x26, + kind: Kind::Bool, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "rpt-speaker", + label: "RPT-Speaker", + menu: "Radio → 17. RPT-Speaker", + addr: 0x3A, + kind: Kind::Bool, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "mdf-a", + label: "MDF-A", + menu: "VFO&CH → 1. MDF-A", + addr: 0x0D, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NAME", "FREQUENCY", "CHANNEL NUM."], + }, + SF { + key: "mdf-b", + label: "MDF-B", + menu: "VFO&CH → 2. MDF-B", + addr: 0x0E, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NAME", "FREQUENCY", "CHANNEL NUM."], + }, + SF { + key: "mdf-c", + label: "MDF-C", + menu: "VFO&CH → 3. MDF-C", + addr: 0x0F, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NAME", "FREQUENCY", "CHANNEL NUM."], + }, + SF { + key: "backlight", + label: "Back Light", + menu: "Setting → 4. Back Light", + addr: 0x03, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["Bright", "5 sec", "10 sec", "15 sec", "20 sec", "30 sec", "1 min", "2 min", "3 min"], + }, + SF { + key: "beep", + label: "Beep Prompt", + menu: "Setting → 1. Beep Prompt", + addr: 0x06, + kind: Kind::Bool, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "voice", + label: "Voice", + menu: "Setting → 2. Voice", + addr: 0x07, + kind: Kind::Bool, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "keypad-lock", + label: "Keypad Lock", + menu: "Setting → 3. Keypad Lock", + addr: 0x10, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["OFF", "5 sec", "10 sec", "15 sec"], + }, SF { key: "power-on-display", label: "Power On Display", @@ -88,4 +271,67 @@ pub(crate) const FIELDS: [SF; 3] = [ enc: Enc::Direct, options: &["Picture", "Voltage"], }, + SF { + key: "menu-timeout", + label: "Menu OutTime", + menu: "Setting → 5. Menu OutTime", + addr: 0x21, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["5 sec", "10 sec", "15 sec", "20 sec", "25 sec", "30 sec", "35 sec", "40 sec", "45 sec", "50 sec", "60 sec"], + }, + SF { + key: "bluetooth", + label: "Bluetooth", + menu: "Bluetooth", + addr: 0x1D, + kind: Kind::Bool, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "dtmfst", + label: "DTMFST", + menu: "Signaling → 6. DTMFST", + addr: 0x09, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["OFF", "DT-ST", "ANI-ST", "DT+ANI"], + }, + SF { + key: "pf1-short", + label: "PF1", + menu: "User Key → 1. PF1", + addr: 0x29, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["RADIO", "MONI", "SCAN", "SEARCH", "SOS", "SPECTRUM", "Beacon TX"], + }, + SF { + key: "pf1-long", + label: "Long press PF1", + menu: "User Key → 2. Long press PF1", + addr: 0x2A, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["RADIO", "MONI", "SCAN", "SEARCH", "SOS", "SPECTRUM", "Beacon TX"], + }, + SF { + key: "pf2-short", + label: "PF2", + menu: "User Key → 3. PF2", + addr: 0x2B, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["RADIO", "MONI", "SCAN", "SEARCH", "SOS", "SPECTRUM", "Beacon TX"], + }, + SF { + key: "pf2-long", + label: "Long press PF2", + menu: "User Key → 4. Long press PF2", + addr: 0x2C, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["RADIO", "MONI", "SCAN", "SEARCH", "SOS", "SPECTRUM", "Beacon TX"], + }, ]; diff --git a/src-tauri/src/radios/binteradio_bt9000/mod.rs b/src-tauri/src/radios/binteradio_bt9000/mod.rs index 8ada5cb..465a901 100644 --- a/src-tauri/src/radios/binteradio_bt9000/mod.rs +++ b/src-tauri/src/radios/binteradio_bt9000/mod.rs @@ -369,6 +369,21 @@ pub(crate) const SETTINGS_READ_SEGMENTS: [Segment; 1] = [READ_SEGMENTS[2]]; pub(crate) const FUNCTION_OFFSET: usize = 0x7900; pub(crate) const FUNCTION_LEN: usize = 0x0100; +/// How much of the function block is LIVE settings. +/// +/// ⚠ Measured on the radio (s128), and the rest of the segment is not padding: +/// +/// | range | what | +/// |---|---| +/// | `0x00-0x45` | the settings the menus edit | +/// | `0x46-0x7F` | `0xFF` filler | +/// | `0x80-0xFF` | a **firmware-maintained SHADOW** — `+0xD0` onward is byte-for-byte the live block | +/// +/// The shadow moves on its own, exactly like the VFO journal at radio `0x8080`. +/// So a settings write can only be VERIFIED across the live area; comparing the +/// whole segment reports a mismatch on bytes the radio owns and we never set. +pub(crate) const FUNCTION_LIVE_LEN: usize = 0x46; + /// Write an image back. Only [`WRITE_SEGMENTS`] is addressed, so the CPS gap, /// the VFO journal and the APRS block are never touched. /// diff --git a/src-tauri/src/radios/binteradio_bt9000/settings.rs b/src-tauri/src/radios/binteradio_bt9000/settings.rs index 185873a..cbf1de8 100644 --- a/src-tauri/src/radios/binteradio_bt9000/settings.rs +++ b/src-tauri/src/radios/binteradio_bt9000/settings.rs @@ -45,7 +45,7 @@ use crate::radios::driver::{SettingsCapture, SettingsReader, SettingsWriteReport use super::bt9000_settings_table::{Enc, Kind, FIELDS, SF}; use super::{ download_segments, handshake, open_port, upload_segments, FUNCTION_LEN, FUNCTION_OFFSET, - READ_SEGMENTS, SETTINGS_READ_SEGMENTS, SETTINGS_SEGMENTS, SETTLE, + FUNCTION_LIVE_LEN, READ_SEGMENTS, SETTINGS_READ_SEGMENTS, SETTINGS_SEGMENTS, SETTLE, }; // ============================================================ @@ -272,9 +272,36 @@ impl SettingsWriter for super::BinteradioBt9000 { ) })?; - // 4. Read the block back. On this radio that is the ONLY evidence the - // write committed — it acknowledges blocks it does not always store. + // 4. Read the block back, and RETRY ONCE if it disagrees. + // + // Measured on the radio (s128): a settings write acknowledged every + // block and did not commit, and an identical second write landed + // perfectly. The read-back is what caught it, so acting on the + // result is the point of having one — reporting "it did not take, + // try again" while holding a proven-idempotent 0.35 s write in hand + // is worse for the operator and no safer. + // + // Exactly one retry. A radio that fails twice is not flaky, and + // hammering a write path on this platform is how radios have been + // damaged. std::thread::sleep(SETTLE); + if matches!(verify(&mut *p, &expected), Ok((false, _))) { + let hs = handshake(&mut *p)?; + upload_segments(&mut *p, &hs, &image, &SETTINGS_SEGMENTS).map_err(|e| { + crate::radios::driver::with_restore_hint( + e, + &backup_path, + "The first write did not commit and the retry failed. Keep that \ + file — it is the radio as it was before either attempt.", + ) + })?; + notes.push( + "The radio acknowledged the first write without committing it, so it \ + was written a second time." + .to_string(), + ); + std::thread::sleep(SETTLE); + } let (verified, verify_note) = match verify(&mut *p, &expected) { Ok(v) => v, Err(e) => ( @@ -307,7 +334,13 @@ fn verify( ) -> Result<(bool, Option), String> { let hs = handshake(p)?; let back = download_segments(p, &hs, &SETTINGS_READ_SEGMENTS)?; - let got = &back[FUNCTION_OFFSET..FUNCTION_OFFSET + FUNCTION_LEN]; + // ⚠ Compare the LIVE area only. `0x80-0xFF` of this segment is a + // firmware-maintained shadow of the settings, and it moves on its own — + // comparing the whole 256 bytes reported a mismatch on a restore that had + // in fact landed perfectly (measured on the radio, s128). Everything this + // driver writes lives below `0x46`. + let got = &back[FUNCTION_OFFSET..FUNCTION_OFFSET + FUNCTION_LIVE_LEN]; + let expected = &expected[..FUNCTION_LIVE_LEN]; if got == expected { return Ok((true, None)); } From de4eed4ce9872aae1a68edbc246158d26ffcdc68 Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 14:55:44 -0600 Subject: [PATCH 08/15] =?UTF-8?q?BT-9000:=20the=20ten=20long-press=20key?= =?UTF-8?q?=20slots,=20measured=20=E2=80=94=2041=20fields=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One write, ten readings, all correct. Every byte the source claimed for a key slot is confirmed, and the 22-entry function list is pinned at BOTH ends -- indices 0, 1, 2, 3, 4 and 6 at the bottom, 16, 17, 18 and 19 near the top, every one in the manual's printed order. No reversal or shift survives that. `language` stays out, and that is the point of the grading rather than an oversight. It reads English at its factory 0, and the manual says the list is English/Chinese -- but the manual also said ROGRE was OFF/ON and the radio has three options there. A wrong index writes the radio into Chinese, which is a genuinely hostile failure for one low-value field, so it waits for someone to scroll the list. One reading settles it. That leaves 41 of 45 rows emitted. The other three are withheld for measured reasons, not missing work: Work Band is in no segment of the clone image, and S-CODE and PTT-ID both edit the current CHANNEL rather than the radio. Two fixes to `pass_a.py` first, both from earlier mistakes this session: * It takes `--port` now, with no default and no auto-detection. Tim had two radios on this Mac at once -- a POTA rig on a second CH340 -- and a probe that guesses its port is a probe that can write to the wrong radio. (`write.py` already refuses anything not answering `RT-950`, and `find_port` already refuses to guess between two candidates; this closes the third hole.) * It builds its image from a FRESH read of the radio instead of from `01_original.bin`. Rebuilding from the factory capture is how Tim's Menu OutTime got reset twice in one session, once as pure collateral from a probe that had no business touching it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- src-tauri/src/bt9000_settings_schema.json | 290 ++++++++++++++++++ .../bt9000_settings_table.rs | 94 +++++- 2 files changed, 382 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/bt9000_settings_schema.json b/src-tauri/src/bt9000_settings_schema.json index c13e500..3c08625 100644 --- a/src-tauri/src/bt9000_settings_schema.json +++ b/src-tauri/src/bt9000_settings_schema.json @@ -365,5 +365,295 @@ "SPECTRUM", "Beacon TX" ] + }, + { + "key": "key0-long", + "label": "key [0] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key1-long", + "label": "key [1] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key2-long", + "label": "key [2] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key3-long", + "label": "key [3] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key4-long", + "label": "key [4] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key5-long", + "label": "key [5] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key6-long", + "label": "key [6] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key7-long", + "label": "key [7] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key8-long", + "label": "key [8] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key9-long", + "label": "key [9] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] } ] diff --git a/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs b/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs index 61c87f0..23a927b 100644 --- a/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs +++ b/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs @@ -13,7 +13,7 @@ //! wrong encoding is stored rather than refused, and this table is the //! only thing standing between the operator and a bad value. //! -//! 31 field(s). The sheet's other rows are measured but not +//! 41 field(s). The sheet's other rows are measured but not //! settled; see its Tally section for what is still owed. /// How a field's value is carried in the byte. @@ -54,7 +54,7 @@ pub(crate) struct SF { pub options: &'static [&'static str], } -pub(crate) const FIELDS: [SF; 31] = [ +pub(crate) const FIELDS: [SF; 41] = [ SF { key: "vox-level", label: "VOX Level", @@ -334,4 +334,94 @@ pub(crate) const FIELDS: [SF; 31] = [ enc: Enc::Direct, options: &["RADIO", "MONI", "SCAN", "SEARCH", "SOS", "SPECTRUM", "Beacon TX"], }, + SF { + key: "key0-long", + label: "key [0] long press", + menu: "User Key → key [0] long press", + addr: 0x3B, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key1-long", + label: "key [1] long press", + menu: "User Key → key [1] long press", + addr: 0x3C, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key2-long", + label: "key [2] long press", + menu: "User Key → key [2] long press", + addr: 0x3D, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key3-long", + label: "key [3] long press", + menu: "User Key → key [3] long press", + addr: 0x3E, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key4-long", + label: "key [4] long press", + menu: "User Key → key [4] long press", + addr: 0x3F, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key5-long", + label: "key [5] long press", + menu: "User Key → key [5] long press", + addr: 0x40, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key6-long", + label: "key [6] long press", + menu: "User Key → key [6] long press", + addr: 0x41, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key7-long", + label: "key [7] long press", + menu: "User Key → key [7] long press", + addr: 0x42, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key8-long", + label: "key [8] long press", + menu: "User Key → key [8] long press", + addr: 0x43, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key9-long", + label: "key [9] long press", + menu: "User Key → key [9] long press", + addr: 0x44, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, ]; From ab11d891143b0f2c06c19de894f546e92b5b798b Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 14:58:27 -0600 Subject: [PATCH 09/15] =?UTF-8?q?BT-9000:=20language=20settled=20=E2=80=94?= =?UTF-8?q?=20the=20settings=20schema=20is=20complete=20at=2042=20fields?= =?UTF-8?q?=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tim confirmed `Setting → 7. Language` offers exactly two options: English and one rendered in Chinese glyphs. With a two-entry list and the factory 0 displaying English, index 1 has nowhere else to go -- the same reasoning that settled Power On Display, and the reason a two-entry list is the documented exception to needing a second confirmed index. Held back deliberately last commit rather than assumed, because the manual also called ROGRE `OFF/ON` when the radio has three options there, and a wrong index here writes the radio into Chinese. ⚠ The label "Chinese" is ours. The radio renders that option in Chinese characters so there is no ASCII string to copy, and the schema names it the way the manual does. Only the INDEX reaches the radio, so the label is a display choice rather than a claim about what is stored. That completes the settings half of this radio. 42 of 45 rows emitted; the remaining three are not radio-profile settings at all and are withheld on measured grounds, not missing work: * Work Band -- in NO segment of the clone image. Changing it moved nothing in channels, VFO, function, DTMF, modulation or APRS. * S-CODE -- a reverse probe caught it moving CHANNEL record byte 12. * PTT-ID -- 0x0B refuted outright, and per-channel like S-CODE. Every emitted field's encoding was read off this radio's own screen. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- src-tauri/src/bt9000_settings_schema.json | 9 +++++++++ .../binteradio_bt9000/bt9000_settings_table.rs | 13 +++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/bt9000_settings_schema.json b/src-tauri/src/bt9000_settings_schema.json index 3c08625..d7340a5 100644 --- a/src-tauri/src/bt9000_settings_schema.json +++ b/src-tauri/src/bt9000_settings_schema.json @@ -241,6 +241,15 @@ "label": "Voice", "type": "boolean" }, + { + "key": "language", + "label": "Language", + "type": "select", + "options": [ + "English", + "Chinese" + ] + }, { "key": "keypad-lock", "label": "Keypad Lock", diff --git a/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs b/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs index 23a927b..342f2ac 100644 --- a/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs +++ b/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs @@ -13,7 +13,7 @@ //! wrong encoding is stored rather than refused, and this table is the //! only thing standing between the operator and a bad value. //! -//! 41 field(s). The sheet's other rows are measured but not +//! 42 field(s). The sheet's other rows are measured but not //! settled; see its Tally section for what is still owed. /// How a field's value is carried in the byte. @@ -54,7 +54,7 @@ pub(crate) struct SF { pub options: &'static [&'static str], } -pub(crate) const FIELDS: [SF; 41] = [ +pub(crate) const FIELDS: [SF; 42] = [ SF { key: "vox-level", label: "VOX Level", @@ -253,6 +253,15 @@ pub(crate) const FIELDS: [SF; 41] = [ enc: Enc::Direct, options: &[], }, + SF { + key: "language", + label: "Language", + menu: "Setting → 7. Language", + addr: 0x08, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["English", "Chinese"], + }, SF { key: "keypad-lock", label: "Keypad Lock", From f72b473f76025b9b37fe2fb043bb4c41f6a7f506 Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 15:16:03 -0600 Subject: [PATCH 10/15] BT-9000: eight attempts now say the APRS block cannot be written (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tim asked whether we were 100% certain. We were not, so the untried avenues got enumerated and run instead of defended. All of them use documented command bytes only -- no guessing, on the platform with a documented permanently-degraded unit. Two real hypotheses died, and both deserved the test: * "The 0x06 was a timeout artifact." s127's probe waited 2 seconds and this radio can take 15 to acknowledge a block that erases flash, so "no response" to the obfuscated write might have been impatience. It was not. At a 16 second wait the plain write still answers 0x06 in 0.0 s and the obfuscated one answers 0x54 -- the APRS READ opcode, not an acknowledgement. * "The commit is deferred to power-off." This firmware demonstrably defers work: it keeps a VFO journal and a settings shadow, and a half-segment write to the function block commits WITHOUT acknowledging. A write landing only across a restart was plausible. Written, not read back, power-cycled, read: unchanged. And one mechanism ruled out rather than assumed: the write is not partial. Reading 0x54 across 0x0000-0x0200 shows only 0x0000 holds data, so the single 0x80 block the CPS sends covers the whole space. That is eight attempts across two sessions and three distinct mechanisms. `aprs_capable: false` now rests on measurement rather than on a count of tries. ⚠ Left as a clue for whoever decompiles `RWDataOperation`: an obfuscated write draws 0x54 rather than silence or an ACK. A radio answering a write with a READ opcode suggests our framing is being parsed as something other than a write. Adds `aprs_try.py` (write attempts, read-only by default, refuses any radio not answering RT-950) and `aprs_space.py` (read-only space map). Nothing to restore: the block never changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- src-tauri/src/radios/binteradio_bt9000/mod.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/radios/binteradio_bt9000/mod.rs b/src-tauri/src/radios/binteradio_bt9000/mod.rs index 465a901..eb3271c 100644 --- a/src-tauri/src/radios/binteradio_bt9000/mod.rs +++ b/src-tauri/src/radios/binteradio_bt9000/mod.rs @@ -134,7 +134,13 @@ pub(crate) const READ_SEGMENTS: [Segment; 7] = [ /// /// - `vfo` stops at `0x80`. The second block is the firmware journal. /// - `aprs` is absent. It does not commit, and a control that runs and fails is -/// worse than one that is not offered. +/// worse than one that is not offered. Established over two sessions and eight +/// attempts: a plain payload is acknowledged with `0x06` in 0.0 s and changes +/// nothing (waiting 16 s rather than 2 does not help), an obfuscated one draws +/// `0x54` — the APRS *read* opcode — rather than an ACK, the block is still +/// unchanged after a POWER CYCLE, and the space holds only the one 0x80 block +/// so the write is not partial. Everything untried needs guessed command +/// bytes, which is desk work on the vendor CPS rather than radio work. pub(crate) const WRITE_SEGMENTS: [Segment; 6] = [ Segment { name: "channels", command: 0x57, address: 0x0000, file_offset: 0x0000, length: 0x7800 }, Segment { name: "vfo", command: 0x57, address: 0x8000, file_offset: 0x7800, length: 0x0080 }, From 688651d2f9b593e1e83a9fe15c3d179c324a3715 Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 15:48:48 -0600 Subject: [PATCH 11/15] BT-9000: tx_bands and rx_bands measured with the PTT (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image could not answer this -- the radio stores every frequency it is given, so all 13 band probes "survive" a round trip regardless. The only instrument is the radio, so each probe was selected and keyed into a dummy load. 27.500, 50.125 key (27.5 needs the 18-64 Work Band selected) 108.000 NO -- receives AM, refuses to transmit 136.000, 145.100, 174.000 key 200.000, 223.500, 260.000 key 400.000, 431.100, 520.000 key 580.000 not settable on the VFO at all Two things follow. The radio DOES gate transmit -- 108 refusing is what makes the other twelve "yes" answers mean anything, and without that negative control the whole test would have been worthless. And 220 MHz is real: the F blob's third pair `0200 0260` is a transmit band the manual never mentions. tx_bands 18-64, 136-174, 200-260, 400-520 rx_bands 18-999 ⚠ The spans between tested points are excluded because nothing was keyed there, not because anything refused: 64-108, 108-136, 174-200, 260-400 and 520-580 are unmeasured. The three narrow spans take both their own edges from a successful key; 18-64 is the range the radio's own Work Band menu declares, with two confirmations inside it. That makes rx wider than tx for the first time, which is what the tripwire added earlier this session existed to gate. It was waiting on byte 15 bit 1 -- claimed to be per-channel TX-enable and never verified, and clearing an unverified bit on this platform is how radios have been damaged. Measured now: a channel written with the bit clear refuses the PTT while its neighbour with it set keys normally. So: * `encode_channel` takes `tx_enable` and clears the bit for a receive-only memory, rather than setting it unconditionally; * `patch_image` takes the model and asks `channel_fit`; * the tripwire is replaced by the assertion it was guarding -- that the bands DO differ and receive-only is therefore live code. ⚠ `hw_ladder`'s band probe now builds a deliberately permissive model, so its probes still go out transmit-enabled. With the seeded model they would be written PTT-disabled, which is right for a codeplug and would defeat a probe whose whole question is whether the radio keys there. The pipeline test now covers all three verdicts with frequencies keyed on the real radio: 223.500 included, 108.000 receive-only and written with the bit clear, 1200.000 dropped. ⚠ Not confirmed: RF actually leaving the PA. These readings are "the radio keys", taken without a wattmeter in line. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- src-tauri/src/commands/export.rs | 46 ++++++++-- .../src/radios/binteradio_bt9000/hw_ladder.rs | 14 ++- src-tauri/src/radios/binteradio_bt9000/mod.rs | 52 ++++++++--- src-tauri/src/seed.rs | 89 +++++++++++-------- 4 files changed, 145 insertions(+), 56 deletions(-) diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index 7f8d121..b2b6046 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -1355,7 +1355,9 @@ mod tests { VALUES (1, 'A Very Long Repeater Name', NULL, 146.940, 0.6, '-', 'FM', 'manual'), (2, 'Simplex', 'SIMP', 146.520, NULL, NULL, 'FM', 'manual'), (3, 'UHF Machine', 'UHF', 442.000, 5.0, '+', 'FM', 'manual'), - (4, '220 Repeater', '220', 223.500, 1.6, '-', 'FM', 'manual')", + (4, '220 Repeater', '220', 223.500, 1.6, '-', 'FM', 'manual'), + (5, 'Airband', 'AIR', 108.000, NULL, NULL, 'FM', 'manual'), + (6, 'Way Out', 'OUT', 1200.000, NULL, NULL, 'FM', 'manual')", ) .execute(&pool) .await @@ -1367,7 +1369,7 @@ mod tests { .unwrap(); sqlx::query( "INSERT INTO channel_list_entries (channel_list_id, channel_id, position) - VALUES (10, 1, 0), (10, 2, 1), (10, 3, 2), (10, 4, 3)", + VALUES (10, 1, 0), (10, 2, 1), (10, 3, 2), (10, 4, 3), (10, 5, 4), (10, 6, 5)", ) .execute(&pool) .await @@ -1400,13 +1402,30 @@ mod tests { let (model, slots) = resolve_codeplug_slots(&pool, 1).await.expect("resolve slots"); assert_eq!(model.driver_key.as_deref(), Some("binteradio_bt9000")); - // 4 in, 3 out: the 220 MHz repeater is outside both band lists, and the - // pipeline refuses it rather than handing the driver a channel the radio - // would happily store and transmit on. - assert_eq!(slots.len(), 3, "the 220 MHz channel must not reach the radio"); + // 6 in, 5 out. Each of the three verdicts is represented, and all three + // come from frequencies keyed on the real radio in s128: + // + // * 223.500 is INCLUDED — the radio keys there, which the manual never + // mentions and only the PTT could establish; + // * 108.000 is RECEIVE-ONLY — it takes AM and refuses the PTT, so it + // is programmed rather than dropped, with transmit disabled; + // * 1200.000 is EXCLUDED — outside the receiver as well, and the VFO + // will not even go there. + assert_eq!(slots.len(), 5, "only the 1200 MHz channel should be dropped"); assert!( - slots.iter().all(|s| s.channel.rx_freq != 223.500), - "223.500 reached the slot list" + slots.iter().any(|s| s.channel.rx_freq == 223.500), + "223.500 must reach the radio — it was keyed on the real one" + ); + assert!( + slots.iter().all(|s| s.channel.rx_freq != 1200.0), + "1200.000 is outside the receiver and must be dropped" + ); + assert!( + matches!( + channel_fit(&slots.iter().find(|s| s.channel.rx_freq == 108.0).unwrap().channel, &model), + ChannelFit::ReceiveOnly(_) + ), + "108.000 receives AM and refuses the PTT, so it is receive-only" ); // Dense and sequential, which is what makes zone = slot / 64 + 1 mean @@ -1430,6 +1449,17 @@ mod tests { let decoded = crate::radios::binteradio_bt9000::decode_channels(&image); assert_eq!(decoded.len(), slots.len(), "channels in the image"); + + // ★ The receive-only memory goes out with byte 15 bit 1 CLEAR. That bit + // is measured: on the real radio a channel written this way refuses the + // PTT while its neighbour keys normally. Before it was measured this + // driver set it unconditionally, and a receive-only channel would have + // been programmed able to transmit on a radio that keys wherever it is + // told. + let air = slots.iter().position(|s| s.channel.rx_freq == 108.0).unwrap(); + assert_eq!(image[air * 32 + 15] & 0x02, 0x00, "108.000 must be TX-disabled"); + let two_m = slots.iter().position(|s| s.channel.rx_freq == 146.520).unwrap(); + assert_eq!(image[two_m * 32 + 15] & 0x02, 0x02, "146.520 must be TX-enabled"); for (d, s) in decoded.iter().zip(&slots) { // `trim_end` is not slack: the shared truncator cuts to the field // width without regard for where words end, so a long name can diff --git a/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs b/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs index ac78eac..ea87c01 100644 --- a/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs +++ b/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs @@ -47,6 +47,18 @@ fn slot(slot: usize, name: &str, rx: f64, tx: f64) -> SlotChannel { /// Read, back up, patch with `slots`, write, and read back. Returns the image /// the radio holds afterwards. fn program(slots: &[SlotChannel], tag: &str) -> Vec { + // A DELIBERATELY permissive model, so every probe goes out transmit-enabled. + // The seeded model would mark an out-of-band probe receive-only and write it + // with the PTT disabled — which is correct for a codeplug and would defeat + // the band probe, whose entire question is whether the radio keys there. + let model = RadioModel { + analog_capable: true, + tx_bands: Some("[[1.0,1000.0]]".to_string()), + rx_bands: Some("[[1.0,1000.0]]".to_string()), + freq_min: Some(1.0), + freq_max: Some(1000.0), + ..Default::default() + }; let port = port(); let mut p = open_port(&port).expect("open the port"); @@ -62,7 +74,7 @@ fn program(slots: &[SlotChannel], tag: &str) -> Vec { println!(" backup: {}", backup.display()); let mut image = base.clone(); - patch_image(&mut image, slots); + patch_image(&mut image, slots, &model); std::thread::sleep(SETTLE); let hs = handshake(&mut *p).expect("re-handshake before writing"); diff --git a/src-tauri/src/radios/binteradio_bt9000/mod.rs b/src-tauri/src/radios/binteradio_bt9000/mod.rs index eb3271c..eed2361 100644 --- a/src-tauri/src/radios/binteradio_bt9000/mod.rs +++ b/src-tauri/src/radios/binteradio_bt9000/mod.rs @@ -675,7 +675,14 @@ fn encode_tones(c: &Channel) -> ([u8; 2], [u8; 2]) { /// The TX shift is carried entirely by the stored TX frequency — there is no /// separate direction field, confirmed against the radio (a −0.600 repeater /// channel differs from a simplex one only in bytes 4-7). -fn encode_channel(c: &Channel, name: &str, tx_hz: u64) -> [u8; ENTRY_LEN] { +/// `tx_enable` clears byte 15 bit 1 for a receive-only memory. +/// +/// ⚠ That bit is **measured**, not inherited: a channel written with it clear +/// refuses the PTT while its neighbour with the bit set keys normally, checked +/// on the radio (s128). It was a source claim until then, which is why this +/// driver spent a release setting it unconditionally and guarding the gap with +/// a test instead of guessing. +fn encode_channel(c: &Channel, name: &str, tx_hz: u64, tx_enable: bool) -> [u8; ENTRY_LEN] { let mut m = [0u8; ENTRY_LEN]; m[0..4].copy_from_slice(&hz_to_lbcd((c.rx_freq * 1e6).round() as u64)); @@ -704,7 +711,7 @@ fn encode_channel(c: &Channel, name: &str, tx_hz: u64) -> [u8; ENTRY_LEN] { // `scratchpad/binteradio_bt9000/SCREEN-CHECK.md` carries the measurement; // guessing the bit is how radios get damaged on this platform. let narrow = matches!(c.mode.as_deref(), Some(m) if m.eq_ignore_ascii_case("NFM")); - m[15] = 0x02 | if narrow { 0x40 } else { 0x00 }; + m[15] = if tx_enable { 0x02 } else { 0x00 } | if narrow { 0x40 } else { 0x00 }; // 16-19 = FHSS code, left zero. m[20..32].copy_from_slice(&name_bytes(name)); @@ -716,7 +723,7 @@ fn encode_channel(c: &Channel, name: &str, tx_hz: u64) -> [u8; ENTRY_LEN] { /// Only the channel segment is touched. Slots the codeplug does not fill are /// **cleared to the radio's own empty form** rather than left alone, so /// programming a shorter codeplug does not leave stale channels behind. -pub(crate) fn patch_image(image: &mut [u8], slots: &[SlotChannel]) { +pub(crate) fn patch_image(image: &mut [u8], slots: &[SlotChannel], model: &RadioModel) { for i in 0..CHANNEL_COUNT { image[i * ENTRY_LEN..(i + 1) * ENTRY_LEN].fill(0xFF); } @@ -725,7 +732,15 @@ pub(crate) fn patch_image(image: &mut [u8], slots: &[SlotChannel]) { continue; } let tx_hz = (crate::commands::export::tx_frequency(&s.channel) * 1e6).round() as u64; - let rec = encode_channel(&s.channel, &s.name, tx_hz); + // A channel the radio can hear but not transmit on is programmed with + // the PTT disabled rather than dropped — and rather than left + // transmit-enabled, which on a radio that validates nothing would hand + // the operator a memory that keys up out of band. + let tx_enable = !matches!( + crate::commands::export::channel_fit(&s.channel, model), + crate::commands::export::ChannelFit::ReceiveOnly(_) + ); + let rec = encode_channel(&s.channel, &s.name, tx_hz, tx_enable); image[s.slot * ENTRY_LEN..(s.slot + 1) * ENTRY_LEN].copy_from_slice(&rec); } } @@ -881,7 +896,7 @@ impl ImageProgrammer for BinteradioBt9000 { fn build_image( &self, - _model: &RadioModel, + model: &RadioModel, channels: &[SlotChannel], base: &[u8], ) -> Result, String> { @@ -893,7 +908,7 @@ impl ImageProgrammer for BinteradioBt9000 { )); } let mut image = base.to_vec(); - patch_image(&mut image, channels); + patch_image(&mut image, channels, model); Ok(image) } @@ -934,7 +949,7 @@ impl ImageProgrammer for BinteradioBt9000 { // 2. Patch channels into the image we just read, so every byte we do // not own goes back exactly as it came. let channels_written = req.channels.len(); - patch_image(&mut image, req.channels); + patch_image(&mut image, req.channels, req.model); let restore_hint = |e: String| { crate::radios::driver::with_restore_hint( @@ -1178,13 +1193,13 @@ mod tests { #[test] fn a_null_mode_encodes_as_wide_fm() { let wide = Channel { rx_freq: 146.52, ..Default::default() }; - assert_eq!(encode_channel(&wide, "NOMODE", 146_520_000)[15] & 0x40, 0x00); + assert_eq!(encode_channel(&wide, "NOMODE", 146_520_000, true)[15] & 0x40, 0x00); let fm = Channel { rx_freq: 146.52, mode: Some("FM".into()), ..Default::default() }; - assert_eq!(encode_channel(&fm, "FM", 146_520_000)[15] & 0x40, 0x00); + assert_eq!(encode_channel(&fm, "FM", 146_520_000, true)[15] & 0x40, 0x00); let nfm = Channel { rx_freq: 146.52, mode: Some("NFM".into()), ..Default::default() }; - assert_eq!(encode_channel(&nfm, "NFM", 146_520_000)[15] & 0x40, 0x40); + assert_eq!(encode_channel(&nfm, "NFM", 146_520_000, true)[15] & 0x40, 0x40); // Round-trips through the decoder, which knows only these two. for (mode, want) in [(None, "FM"), (Some("FM"), "FM"), (Some("NFM"), "NFM")] { @@ -1193,13 +1208,28 @@ mod tests { mode: mode.map(str::to_string), ..Default::default() }; - let rec = encode_channel(&c, "X", 146_520_000); + let rec = encode_channel(&c, "X", 146_520_000, true); let mut image = vec![0xFFu8; IMAGE_LEN]; image[..ENTRY_LEN].copy_from_slice(&rec); assert_eq!(decode_channels(&image)[0].narrow, want == "NFM", "{mode:?}"); } } + /// ⚠ MEASURED on the radio (s128), not inherited: a channel written with + /// byte 15 bit 1 clear refuses the PTT while its neighbour with the bit set + /// keys normally. Before that check this driver set the bit unconditionally, + /// because clearing an unverified bit on this platform is how radios have + /// been damaged. + #[test] + fn a_receive_only_channel_is_written_with_the_ptt_disabled() { + let c = Channel { rx_freq: 146.52, mode: Some("FM".into()), ..Default::default() }; + assert_eq!(encode_channel(&c, "TX", 146_520_000, true)[15] & 0x02, 0x02); + assert_eq!(encode_channel(&c, "RX", 146_520_000, false)[15] & 0x02, 0x00); + // The bandwidth bit is independent of it. + let n = Channel { mode: Some("NFM".into()), ..c.clone() }; + assert_eq!(encode_channel(&n, "RX", 146_520_000, false)[15], 0x40); + } + #[test] fn names_use_the_radios_two_sentinels() { assert_eq!(name_bytes(""), [0x00; NAME_LEN]); diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index 6bece9d..913594a 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -348,15 +348,37 @@ fn models() -> Vec { covers_900: false, freq_min: 136.0, freq_max: 520.0, - // ⚠ DELIBERATELY CONSERVATIVE, pending the band probe (ladder step - // 4). The manual states 136-174 and 400-520, and the first two - // pairs of the radio's `F` handshake blob agree. That blob's third - // pair reads 200-260, and the vendor's web copy claims TX on CB and - // 18-32 MHz — none of it measured. Under-claiming excludes a - // channel with a reason the operator can see; over-claiming writes - // a SILENTLY EMPTY memory slot while reporting success. - tx_bands: Some("[[136.0,174.0],[400.0,520.0]]"), - rx_bands: Some("[[136.0,174.0],[400.0,520.0]]"), + // MEASURED on the radio (s128), one channel per band edge and the + // PTT pressed on each. The image could not answer this — it stores + // every frequency it is given — so each of these was keyed. + // + // | probe | keys? | + // |---|---| + // | 27.500, 50.125 | yes (27.5 needs the 18-64 Work Band selected) | + // | 108.000 | **NO** — receives AM, refuses to transmit | + // | 136.000, 145.100, 174.000 | yes | + // | 200.000, 223.500, 260.000 | yes — the `F` blob's third pair is REAL | + // | 400.000, 431.100, 520.000 | yes | + // | 580.000 | not settable on the VFO at all | + // + // So the radio DOES gate transmit — 108 refusing is what proves the + // other twelve "yes" answers mean something — and 220 MHz is real, + // which the manual never mentions. + // + // ⚠ The spans between tested points are NOT measured: 64-108, + // 108-136, 174-200, 260-400 and 520-580 are excluded because + // nothing was keyed there, not because anything refused. The three + // narrow spans take both of their own edges from a successful key; + // 18-64 takes the range the radio's own Work Band menu declares, + // with 27.5 and 50.125 confirmed inside it. + tx_bands: Some("[[18.0,64.0],[136.0,174.0],[200.0,260.0],[400.0,520.0]]"), + // The receiver is wider than the transmitter, which is why these + // now differ. The Work Band menu offers 18-64 and 64-999, and + // 108.000 was confirmed receiving AM inside the second. A channel + // that lands here but not in `tx_bands` is programmed receive-only — + // byte 15 bit 1 clear — which is measured, not assumed: such a + // channel refuses the PTT while its neighbour keys normally. + rx_bands: Some("[[18.0,999.0]]"), memory_channels: 960, zones_supported: false, max_zones: None, @@ -892,41 +914,36 @@ mod tests { /// ⚠️ `UIS` mirrors the keys of `PROGRAM_DIALOGS` in /// `src/components/codeplugs/programDialogs.ts`; adding a bespoke dialog /// means adding its key in both places. - /// ⚠ TRIPWIRE, not an invariant anybody wants to keep. + /// The tripwire this replaces has done its job. /// - /// The BT-9000's `rx_bands` and `tx_bands` are currently the SAME two - /// spans, which is the only reason `channel_fit` can never return - /// `ReceiveOnly` for this radio — and the only reason its encoder gets - /// away with setting the per-channel TX-enable bit unconditionally - /// (`m[15] = 0x02 | narrow` in `radios/binteradio_bt9000/mod.rs`). + /// It fired if the BT-9000's `rx_bands` and `tx_bands` ever diverged, because + /// the encoder set the per-channel TX-enable bit unconditionally and a + /// receive-only channel would therefore have been programmed able to + /// transmit — on a radio that validates nothing and keys wherever it is + /// told. The bit it was waiting on (byte 15 bit 1) was measured on the radio + /// in s128: a channel written with it clear refuses the PTT while its + /// neighbour with it set keys normally. /// - /// Widening `rx_bands` is an EXPECTED outcome of issue #43's band work: - /// this radio receives broadcast FM, AM and SSB, and its `F` handshake - /// blob hints at a third span at 200-260 MHz. The moment `rx_bands` grows - /// past `tx_bands`, every out-of-TX channel starts being programmed - /// transmit-enabled — on a radio that validates NOTHING and will key up - /// wherever it is told. - /// - /// So before widening them, settle what byte 15 bit 1 actually does. It is - /// claimed as "TX enable" by the inherited reverse-engineering and has - /// never been measured here, and clearing an unverified bit on this - /// platform is how radios get damaged. `scratchpad/binteradio_bt9000/` - /// carries the campaign; the FT5D's opposite choice - /// (`receive_only_channels_encode_as_ordinary_memories`) does NOT transfer, - /// because its reasoning is "the radio polices its own TX bands" and this - /// one does not police anything. + /// So the bands now legitimately differ, and what needs guarding is the + /// other half: that the encoder actually honours the distinction. #[test] - fn bt9000_receive_only_channels_cannot_arise_yet() { + fn bt9000_receive_only_channels_are_possible_and_handled() { let bt = models() .into_iter() .find(|m| m.driver_key == Some("binteradio_bt9000")) .expect("the BT-9000 is seeded"); - assert_eq!( + assert_ne!( bt.rx_bands, bt.tx_bands, - "BT-9000 rx_bands and tx_bands have diverged, so a receive-only channel \ - is now possible — decide what the encoder does with byte 15 bit 1 \ - (claimed TX-enable, never measured) BEFORE shipping this. See the \ - comment on this test." + "the BT-9000 receives wider than it transmits — 108 MHz takes AM and \ + refuses the PTT — so these are expected to differ" + ); + // The encoder's own test proves the bit; this proves the schema still + // asks it to be used. + let rx: Vec<[f64; 2]> = serde_json::from_str(bt.rx_bands.unwrap()).unwrap(); + let tx: Vec<[f64; 2]> = serde_json::from_str(bt.tx_bands.unwrap()).unwrap(); + assert!( + rx.iter().any(|r| tx.iter().all(|t| t[0] > r[1] || t[1] < r[0] || t != r)), + "rx_bands must cover ground tx_bands does not, or receive-only is dead code" ); } From 5e32276ca3cf4f48f9d9eac9e86c45a290d94ce7 Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 15:56:28 -0600 Subject: [PATCH 12/15] README: list every radio that actually works, and a test so it stays that way (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two shipped radios were missing from the Supported table. The TH-D72 went out in v26.8.29 and never reached it. The BT-9000 was still sitting under "Planned" on the day it shipped, under "BTECH / Btrianium" -- which is not its manufacturer. A radio someone owns and cannot tell is supported may as well not be supported. * TH-D72 added: 1000 memories, 113 menu settings over the radio's `MU` command, 2 m / 70 cm TX with 118-174 / 320-524 MHz RX. * BT-9000 added under its real name, with the badges it also sells under (Radtel RT-950 Pro, Bajeton BJ-9000, Tenway TP-900 Pro) and the fact that it reports itself as RT-950 -- which is what an owner searching for their radio will actually find. * TM-D710 added to Planned against #113, where it is in progress. And a guard, because Tim's note was that this has been missed more than once: `every_seeded_radio_appears_in_the_readme` asserts that every driver in the registry is named in the Supported table and NOT in the Planned one. Keyed on `display_name`, so the table and the app cannot drift into describing a radio differently. ⚠ It caught a bug in itself first: matching against everything after the Planned heading reported the AT-D890UV as still planned, because the credits section names it too. Bounded to the Planned table. The receive-only paragraph now says how it is actually done -- the app sets the radio's per-channel transmit inhibit where one exists -- and notes that band limits are measured rather than copied from the manual, with the BT-9000's undocumented 220 MHz as the example. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- README.md | 13 +++++++--- src-tauri/src/radios/wiring.rs | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ad0cf17..4b4b48b 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ card — or exported as CSV for tools that expect it. | **AnyTone AT-D890UV** | DMR + Analog | VHF / UHF | Direct USB — channels, zones, scan lists, settings, call-sign DB | 4000 channels; full DMR: zones, talkgroups, 308k-entry caller-ID database | | **Yaesu FT5D** | C4FM (System Fusion) + Analog | VHF / UHF TX, wideband RX | microSD — patches the radio's own backup file | 900 channels in 24 banks; channels, banks, and menu settings | | **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 | +| **Binteradio BT-9000** | Analog FM/NFM | 18–64 / 136–174 / 200–260 / 400–520 MHz TX, 18–999 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 original, writes, and can verify the result byte-for-byte. @@ -35,8 +37,13 @@ radio restores it from its own menu with no vendor software involved. Because th the radio's settings as well as its memories, the codeplug and the radio profile travel together. Channels the radio can hear but not transmit on (GMRS, marine, NOAA, air band, 220) are -programmed **receive-only** rather than dropped, and frequencies outside the receiver's real -coverage are excluded with a reason rather than silently written to an empty slot. +programmed **receive-only** rather than dropped — where the radio has a per-channel transmit +inhibit, the app sets it — and frequencies outside the receiver's real coverage are excluded with +a reason rather than silently written to an empty slot. + +Band limits are measured on the radio, not copied from its manual. The BT-9000's 220 MHz +transmit capability, for instance, appears in no published source for it and was found by +programming a channel there and keying up. ## Future development @@ -51,8 +58,8 @@ 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) | -| **BTECH / Btrianium BT-9000** | Analog mobile | [#43](https://github.com/ww8l/codeplug-magic/issues/43) | | **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/radios/wiring.rs b/src-tauri/src/radios/wiring.rs index 13ffe6c..1bf26fa 100644 --- a/src-tauri/src/radios/wiring.rs +++ b/src-tauri/src/radios/wiring.rs @@ -40,6 +40,53 @@ fn production_half(src: &str) -> &str { } /// Every `radios//` folder that holds a driver. +/// Every radio the app can actually program must be listed in the README's +/// "Supported radios" table. +/// +/// ⚠ This exists because it was missed twice. The TH-D72 shipped in v26.8.29 and +/// never reached the table, and the BT-9000 was still sitting under "Planned" +/// under a manufacturer name that is not even its own on the day it shipped. A +/// radio someone owns and cannot tell is supported may as well not be. +/// +/// Keyed on `display_name`, which is what the table prints and what the app +/// shows the operator, so the two cannot drift into describing it differently. +#[test] +fn every_seeded_radio_appears_in_the_readme() { + let readme = std::fs::read_to_string(manifest_dir().join("../README.md")) + .expect("README.md is readable from the crate"); + let supported = readme + .split("## Supported radios") + .nth(1) + .expect("README has a `## Supported radios` heading") + .split("\n## ") + .next() + .expect("that section ends at the next heading"); + + let mut checked = 0; + for d in crate::radios::registry::all_drivers() { + let name = d.display_name(); + checked += 1; + assert!( + supported.contains(name), + "{name} is a working driver but is not in the README's Supported radios \ + table. Someone who owns one cannot tell that it works." + ); + // Bounded to the Planned TABLE, not to everything after the heading: + // the credits and feature sections name shipped radios too, and an + // unbounded match reported the AT-D890UV as still planned. + let planned = readme + .split("**Planned**") + .nth(1) + .and_then(|p| p.split("\n\n").find(|b| b.trim_start().starts_with('|'))) + .unwrap_or(""); + assert!( + !planned.contains(name), + "{name} ships AND is still listed under Planned" + ); + } + assert!(checked > 0, "no drivers found — this test would pass vacuously"); +} + fn driver_dirs() -> Vec { let radios = manifest_dir().join("src/radios"); let mut dirs: Vec = std::fs::read_dir(&radios) From 1d89dada756b758794a9f84cca002ca49e572e22 Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 15:58:06 -0600 Subject: [PATCH 13/15] Release 26.9.2 Binteradio BT-9000 support, and the README finally listing every radio that works. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 6ca0b31..5bbfcf7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ww8l-codeplug-magic", "private": true, - "version": "26.8.29", + "version": "26.9.2", "license": "GPL-3.0-only", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c6948bb..425a4bc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5894,7 +5894,7 @@ dependencies = [ [[package]] name = "ww8l-codeplug-magic" -version = "26.8.29" +version = "26.9.2" dependencies = [ "chrono", "csv", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f24a6bd..23a0d21 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ww8l-codeplug-magic" -version = "26.8.29" +version = "26.9.2" description = "WW8L Codeplug Magic - master codeplug database for amateur radio operators" authors = ["WW8L"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 684fa8f..e041f43 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "WW8L Codeplug Magic", - "version": "26.8.29", + "version": "26.9.2", "identifier": "com.ww8l.codeplugmagic", "build": { "beforeDevCommand": "npm run dev", From 60d1474dc13f5fdddab1551a1a3c9d4da960214d Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 17:12:28 -0600 Subject: [PATCH 14/15] Fix the README guard on Windows: CRLF broke its paragraph splitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard added minutes ago passed on macOS and Ubuntu and failed on Windows, reporting the AT-D890UV as "ships AND is still listed under Planned" when it is in neither state. Cause: it bounded the Planned table by splitting the file into paragraphs on "\n\n". A Windows checkout has CRLF, so every paragraph boundary is "\r\n\r\n", the split found nothing, and the fallback matched a later block that names a shipped radio in passing. Rewritten line-based, trimming '\r' per line and ending each table at the first non-table line after it starts. The extraction is now a function taking &str, so it is testable -- and the new test runs the same document through it twice, once with LF and once with CRLF, asserting the two agree. That reproduces the Windows condition on any machine, and it would have failed against the old parser. ⚠ Worth noting what this cost and what it saved: `npm run ci` is macOS only, and the pre-push hook was green for a defect that only Windows could see. The three-OS run on the PR is what caught it, one commit before a merge and a release. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- src-tauri/src/radios/wiring.rs | 94 ++++++++++++++++++++++++++++------ 1 file changed, 78 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/radios/wiring.rs b/src-tauri/src/radios/wiring.rs index 1bf26fa..d9bbfac 100644 --- a/src-tauri/src/radios/wiring.rs +++ b/src-tauri/src/radios/wiring.rs @@ -40,8 +40,48 @@ fn production_half(src: &str) -> &str { } /// Every `radios//` folder that holds a driver. +/// The README's "Supported radios" table, and its "Planned" table, as text. +/// +/// ⚠ Line-based on purpose. The first version split the file into paragraphs on +/// `"\n\n"`, which is not how the file looks on a Windows checkout: CRLF made +/// every paragraph boundary `"\r\n\r\n"`, the Planned table was never found, +/// and the fallback block matched a later section that happens to name a shipped +/// radio. Green on macOS and Ubuntu, red on Windows — exactly the kind of defect +/// the three-OS run exists to catch. +fn readme_tables(readme: &str) -> (String, String) { + let line = |l: &str| l.trim_end_matches('\r').to_string(); + let mut supported = String::new(); + let mut planned = String::new(); + let (mut in_supported, mut in_planned) = (false, false); + for raw in readme.lines() { + let l = line(raw); + if l.starts_with("## ") { + in_supported = l.contains("Supported radios"); + in_planned = false; + continue; + } + if l.trim() == "**Planned**" { + in_planned = true; + continue; + } + // A table ends at the first non-table line after it has started. + if in_planned && !planned.is_empty() && !l.starts_with('|') { + in_planned = false; + } + if in_supported && l.starts_with('|') { + supported.push_str(&l); + supported.push('\n'); + } + if in_planned && l.starts_with('|') { + planned.push_str(&l); + planned.push('\n'); + } + } + (supported, planned) +} + /// Every radio the app can actually program must be listed in the README's -/// "Supported radios" table. +/// "Supported radios" table, and must NOT still be listed under "Planned". /// /// ⚠ This exists because it was missed twice. The TH-D72 shipped in v26.8.29 and /// never reached the table, and the BT-9000 was still sitting under "Planned" @@ -54,13 +94,8 @@ fn production_half(src: &str) -> &str { fn every_seeded_radio_appears_in_the_readme() { let readme = std::fs::read_to_string(manifest_dir().join("../README.md")) .expect("README.md is readable from the crate"); - let supported = readme - .split("## Supported radios") - .nth(1) - .expect("README has a `## Supported radios` heading") - .split("\n## ") - .next() - .expect("that section ends at the next heading"); + let (supported, planned) = readme_tables(&readme); + assert!(!supported.is_empty(), "no `## Supported radios` table found"); let mut checked = 0; for d in crate::radios::registry::all_drivers() { @@ -71,14 +106,6 @@ fn every_seeded_radio_appears_in_the_readme() { "{name} is a working driver but is not in the README's Supported radios \ table. Someone who owns one cannot tell that it works." ); - // Bounded to the Planned TABLE, not to everything after the heading: - // the credits and feature sections name shipped radios too, and an - // unbounded match reported the AT-D890UV as still planned. - let planned = readme - .split("**Planned**") - .nth(1) - .and_then(|p| p.split("\n\n").find(|b| b.trim_start().starts_with('|'))) - .unwrap_or(""); assert!( !planned.contains(name), "{name} ships AND is still listed under Planned" @@ -87,6 +114,41 @@ fn every_seeded_radio_appears_in_the_readme() { assert!(checked > 0, "no drivers found — this test would pass vacuously"); } +/// The parser itself, against both line endings, because the difference between +/// them is what broke it on Windows while two other platforms said it was fine. +#[test] +fn readme_tables_are_parsed_the_same_with_either_line_ending() { + const DOC: &str = "\ +## Supported radios + +| Radio | Notes | +|---|---| +| **Kept Radio** | works | + +Some prose that mentions Planned Radio in passing. + +## Future development + +**Planned** + +| Radio | Issue | +|---|---| +| **Planned Radio** | #1 | + +Trailing prose naming Kept Radio again. +"; + for (label, doc) in [("LF", DOC.to_string()), ("CRLF", DOC.replace('\n', "\r\n"))] { + let (supported, planned) = readme_tables(&doc); + assert!(supported.contains("Kept Radio"), "{label}: supported table lost a row"); + assert!(!supported.contains("Planned Radio"), "{label}: tables bled together"); + assert!(planned.contains("Planned Radio"), "{label}: planned table not found"); + assert!( + !planned.contains("Kept Radio"), + "{label}: planned table ran past its end and swallowed later prose" + ); + } +} + fn driver_dirs() -> Vec { let radios = manifest_dir().join("src/radios"); let mut dirs: Vec = std::fs::read_dir(&radios) From fd41bcd7d67f844ed7a632fc64623b1c04edd878 Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 17:25:32 -0600 Subject: [PATCH 15/15] BT-9000: fix nine findings from the pre-merge review (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tim caught that the review had not been run since 4c31e94, one commit before the merge. Nine of eleven findings are fixed here; the other two are answered below. ★ The two HIGH ones are the same bug, fixed once and left in two other places. This branch established that function-block bytes 0x80-0xFF are a firmware-maintained shadow that moves on its own, and narrowed the settings read-back to the live area -- but `restore_image` and `verify_after_write` both kept comparing the full segment. So a restore that landed perfectly would tell the operator "the radio does NOT hold this backup", on the one path somebody reaches for when a write has already gone wrong, and a good codeplug program would steer them toward an unnecessary restore. All three now go through one `comparable()` helper, so a fourth caller cannot forget. ★ `rx_bands` claimed 18-999 MHz on the strength of a Work Band menu label, while the highest confirmed point is 520 and the next probe up could not be dialled on the VFO at all. That made a 902 MHz repeater -- RepeaterBook carries them -- `ReceiveOnly` rather than `Excluded`: a memory slot consumed and reported as written on a radio that cannot tune it. That is the silently-empty-slot failure already recorded here for the ID-52, introduced by over-claiming. Capped at 520. Also: * `write_settings` verified TWICE on the success path and discarded the first result, so a flaky second handshake could report a proven-committed write as unverified. Verify once, keep the answer, re-verify only after an actual retry. * The retry's re-handshake was the only fallible step in that function that dropped the backup path from its error, and it fires after a write has gone out. * `verify` indexes FIELDS addresses into a slice truncated to 0x46 with no guard. The highest is 0x44 and the sheet still owes rows; a field at 0x46 would never be verified AND would panic inside the mismatch-reporting loop, which runs only when a write failed. Test added. * The README guard matched display names as substrings: "Icom ID-51" is a substring of the planned "Icom ID-5100", so it would have failed the day the ID-51 ships. Matched as the bolded cell now, with a prefix-collision case in the parser test. * ⚠ The seed guard replacing the band tripwire did not check what its message claimed -- a `t != r` disjunct made it true for bands that merely differ, so an rx band strictly INSIDE tx would have passed while receive-only was dead code. Rewritten to test real coverage and checked against that counter-example. * `covers_hf` and `covers_220` still said false while the seed claims TX on 18-64 and 200-260. Display only, but it is what the band chips show and what an operator picks a radio by. Not changed, with reasons: * Truncating the `function` WRITE segment to 0x80 to spare the shadow, by analogy with the VFO journal. Tested on the radio this session and REJECTED: a half-segment write commits WITHOUT being acknowledged, and this driver aborts on a missing ACK, so it would stop mid-write on data that had already landed. * The 18-64 MHz transmit span takes neither edge from a measurement -- 27.500 and 50.125 keyed inside it and the edges come from the radio's own Work Band label. Flagged to Tim as the one deliberate judgement call rather than changed unilaterally. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- README.md | 2 +- src-tauri/src/radios/binteradio_bt9000/mod.rs | 28 ++++++-- .../src/radios/binteradio_bt9000/settings.rs | 44 ++++++++++-- src-tauri/src/radios/wiring.rs | 24 +++++-- src-tauri/src/seed.rs | 70 +++++++++++++++---- 5 files changed, 139 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 4b4b48b..c8fa9da 100644 --- a/README.md +++ b/README.md @@ -26,7 +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 | -| **Binteradio BT-9000** | Analog FM/NFM | 18–64 / 136–174 / 200–260 / 400–520 MHz TX, 18–999 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` | +| **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 original, writes, and can verify the result byte-for-byte. diff --git a/src-tauri/src/radios/binteradio_bt9000/mod.rs b/src-tauri/src/radios/binteradio_bt9000/mod.rs index eed2361..5e321a9 100644 --- a/src-tauri/src/radios/binteradio_bt9000/mod.rs +++ b/src-tauri/src/radios/binteradio_bt9000/mod.rs @@ -150,6 +150,25 @@ pub(crate) const WRITE_SEGMENTS: [Segment; 6] = [ Segment { name: "mod_names", command: 0x57, address: 0xD000, file_offset: 0x7E00, length: 0x0300 }, ]; +/// The bytes of `seg` a read-back may legitimately be compared against. +/// +/// ⚠ Two ranges inside [`WRITE_SEGMENTS`] are FIRMWARE-OWNED and move on their +/// own, so comparing them reports a mismatch on a write that landed perfectly: +/// +/// - the `vfo` journal, already excluded by that segment stopping at `0x80`; +/// - the back half of `function`, `0x9080`-`0x90FF`, a shadow copy of the live +/// settings (`+0xD0` onward is byte-for-byte the live block). +/// +/// This exists as a helper because the first fix for it was applied to ONE of +/// the three comparisons in this driver. The other two — the restore and the +/// codeplug read-back — kept telling the operator their write had not landed, +/// and the restore is the path somebody reaches for when things have already +/// gone wrong. +pub(crate) fn comparable(seg: Segment) -> std::ops::Range { + let len = if seg.name == "function" { FUNCTION_LIVE_LEN } else { seg.length }; + seg.file_offset..seg.file_offset + len +} + /// Refuse a segment whose opcode does not belong to the transport being asked /// to carry it. /// @@ -854,7 +873,7 @@ impl ImageRestorer for BinteradioBt9000 { let hs = handshake(&mut *p)?; let back = download(&mut *p, &hs)?; for seg in WRITE_SEGMENTS { - let r = seg.file_offset..seg.file_offset + seg.length; + let r = comparable(seg); if image[r.clone()] != back[r] { return Err(format!( "the radio acknowledged the restore but segment {} read back \ @@ -1011,8 +1030,9 @@ const SETTLE: Duration = Duration::from_secs(5); /// Read the image back and compare only the regions we actually wrote. /// -/// The VFO journal and the APRS block are excluded because the radio owns them: -/// comparing them would report a difference on every single write. +/// The VFO journal, the function block's shadow half and the APRS block are all +/// excluded because the radio owns them: comparing them would report a +/// difference on every single write. See [`comparable`]. fn verify_after_write( p: &mut dyn SerialPort, expected: &[u8], @@ -1021,7 +1041,7 @@ fn verify_after_write( let actual = download(p, &hs)?; let mut mismatched = Vec::new(); for seg in WRITE_SEGMENTS { - let range = seg.file_offset..seg.file_offset + seg.length; + let range = comparable(seg); if expected[range.clone()] != actual[range] { mismatched.push(seg.name); } diff --git a/src-tauri/src/radios/binteradio_bt9000/settings.rs b/src-tauri/src/radios/binteradio_bt9000/settings.rs index cbf1de8..fa65ac6 100644 --- a/src-tauri/src/radios/binteradio_bt9000/settings.rs +++ b/src-tauri/src/radios/binteradio_bt9000/settings.rs @@ -285,8 +285,22 @@ impl SettingsWriter for super::BinteradioBt9000 { // hammering a write path on this platform is how radios have been // damaged. std::thread::sleep(SETTLE); - if matches!(verify(&mut *p, &expected), Ok((false, _))) { - let hs = handshake(&mut *p)?; + // ⚠ Verify ONCE and keep the answer. Calling it again for the report + // meant a proven-committed write could still be announced as + // unverified, because the second call is a fresh handshake and download + // on a radio this driver documents as needing seconds to settle and as + // wedging its handshake. Only an actual retry earns a second look. + let mut outcome = verify(&mut *p, &expected); + if matches!(outcome, Ok((false, _))) { + let hs = handshake(&mut *p).map_err(|e| { + crate::radios::driver::with_restore_hint( + e, + &backup_path, + "The first write did not commit and the radio would not answer for \ + a retry. Keep that file — it is the radio as it was read.", + ) + .to_string() + })?; upload_segments(&mut *p, &hs, &image, &SETTINGS_SEGMENTS).map_err(|e| { crate::radios::driver::with_restore_hint( e, @@ -301,8 +315,9 @@ impl SettingsWriter for super::BinteradioBt9000 { .to_string(), ); std::thread::sleep(SETTLE); + outcome = verify(&mut *p, &expected); } - let (verified, verify_note) = match verify(&mut *p, &expected) { + let (verified, verify_note) = match outcome { Ok(v) => v, Err(e) => ( false, @@ -339,7 +354,8 @@ fn verify( // comparing the whole 256 bytes reported a mismatch on a restore that had // in fact landed perfectly (measured on the radio, s128). Everything this // driver writes lives below `0x46`. - let got = &back[FUNCTION_OFFSET..FUNCTION_OFFSET + FUNCTION_LIVE_LEN]; + let live = super::comparable(SETTINGS_READ_SEGMENTS[0]); + let got = &back[live]; let expected = &expected[..FUNCTION_LIVE_LEN]; if got == expected { return Ok((true, None)); @@ -453,6 +469,26 @@ mod tests { super::super::check_commands(&SETTINGS_SEGMENTS, &[0x57], "write").unwrap(); } + /// ⚠ Every field must live inside the area the read-back compares. + /// + /// `verify` truncates to `FUNCTION_LIVE_LEN` (0x46) and then indexes + /// `FIELDS` addresses into that slice. The highest address today is 0x44 — + /// one byte of headroom — and the sheet still has rows owed. A field at 0x46 + /// or above would never be verified AND would panic inside the + /// mismatch-reporting loop, which only runs when a write failed to commit. + #[test] + fn every_field_lives_inside_the_verified_area() { + for f in &FIELDS { + assert!( + f.addr < FUNCTION_LIVE_LEN, + "{} is at 0x{:02X}, outside the 0x{:02X} bytes `verify` compares", + f.key, + f.addr, + FUNCTION_LIVE_LEN + ); + } + } + /// Round-trip every field through the form's own representation. #[test] fn every_field_round_trips() { diff --git a/src-tauri/src/radios/wiring.rs b/src-tauri/src/radios/wiring.rs index d9bbfac..4e071e3 100644 --- a/src-tauri/src/radios/wiring.rs +++ b/src-tauri/src/radios/wiring.rs @@ -101,13 +101,19 @@ fn every_seeded_radio_appears_in_the_readme() { for d in crate::radios::registry::all_drivers() { let name = d.display_name(); checked += 1; + // ⚠ Matched as the bolded cell `**Name**`, not as a bare substring of the + // table. "Icom ID-51" is a substring of the planned "Icom ID-5100", so a + // substring match would report the ID-51 as still planned the day it + // ships (#50), and would accept an ID-5100 row as proof the ID-51 is + // supported. + let cell = format!("**{name}**"); assert!( - supported.contains(name), + supported.contains(&cell), "{name} is a working driver but is not in the README's Supported radios \ table. Someone who owns one cannot tell that it works." ); assert!( - !planned.contains(name), + !planned.contains(&cell), "{name} ships AND is still listed under Planned" ); } @@ -139,13 +145,19 @@ Trailing prose naming Kept Radio again. "; for (label, doc) in [("LF", DOC.to_string()), ("CRLF", DOC.replace('\n', "\r\n"))] { let (supported, planned) = readme_tables(&doc); - assert!(supported.contains("Kept Radio"), "{label}: supported table lost a row"); - assert!(!supported.contains("Planned Radio"), "{label}: tables bled together"); - assert!(planned.contains("Planned Radio"), "{label}: planned table not found"); + assert!(supported.contains("**Kept Radio**"), "{label}: supported table lost a row"); + assert!(!supported.contains("**Planned Radio**"), "{label}: tables bled together"); + assert!(planned.contains("**Planned Radio**"), "{label}: planned table not found"); assert!( - !planned.contains("Kept Radio"), + !planned.contains("**Kept Radio**"), "{label}: planned table ran past its end and swallowed later prose" ); + // A shorter name must not match a longer one that starts with it — the + // ID-51 / ID-5100 collision this guard would otherwise have. + assert!( + !planned.contains("**Planned Rad**"), + "{label}: a prefix matched a longer name" + ); } } diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index 913594a..2a831f7 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -209,12 +209,18 @@ fn models() -> Vec { p25_capable: false, m17_capable: false, aprs_capable: false, - covers_hf: false, + // Updated with the measured bands: `tx_bands` now reaches 18-64 MHz + // (27.500 and 50.125 both keyed, so part of it is HF) and 200-260 + // (223.500 keyed, which is 1.25 m). These flags are display only — + // `tx_capable` reads `tx_bands` — but they are what the band chips + // in the codeplug and profile screens show, and therefore what an + // operator picks a radio by. + covers_hf: true, covers_vhf: true, covers_uhf: true, - covers_220: false, + covers_220: true, covers_900: false, - freq_min: 136.0, + freq_min: 18.0, freq_max: 520.0, tx_bands: None, rx_bands: None, @@ -341,12 +347,18 @@ fn models() -> Vec { // Claiming the capability would put an APRS form in front of the // operator that silently does nothing. aprs_capable: false, - covers_hf: false, + // Updated with the measured bands: `tx_bands` now reaches 18-64 MHz + // (27.500 and 50.125 both keyed, so part of it is HF) and 200-260 + // (223.500 keyed, which is 1.25 m). These flags are display only — + // `tx_capable` reads `tx_bands` — but they are what the band chips + // in the codeplug and profile screens show, and therefore what an + // operator picks a radio by. + covers_hf: true, covers_vhf: true, covers_uhf: true, - covers_220: false, + covers_220: true, covers_900: false, - freq_min: 136.0, + freq_min: 18.0, freq_max: 520.0, // MEASURED on the radio (s128), one channel per band edge and the // PTT pressed on each. The image could not answer this — it stores @@ -372,13 +384,22 @@ fn models() -> Vec { // 18-64 takes the range the radio's own Work Band menu declares, // with 27.5 and 50.125 confirmed inside it. tx_bands: Some("[[18.0,64.0],[136.0,174.0],[200.0,260.0],[400.0,520.0]]"), - // The receiver is wider than the transmitter, which is why these - // now differ. The Work Band menu offers 18-64 and 64-999, and - // 108.000 was confirmed receiving AM inside the second. A channel - // that lands here but not in `tx_bands` is programmed receive-only — - // byte 15 bit 1 clear — which is measured, not assumed: such a - // channel refuses the PTT while its neighbour keys normally. - rx_bands: Some("[[18.0,999.0]]"), + // The receiver is wider than the transmitter, which is why these now + // differ. A channel that lands here but not in `tx_bands` is + // programmed receive-only — byte 15 bit 1 clear — which is measured, + // not assumed: such a channel refuses the PTT while its neighbour + // keys normally. + // + // ⚠ CAPPED AT 520, not at the 999 the Work Band menu advertises. + // The highest frequency anything was confirmed at is 520.000, and + // the next probe up — 580.000 — could not be dialled on the VFO at + // all. Claiming 999 on the strength of a menu label would make a 902 + // MHz repeater (RepeaterBook carries them) `ReceiveOnly` instead of + // `Excluded`: it would take a memory slot and be reported as + // written, on a radio that cannot tune it. That is the + // silently-empty-slot failure this project already recorded once on + // the ID-52, and the old conservative seed did not have it. + rx_bands: Some("[[18.0,520.0]]"), memory_channels: 960, zones_supported: false, max_zones: None, @@ -941,8 +962,29 @@ mod tests { // asks it to be used. let rx: Vec<[f64; 2]> = serde_json::from_str(bt.rx_bands.unwrap()).unwrap(); let tx: Vec<[f64; 2]> = serde_json::from_str(bt.tx_bands.unwrap()).unwrap(); + // ⚠ "Some rx band is not fully covered by the UNION of the tx bands." + // + // The first version of this asserted `t != r` among its disjuncts, which + // made it true for any bands that merely differ — so `rx = [[137,173]]` + // strictly INSIDE `tx = [[136,174]]` would have passed while receive-only + // was dead code, which is the exact thing it claims to rule out. A guard + // that cannot fail proves nothing. + let uncovered = rx.iter().any(|r| { + // Walk the rx span, swallowing any tx span that overlaps it. If a gap + // survives, the receiver reaches somewhere the transmitter does not. + let mut lo = r[0]; + let mut spans: Vec<[f64; 2]> = tx.iter().copied().filter(|t| t[1] > r[0] && t[0] < r[1]).collect(); + spans.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap()); + for t in spans { + if t[0] > lo { + return true; + } + lo = lo.max(t[1]); + } + lo < r[1] + }); assert!( - rx.iter().any(|r| tx.iter().all(|t| t[0] > r[1] || t[1] < r[0] || t != r)), + uncovered, "rx_bands must cover ground tx_bands does not, or receive-only is dead code" ); }