From a37e23a01e4c8b23484e7300e45c1199f18d2700 Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 20:04:57 -0600 Subject: [PATCH 1/5] BT-9000: a codeplug program writes the profile's settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `carries_profile_settings` answered false, so `program_codeplug` ignored `req.settings` — and the note said that was the safe choice, because this radio validates nothing and an unintended value would be stored rather than rejected. It was not the safe choice, because it was never the quiet one. The whole-image `upload` addresses WRITE_SEGMENTS, and the function block is one of them: every codeplug program was already rewriting the settings segment, with the bytes read off the radio moments earlier. Saying "we leave settings alone" described the effect on the radio, not on the profile — the profile's settings were being overwritten by the radio's own on every program. So the settings now go into that block before it goes out. The write is not one byte wider than it was. The risk the old note worried about is bounded by the encoder being a PATCH: a key the profile does not carry keeps the value the radio had, the command layer only fills `req.settings` from a profile the operator saved, and the range strip runs first so a stale value is dropped with a note instead of failing the program. The narrow standalone settings write stays — a third of a second against the four minutes a full program takes. The Program dialog needs no change: its banner is written from the capability flag, so it now promises the settings and drops the "written separately" sentence on its own. ⚠ Not verified on the radio. The encoder, the segment table and the read-back verifier are all the ones hardware ladder step 5 proved in s128, but this particular path has not been run against a BT-9000. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- src-tauri/src/commands/program.rs | 10 +- src-tauri/src/radios/binteradio_bt9000/mod.rs | 99 +++++++++++++++++-- src-tauri/src/radios/driver.rs | 19 ++-- 3 files changed, 111 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/commands/program.rs b/src-tauri/src/commands/program.rs index c14a1e7..cd994fa 100644 --- a/src-tauri/src/commands/program.rs +++ b/src-tauri/src/commands/program.rs @@ -941,10 +941,12 @@ pub async fn program_radio( // digital-mode) channels drop out and the rest close up behind them. let (_model, slots) = export::resolve_codeplug_slots(&state.pool, codeplug_id).await?; - // The radio profile's settings + the model's schema. Present on the - // UV-5R (which makes the profile authoritative over every editable - // setting during a program); the TD-H3 ignores them and pushes settings - // through its separate, explicitly-acknowledged settings write. + // The radio profile's settings + the model's schema. Used by the + // UV-5R and the BT-9000, whose settings live inside the image the + // program uploads; the TD-H3 ignores them and pushes settings through + // its separate, explicitly-acknowledged settings write. Which drivers + // use them is not guesswork — `carries_profile_settings` declares it, + // and the Program dialog's banner is written from that flag. let profile_settings: Option = sqlx::query_scalar( "SELECT rp.non_channel_settings FROM codeplugs cp \ JOIN radio_profiles rp ON rp.id = cp.radio_profile_id WHERE cp.id = ?1", diff --git a/src-tauri/src/radios/binteradio_bt9000/mod.rs b/src-tauri/src/radios/binteradio_bt9000/mod.rs index 5e321a9..35ed659 100644 --- a/src-tauri/src/radios/binteradio_bt9000/mod.rs +++ b/src-tauri/src/radios/binteradio_bt9000/mod.rs @@ -888,6 +888,27 @@ impl ImageRestorer for BinteradioBt9000 { } impl ImageProgrammer for BinteradioBt9000 { + /// Yes. A codeplug program writes the profile's settings alongside the + /// channels. + /// + /// This read `false` until it was measured what the whole-image [`upload`] + /// already does: it addresses [`WRITE_SEGMENTS`], and the function block is + /// one of them — so a channel program was ALREADY rewriting the settings + /// segment, just with the bytes it had read a moment earlier. Nothing about + /// the write got wider here; what changed is that the profile's values go + /// into that segment before it goes out, instead of the radio's own. + /// + /// The old note said settings were held back because this radio validates + /// nothing and an unintended value would be stored rather than rejected. + /// That risk is bounded by [`settings::apply_profile_settings`] being a + /// PATCH: a key the profile does not carry is left exactly as the radio had + /// it, and the command layer only fills `req.settings` from a profile the + /// operator saved. The standalone `write_settings` path already accepted + /// the same values on the same encoder. + fn carries_profile_settings(&self) -> bool { + true + } + fn download_image(&self, port: &str) -> Result<(RadioIdentity, Vec), String> { let mut p = open_port(port)?; let hs = handshake(&mut *p)?; @@ -934,10 +955,10 @@ impl ImageProgrammer for BinteradioBt9000 { /// 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. + /// `req.settings` is patched into the function block on the way out, so a + /// program leaves the radio holding the profile in full — channels AND + /// settings. Only the keys the profile carries move; every other byte of + /// that block goes back exactly as it was read. fn program_codeplug( &self, port: &str, @@ -970,6 +991,29 @@ impl ImageProgrammer for BinteradioBt9000 { let channels_written = req.channels.len(); patch_image(&mut image, req.channels, req.model); + // 2b. Patch the profile's settings into the function block, if the + // profile carries any. Before the write, so a value this driver's + // encoder refuses aborts with nothing on the wire. The range strip + // runs first for the same reason it does in `write_settings`: a + // stale profile value is dropped with a note rather than failing + // the whole program. (The command layer strips too; this covers the + // hardware-ladder callers, which reach the trait directly.) + let mut warnings: Vec = Vec::new(); + let settings_written = match req.settings { + Some((settings, schema)) => { + let mut settings = settings.clone(); + warnings.extend(crate::radios::settings_bounds::strip_out_of_range( + schema, + &mut settings, + )); + let (written, skipped) = + settings::apply_profile_settings(&mut image, &settings)?; + warnings.extend(skipped); + Some(written) + } + None => None, + }; + let restore_hint = |e: String| { crate::radios::driver::with_restore_hint( e, @@ -1004,7 +1048,7 @@ impl ImageProgrammer for BinteradioBt9000 { Ok(CodeplugProgramReport { channels_written, slots_cleared: CHANNEL_COUNT - channels_written, - settings_written: None, + settings_written, verified: Some(verified), note, backup_path: backup_path.to_string_lossy().to_string(), @@ -1018,7 +1062,7 @@ impl ImageProgrammer for BinteradioBt9000 { expected_path: None, windows_written: Vec::new(), skipped: Vec::new(), - warnings: Vec::new(), + warnings, }) } } @@ -1271,6 +1315,49 @@ mod tests { assert!(!forbidden(0x807F)); } + /// A codeplug program leaves the radio holding the PROFILE's settings. + /// + /// It did not, and the reason is worth pinning: the whole-image upload has + /// always addressed the function block, so declaring + /// `carries_profile_settings = false` protected nothing — it just wrote the + /// radio's own settings straight back over the profile's, every program. + /// The three facts the fix stands on are all here. + #[test] + fn a_program_carries_the_profile_settings() { + let caps = crate::radios::driver::DriverCapabilities::of(&DRIVER); + assert!( + caps.programs_settings, + "the program writes the function block either way; saying otherwise \ + sends the radio's old settings back over the profile's" + ); + assert!( + caps.write_settings, + "the narrow standalone write stays — a third of a second against the \ + four minutes a full program takes" + ); + + // The block the settings encoder writes into is one the upload sends. + let seg = WRITE_SEGMENTS + .iter() + .find(|s| s.file_offset == FUNCTION_OFFSET) + .expect("the function block is part of a full program's write"); + assert!(FUNCTION_LIVE_LEN <= seg.length); + + // And a channel patch cannot reach it: the records stop first. + const { assert!(CHANNEL_COUNT * ENTRY_LEN <= FUNCTION_OFFSET) }; + + // The encoder lands inside that segment, so what it writes goes out. + const SQUELCH_ADDR: usize = 0x00; + let mut image = vec![0xFFu8; IMAGE_LEN]; + let (written, notes) = settings::apply_profile_settings( + &mut image, + &serde_json::json!({ "squelch": 5 }), + ) + .unwrap(); + assert_eq!((written, notes.len()), (1, 0)); + assert_eq!(image[FUNCTION_OFFSET + SQUELCH_ADDR], 5); + } + /// 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] diff --git a/src-tauri/src/radios/driver.rs b/src-tauri/src/radios/driver.rs index 664e9cc..dc22f7b 100644 --- a/src-tauri/src/radios/driver.rs +++ b/src-tauri/src/radios/driver.rs @@ -197,10 +197,13 @@ pub(crate) trait ImageProgrammer: Send + Sync { /// 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). + /// fills that for every driver, and some deliberately ignore it. A radio + /// whose settings live outside the region a channel program writes treats + /// them as a separate, explicitly-acknowledged operation and leaves the + /// radio's own alone during a program (the TD-H3 does). A radio whose + /// program already rewrites the settings region either way should answer + /// true, or it puts the radio's old settings back over the profile's every + /// time (the BT-9000 did exactly that). /// /// Declared rather than inferred because the generic Program dialog states /// in a safety banner what the operation will change, and that sentence was @@ -607,9 +610,11 @@ 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. + /// Whether a codeplug program also writes the profile's settings. The + /// UV-5R and the BT-9000 do: their settings live inside the image + /// `program_codeplug` uploads, so they ride out with the channels. (The + /// BT-9000 also keeps its standalone narrow settings write, which is a + /// third of a second against four minutes.) pub programs_settings: bool, pub write_callsign_db: bool, pub export: bool, From 8edc9863c68450f158f4d09660d81a2c290a30c7 Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 20:21:31 -0600 Subject: [PATCH 2/5] BT-9000: program every channel into the scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte 15 bit 2 was left clear on all 960 memories, so a radio programmed by this app scanned nothing it had been given. The old comment called the whole byte "measured defaults, not guesses" — true of the bits that had been measured, and quietly untrue of this one, which had never been set either way. The channel library has nowhere to store a per-channel skip, so this is a policy choice and it is now the same one the TD-H3 makes: scan-add set beside the used flag on every channel written, cleared on none, because a memory excluded from scan is the surprising default of the two. ⚠ The BIT is inherited, not measured. `chirp_rt950/rt950pro/channel.py` decodes `flags & 0x04` as `scan_add` and round-trips it against the vendor CPS's own `.dat` files, and no BT-9000 image on this machine has it set — both radio-authored dumps carry 0x02, TX-enable alone. Its neighbour at bit 1 was a source claim of exactly this kind until the radio was made to refuse a PTT. So: unproven until a scan on the radio stops on a programmed memory. The test pins the DECISION rather than the bit, so if the bit is wrong, one constant moves and the policy does not. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- src-tauri/src/radios/binteradio_bt9000/mod.rs | 57 +++++++++++++++++-- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/radios/binteradio_bt9000/mod.rs b/src-tauri/src/radios/binteradio_bt9000/mod.rs index 35ed659..6f90781 100644 --- a/src-tauri/src/radios/binteradio_bt9000/mod.rs +++ b/src-tauri/src/radios/binteradio_bt9000/mod.rs @@ -689,6 +689,12 @@ fn encode_tones(c: &Channel) -> ([u8; 2], [u8; 2]) { } } +/// Byte 15 bit 2: include this memory in the radio's scan. +/// +/// Every channel this app programs gets it — see the note in +/// [`encode_channel`] for why, and for what is and is not measured about it. +const SCAN_ADD: u8 = 0x04; + /// Build one 32-byte channel record. /// /// The TX shift is carried entirely by the stored TX frequency — there is no @@ -715,8 +721,23 @@ fn encode_channel(c: &Channel, name: &str, tx_hz: u64, tx_enable: bool) -> [u8; // 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. + // bit 1 = TX enable, bit 2 = scan-add, bit 6 = narrow. Everything else + // (FHSS, encryption, busy lockout, AM) stays off. + // + // ⚠ Scan-add is set on EVERY programmed channel, and that is a choice, not + // a copy of what the radio had. The channel library has nowhere to store a + // per-channel skip, so the alternative is what this driver shipped with: + // every channel written with the bit clear, and an operator who scans and + // hears nothing on any memory they programmed. The TD-H3 made the same call + // for the same reason (`SCANADD_BASE`, set true beside `USEDFLAGS_BASE`), + // and a channel excluded from scan is the surprising default of the two. + // + // ⚠ The BIT is inherited, not measured here: `scratchpad/binteradio_bt9000/ + // chirp_rt950/rt950pro/channel.py` decodes `flags & 0x04` as `scan_add` and + // round-trips it against the vendor CPS's own `.dat` files. Its neighbour at + // bit 1 was a source claim of exactly this kind until the radio was made to + // refuse a PTT, so treat this one as unproven until a scan on the radio + // stops on a programmed memory. // // ⚠ Narrow ONLY on an explicit narrow mode. `mode` is nullable in the // schema and reachable from a CSV import with no mode column, and @@ -730,7 +751,7 @@ fn encode_channel(c: &Channel, name: &str, tx_hz: u64, tx_enable: bool) -> [u8; // `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] = if tx_enable { 0x02 } else { 0x00 } | if narrow { 0x40 } else { 0x00 }; + m[15] = SCAN_ADD | 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)); @@ -1289,9 +1310,35 @@ mod tests { 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. + // The bandwidth bit is independent of it. Scan-add is present either + // way: a receive-only memory is exactly the kind a scan should stop on. let n = Channel { mode: Some("NFM".into()), ..c.clone() }; - assert_eq!(encode_channel(&n, "RX", 146_520_000, false)[15], 0x40); + assert_eq!(encode_channel(&n, "RX", 146_520_000, false)[15], 0x40 | SCAN_ADD); + } + + /// Every programmed channel is in the scan; every empty slot is not. + /// + /// The driver shipped writing this bit clear on all 960 memories, which is + /// how a radio ends up scanning nothing the operator programmed. + /// + /// ⚠ The bit itself is inherited from the RT-950 Pro reference driver, not + /// measured on a BT-9000. This test pins the DECISION — scan by default — + /// so that if the bit turns out to be something else, what changes is one + /// constant and not the policy. + #[test] + fn every_programmed_channel_is_in_the_scan() { + let c = Channel { rx_freq: 146.52, mode: Some("FM".into()), ..Default::default() }; + assert_eq!(encode_channel(&c, "SCAN", 146_520_000, true)[15] & SCAN_ADD, SCAN_ADD); + + // An empty slot is the radio's own all-0xFF form, which the radio reads + // as "no memory here" — the flag byte inside it means nothing, and both + // images the radio authored agree (channel records 0xFF, byte 15 + // included). Nothing to clear. + let mut image = vec![0xFFu8; IMAGE_LEN]; + let rec = encode_channel(&c, "SCAN", 146_520_000, true); + image[..ENTRY_LEN].copy_from_slice(&rec); + assert_eq!(image[15] & SCAN_ADD, SCAN_ADD); + assert_eq!(image[ENTRY_LEN..ENTRY_LEN * 2], [0xFF; ENTRY_LEN]); } #[test] From c55f6336689539e69d72714c562971fdeaa808ed Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 20:39:53 -0600 Subject: [PATCH 3/5] BT-9000: one channel list per zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The radio has fifteen zones of sixty-four and the operator switches between them with a key. This app poured every channel list into one undifferentiated run of memories, so a zone began and ended wherever the previous list happened to stop. The seed said `zones_supported: false`, and the comment gave the reason: the radio stores no zone NAMES, so declaring zones "would be offering a name the radio cannot store." That confused the label with the zone. The zones exist either way; withholding the flag withheld the layout, not the name. Zones here are index arithmetic — a memory's zone is `slot / 64` and there is no zone table anywhere in the image — so honouring them is a decision about which slots the channels land in, and it belongs in the resolver, not a driver that only ever sees slots. `resolve_codeplug_zone_slots` lays one list into each zone: * a list that does not fill its zone leaves the rest of it empty, and that gap is the feature — it is what keeps zone 2 the second list; * a list longer than one zone spills into the next rather than being truncated, because dropping channels an operator explicitly listed is the worse failure and "this list is zones 1-2" costs one sentence; * a list with nothing programmable takes no zone, and the rest move up; * a channel in two lists is programmed TWICE, once in each zone — on a positional radio that is the only way to be in both. ⚠ `fixed_zone_layout` asks the DRIVER, not the three zone columns. The AT-D890UV declares zones with 250 of 160 as well, and its zones are records naming arbitrary slots; reading the columns alone — which is how this was first written — would have laid its channels out positionally and shown it a zone map it does not use. A radio programmed as a whole image has no zone table to write, which is exactly what makes position the membership. The names stay ours, so the Program dialog shows the map before the write: the radio can only ever say "zone 2". Verified: the resolver against a real database (four decisions, plus the AnyTone exclusion), and the dialog rendered against canned IPC. Not yet run on the radio — the check is that its zone 2 holds the second list. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- src-tauri/src/commands/export.rs | 412 +++++++++++++++++- src-tauri/src/commands/program.rs | 35 +- src-tauri/src/models.rs | 23 + src-tauri/src/seed.rs | 20 +- .../codeplugs/ProgramRadioDialog.tsx | 59 ++- src/lib/types.ts | 16 + 6 files changed, 553 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index b2b6046..11ddc55 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -1,11 +1,11 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use tauri::State; use crate::db::AppState; use crate::error::MapErrString; use crate::models::{ - Channel, ExportPreview, ExportPreviewRow, RadioModel, RepeaterTalkgroup, + Channel, ExportPreview, ExportPreviewRow, PreviewZone, RadioModel, RepeaterTalkgroup, }; const MODEL_COLUMNS_PREFIXED: &str = "rm.id, rm.manufacturer, rm.model, rm.display_name, rm.analog_capable, rm.dmr_capable, rm.dstar_capable, rm.ysf_capable, rm.nxdn_capable, rm.p25_capable, rm.m17_capable, rm.aprs_capable, rm.covers_hf, rm.covers_vhf, rm.covers_uhf, rm.covers_220, rm.covers_900, rm.freq_min, rm.freq_max, rm.tx_bands, rm.rx_bands, rm.memory_channels, rm.zones_supported, rm.max_zones, rm.channels_per_zone, rm.scan_lists_supported, rm.max_scan_lists, rm.banks_supported, rm.max_name_length, rm.export_format, rm.connection_type, rm.non_channel_settings_schema, rm.driver_key, rm.programming_ui"; @@ -598,6 +598,199 @@ pub(crate) async fn resolve_codeplug_slots( Ok((model, slots)) } +/// One zone as it will exist on a radio whose zones are FIXED BLOCKS of +/// memories rather than named lists of them. +/// +/// The BT-9000 is the shape this exists for: 15 zones of 64, and a memory's +/// zone is `index / 64` with no zone record and no zone name stored anywhere on +/// the radio. So "put this channel list in its own zone" is not a table to +/// write — it is a decision about which memory slots the channels land in, and +/// it belongs here in the resolver rather than in a driver, which sees slots. +/// +/// Contrast the AnyTone, whose zones are real records naming arbitrary slots: +/// there a channel in two lists occupies ONE memory that two zones point at. +/// Here it has to occupy one memory in EACH zone, because position is the only +/// membership there is. +pub(crate) struct ProgrammedZone { + /// 1-based zone number on the radio: the block of `per_zone` memories + /// starting at `(number - 1) * per_zone`. + pub number: usize, + /// The channel list this zone came from. Ours to display only — the radio + /// cannot store it, which is exactly why the caller has to show the map. + pub list_name: String, + pub channels: usize, +} + +/// A codeplug laid out into a fixed-zone radio's memory. +pub(crate) struct ZonedCodeplug { + /// Slot-resolved channels. NOT dense from 0: each list starts at its zone + /// boundary, so the gap after a short list is deliberate. + pub slots: Vec, + pub zones: Vec, + /// What the operator needs told about the layout — a list that outgrew one + /// zone, a list with nothing programmable in it, lists past the radio's + /// last zone. + pub warnings: Vec, +} + +/// Resolve a codeplug into a fixed-zone radio's memory: one channel list per +/// zone, in the codeplug's list order. +/// +/// The rules, all of them visible to the operator in [`ZonedCodeplug::zones`] +/// and `warnings`: +/// +/// * A list that does not fill its zone leaves the rest of that zone empty. +/// That gap is the feature — it is what keeps zone 2 the second list rather +/// than "wherever the first one happened to stop". +/// * A list LONGER than one zone spills into the next zone(s) instead of being +/// truncated. Dropping channels an operator explicitly put in a list is the +/// worse failure, and on a radio whose zones have no names, "this list is +/// zones 2-3" costs nothing to explain. +/// * A list with nothing this radio can program takes no zone at all; the lists +/// behind it move up. +/// * Lists that do not fit in `max_zones` are not programmed, and are named. +/// +/// A channel in two lists is programmed TWICE, once in each zone — see +/// [`ProgrammedZone`]. +pub(crate) async fn resolve_codeplug_zone_slots( + pool: &sqlx::SqlitePool, + codeplug_id: i64, + per_zone: usize, + max_zones: usize, +) -> Result<(RadioModel, ZonedCodeplug), String> { + let model = codeplug_model(pool, codeplug_id).await?; + let groups = resolve_codeplug_groups(pool, codeplug_id).await?; + + // Expand each list on its own. The flat resolver dedups across lists + // (`codeplug_channels`, first list wins) because there the channels share + // one pool; here a shared channel needs a memory in each zone. + let mut lists: Vec<(String, Vec)> = Vec::new(); + for g in groups { + let expanded = expand_for_export(pool, g.channels).await?; + let included: Vec = expanded + .into_iter() + .filter(|ec| exclusion_reason(&ec.channel, &model).is_none()) + .collect(); + lists.push((g.list_name, included)); + } + + // Name the DISTINCT channels once, then reuse. Naming the flattened slot + // list instead would hand `disambiguate_names` two rows with the same name + // AND the same frequency — the same channel in two zones — and it would + // pull them apart into "W0QEY" / "W0QEY 2", renaming a channel because of + // where else the operator filed it. + let mut order: Vec<&ExpandedChannel> = Vec::new(); + let mut name_of: HashMap<(i64, Option, Option), usize> = HashMap::new(); + for (_, ecs) in &lists { + for ec in ecs { + let key = (ec.channel.id, ec.tg_number, ec.timeslot); + if let std::collections::hash_map::Entry::Vacant(e) = name_of.entry(key) { + e.insert(order.len()); + order.push(ec); + } + } + } + let names = expanded_names(order, &model); + + let mut slots: Vec = Vec::new(); + let mut zones: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + let mut next_zone = 0usize; // 0-based + let mut unplaced: Vec = Vec::new(); + + for (list_name, ecs) in &lists { + if ecs.is_empty() { + warnings.push(format!( + "'{list_name}' has no channels this radio can program — it gets no zone, \ + and the lists after it move up one." + )); + continue; + } + let needed = ecs.len().div_ceil(per_zone); + if next_zone + needed > max_zones { + unplaced.push(format!("'{list_name}'")); + continue; + } + let first_zone = next_zone; + for (i, ec) in ecs.iter().enumerate() { + let key = (ec.channel.id, ec.tg_number, ec.timeslot); + slots.push(SlotChannel { + slot: first_zone * per_zone + i, + name: names[name_of[&key]].clone(), + channel: ec.channel.clone(), + }); + } + for z in 0..needed { + let taken = if z + 1 == needed { + ecs.len() - z * per_zone + } else { + per_zone + }; + zones.push(ProgrammedZone { + number: first_zone + z + 1, + list_name: if needed == 1 { + list_name.clone() + } else { + format!("{list_name} ({} of {needed})", z + 1) + }, + channels: taken, + }); + } + if needed > 1 { + warnings.push(format!( + "'{list_name}' has {} channels and a zone holds {per_zone}, so it fills \ + zones {}-{} on the radio.", + ecs.len(), + first_zone + 1, + first_zone + needed + )); + } + next_zone += needed; + } + + if !unplaced.is_empty() { + warnings.push(format!( + "The radio has {max_zones} zones and this codeplug needs more, so {} \ + {} not programmed. Remove a channel list, or split this codeplug in two.", + unplaced.join(", "), + if unplaced.len() == 1 { "was" } else { "were" } + )); + } + + Ok((model, ZonedCodeplug { slots, zones, warnings })) +} + +/// Whether `model` lays its memories out as fixed zone blocks, and how big +/// they are. `Some((per_zone, max_zones))` selects +/// [`resolve_codeplug_zone_slots`] over the flat [`resolve_codeplug_slots`]. +/// +/// ⚠ The three zone COLUMNS cannot answer this on their own, and reading them +/// alone is a bug this function was written with: the AT-D890UV declares +/// `zones_supported` with 250 zones of 160 as well, and its zones are named +/// records naming arbitrary slots — laying its channels out positionally would +/// be inventing a constraint the radio does not have, and its preview would +/// have shown a zone map it does not use. +/// +/// What separates them is the driver. A radio programmed as a whole image sees +/// nothing but slots and has no zone table anywhere to write, so where a +/// channel lands IS its zone; a radio with a [`CodeplugProgrammer`] writes real +/// zone records and owns the mapping itself. +pub(crate) fn fixed_zone_layout(model: &RadioModel) -> Option<(usize, usize)> { + if !model.zones_supported { + return None; + } + let (per, max) = match (model.channels_per_zone, model.max_zones) { + (Some(per), Some(max)) if per > 0 && max > 0 => (per as usize, max as usize), + _ => return None, + }; + let driver = crate::radios::registry::driver_for_model(model)?; + if driver.as_codeplug_programmer().is_some() { + return None; + } + driver.as_image_programmer()?; + Some((per, max)) +} + #[tauri::command] pub async fn export_preview( state: State<'_, AppState>, @@ -660,6 +853,28 @@ pub async fn export_preview( }); } + // The zone map, for a radio whose zones are fixed memory blocks. Resolved + // here rather than derived in the UI: which list lands in which zone is + // decided by `resolve_codeplug_zone_slots`, and a preview that works it out + // a second way is a preview that can disagree with the write. + let (zones, zone_notes) = match fixed_zone_layout(&model) { + Some((per_zone, max_zones)) => { + let (_model, zoned) = + resolve_codeplug_zone_slots(&state.pool, codeplug_id, per_zone, max_zones).await?; + let zones = zoned + .zones + .into_iter() + .map(|z| PreviewZone { + number: z.number, + list_name: z.list_name, + channels: z.channels, + }) + .collect(); + (zones, zoned.warnings) + } + None => (Vec::new(), Vec::new()), + }; + Ok(ExportPreview { codeplug_id, radio_model: model.display_name, @@ -668,6 +883,8 @@ pub async fn export_preview( excluded_count: excluded, receive_only_count: receive_only, rows, + zones, + zone_notes, }) } @@ -1479,6 +1696,197 @@ mod tests { let _ = std::fs::remove_file(&db_path); } + /// One channel list, one zone — on the radio the operator is switching + /// between, not just in this app's vocabulary. + /// + /// The BT-9000 shipped with `zones_supported = false`, on the argument that + /// the radio stores no zone NAMES. It has fifteen zones all the same, and + /// the flag being false meant every channel list was poured into one run of + /// memories: the zone selector walked through blocks of sixty-four that + /// began and ended in the middle of a list. + /// + /// Zones here are index arithmetic (`slot / 64 + 1`) with nothing to write, + /// so the layout is the whole feature and it lives in the resolver. This + /// pins the four decisions in it that an operator would notice. + #[tokio::test] + async fn channel_lists_become_zones_on_a_fixed_zone_radio() { + let dir = std::env::temp_dir().join(format!("cpm_bt9000_zones_{}", 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"); + + // 70 programmable 2 m channels, so one list can be made to outgrow a + // 64-memory zone, plus one the radio cannot use at all. + for i in 1..=70i64 { + sqlx::query( + "INSERT INTO channels (id, name_long, name_short, rx_freq, mode, source) + VALUES (?1, ?2, ?3, ?4, 'FM', 'manual')", + ) + .bind(i) + .bind(format!("Repeater {i}")) + .bind(format!("R{i}")) + .bind(145.0 + (i as f64) * 0.01) + .execute(&pool) + .await + .unwrap(); + } + sqlx::query( + "INSERT INTO channels (id, name_long, name_short, rx_freq, mode, source) + VALUES (99, 'Way Out', 'OUT', 1200.0, 'FM', 'manual')", + ) + .execute(&pool) + .await + .unwrap(); + + // Three lists: HOME (3 channels), GMRS (2, one of them ALSO in HOME), + // and DEAD (nothing this radio can program). + sqlx::query( + "INSERT INTO channel_lists (id, name) VALUES (10, 'HOME'), (11, 'GMRS'), (12, 'DEAD')", + ) + .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), + (11, 3, 0), (11, 4, 1), + (12, 99, 0)", + ) + .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"); + sqlx::query( + "INSERT INTO radio_profiles (id, display_name, radio_model_id) + VALUES (1, 'BT-9000 test', ?1)", + ) + .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), (1, 12, 1), (1, 11, 2)", + ) + .execute(&pool) + .await + .unwrap(); + + // The model declares the layout; the resolver is chosen from it, not + // from the driver key. + let model = codeplug_model(&pool, 1).await.unwrap(); + let (per_zone, max_zones) = + fixed_zone_layout(&model).expect("the BT-9000 declares 15 zones of 64"); + assert_eq!((per_zone, max_zones), (64, 15)); + + let (_model, zoned) = resolve_codeplug_zone_slots(&pool, 1, per_zone, max_zones) + .await + .expect("zone resolve"); + + // 1. One zone per list, in the codeplug's list order — and the empty + // list takes none, so GMRS moves up into zone 2 rather than leaving + // a hole the operator would scroll through. + let map: Vec<(usize, &str, usize)> = zoned + .zones + .iter() + .map(|z| (z.number, z.list_name.as_str(), z.channels)) + .collect(); + assert_eq!(map, vec![(1, "HOME", 3), (2, "GMRS", 2)]); + assert!( + zoned.warnings.iter().any(|w| w.contains("DEAD")), + "a list that got no zone has to be said out loud: {:?}", + zoned.warnings + ); + + // 2. The slots are NOT dense: zone 2 starts at memory 64. That gap is + // the feature — it is the only thing that makes zone 2 the GMRS list. + let slots: Vec = zoned.slots.iter().map(|s| s.slot).collect(); + assert_eq!(slots, vec![0, 1, 2, 64, 65]); + + // 3. A channel in two lists is programmed TWICE, once in each zone, + // because membership here is position and nothing else. And it keeps + // ONE name: naming the flat slot list instead would hand the + // disambiguator two rows with the same name and the same frequency + // and it would rename one of them. + let shared: Vec<&SlotChannel> = + zoned.slots.iter().filter(|s| s.channel.id == 3).collect(); + assert_eq!(shared.len(), 2, "a shared channel needs a memory in each zone"); + assert_eq!(shared[0].slot, 2); + assert_eq!(shared[1].slot, 64); + assert_eq!(shared[0].name, shared[1].name, "same channel, same name"); + + // 4. A list too big for one zone spills into the next rather than + // losing channels. 70 into zones of 64 is 64 + 6. + sqlx::query("INSERT INTO channel_lists (id, name) VALUES (13, 'BIG')") + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO channel_list_entries (channel_list_id, channel_id, position) + SELECT 13, id, id FROM channels WHERE id <= 70", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM codeplug_channel_lists WHERE codeplug_id = 1") + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO codeplug_channel_lists (codeplug_id, channel_list_id, position) + VALUES (1, 13, 0), (1, 11, 1)", + ) + .execute(&pool) + .await + .unwrap(); + + let (_model, big) = resolve_codeplug_zone_slots(&pool, 1, per_zone, max_zones) + .await + .expect("zone resolve"); + assert_eq!(big.slots.len(), 72, "70 in BIG + 2 in GMRS, none dropped"); + assert_eq!( + big.zones + .iter() + .map(|z| (z.number, z.channels)) + .collect::>(), + vec![(1, 64), (2, 6), (3, 2)], + "BIG fills zone 1 and spills 6 into zone 2; GMRS follows in zone 3" + ); + assert_eq!(big.slots[64].slot, 64, "the 65th channel is the first of zone 2"); + assert_eq!(big.slots[70].slot, 128, "GMRS still starts on a zone boundary"); + assert!( + big.warnings.iter().any(|w| w.contains("zones 1-2")), + "a list that needed two zones has to say so: {:?}", + big.warnings + ); + + // And a radio without a fixed-zone layout keeps the flat packing. + let sql = + format!("SELECT {MODEL_COLUMNS_PREFIXED} FROM radio_models rm WHERE rm.model = ?1"); + let model_named = |m: &str| { + sqlx::query_as::<_, RadioModel>(&sql).bind(m.to_string()).fetch_one(&pool) + }; + // Declares no zones at all. + assert!(fixed_zone_layout(&model_named("UV-5R").await.unwrap()).is_none()); + // ⚠ Declares zones AND both size columns — 250 of 160 — and must still + // be None. Its zones are records naming arbitrary slots, which its own + // driver writes; laying its channels out positionally would invent a + // constraint the radio does not have. The columns cannot tell these two + // radios apart, which is why this function asks the driver. + assert!(fixed_zone_layout(&model_named("AT-D890UV").await.unwrap()).is_none()); + + 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/commands/program.rs b/src-tauri/src/commands/program.rs index cd994fa..7eb991a 100644 --- a/src-tauri/src/commands/program.rs +++ b/src-tauri/src/commands/program.rs @@ -937,9 +937,33 @@ pub async fn program_radio( .estr()??; program_report_to_generic(report) } else if let Some(imager) = driver.as_image_programmer() { - // Included channels pack contiguously from slot 0; excluded (e.g. - // digital-mode) channels drop out and the rest close up behind them. - let (_model, slots) = export::resolve_codeplug_slots(&state.pool, codeplug_id).await?; + // How the channels land in memory. Two layouts, chosen by the model: + // + // * Fixed-zone radios (the BT-9000: 15 blocks of 64, a memory's zone is + // its index / 64) get one channel list per zone, so the zones the + // operator switches between on the radio ARE the codeplug's lists. + // Slots are deliberately NOT dense — the gap after a short list is + // what keeps the next list in its own zone. + // * Everything else packs contiguously from slot 0; excluded (e.g. + // digital-mode) channels drop out and the rest close up behind them. + let (slots, zones, mut layout_warnings) = + match export::fixed_zone_layout(&model) { + Some((per_zone, max_zones)) => { + let (_model, zoned) = export::resolve_codeplug_zone_slots( + &state.pool, + codeplug_id, + per_zone, + max_zones, + ) + .await?; + (zoned.slots, zoned.zones.len(), zoned.warnings) + } + None => { + let (_model, slots) = + export::resolve_codeplug_slots(&state.pool, codeplug_id).await?; + (slots, 0, Vec::new()) + } + }; // The radio profile's settings + the model's schema. Used by the // UV-5R and the BT-9000, whose settings live inside the image the @@ -998,6 +1022,11 @@ pub async fn program_radio( report .warnings .extend(crate::radios::settings_bounds::note_line(&dropped)); + // The zone layout is the command layer's, not the driver's: on these + // radios a zone is a range of slots, so the driver only ever saw + // channels at the positions this function chose for them. + report.zones_written = zones; + report.warnings.append(&mut layout_warnings); report } else { return Err(format!( diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index d7e5e3f..b2f55f5 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -418,6 +418,17 @@ pub struct ExportPreviewRow { pub reason: Option, } +/// One zone in the preview's zone map, for a radio whose zones are fixed blocks +/// of memories. The radio stores no zone names, so this map is the only place +/// the operator can learn that zone 2 is their GMRS list. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreviewZone { + /// 1-based zone number, as the radio's own zone selector shows it. + pub number: usize, + pub list_name: String, + pub channels: usize, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExportPreview { pub codeplug_id: i64, @@ -432,6 +443,18 @@ pub struct ExportPreview { /// the rows. pub receive_only_count: usize, pub rows: Vec, + /// The zone map, for radios that lay their memories out as fixed zone + /// blocks. Empty for everything else — including zone radios whose zones + /// are named records the driver writes (the AnyTone), because there the + /// zones are not a property of where the channels land. + /// + /// ⚠ A channel in two of the codeplug's lists appears in BOTH zones and is + /// programmed twice, so `zones` summed is the real memory count and + /// `included_count` (which dedups across lists) can be lower. + pub zones: Vec, + /// What the operator needs told about that layout: a list that outgrew one + /// zone, a list with nothing programmable in it, lists past the last zone. + pub zone_notes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index 2a831f7..9f8f0ca 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -325,8 +325,18 @@ fn models() -> Vec { // // 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. + // only in its own file. + // + // ⚠ `zones_supported` was false for that reason, on the argument + // that this app would be offering a name the radio cannot store. + // That confused the LABEL with the ZONE. The radio has fifteen of + // them and the operator switches between them; declaring otherwise + // did not withhold a name, it dumped every channel list into one + // undifferentiated run of memories. The flag now says what is true — + // fifteen zones of sixty-four — and `commands/export.rs` + // (`fixed_zone_layout`) reads those two numbers to lay one channel + // list into each zone. The names stay ours: the Program dialog shows + // the zone map, because the radio cannot. // -------------------------------------------------------- ModelSeed { manufacturer: "Binteradio", @@ -401,9 +411,9 @@ fn models() -> Vec { // 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, - channels_per_zone: None, + zones_supported: true, + max_zones: Some(15), + channels_per_zone: Some(64), // Per-channel scan-add flag (byte 15 bit 2), not named scan lists. scan_lists_supported: false, max_scan_lists: None, diff --git a/src/components/codeplugs/ProgramRadioDialog.tsx b/src/components/codeplugs/ProgramRadioDialog.tsx index 4539e40..9dc281f 100644 --- a/src/components/codeplugs/ProgramRadioDialog.tsx +++ b/src/components/codeplugs/ProgramRadioDialog.tsx @@ -229,7 +229,15 @@ export function ProgramRadioDialog({ }); }; - const writeCount = preview?.included_count ?? 0; + // Radios whose zones are fixed memory blocks (the BT-9000) get one channel + // list per zone, and a channel that is in two lists is programmed in each — + // so the memories written is the zone map's total, not the deduped + // `included_count`. Empty for every other model, which keeps `writeCount` + // exactly what it was. + const zones = preview?.zones ?? []; + const zoneNotes = preview?.zone_notes ?? []; + const zoneTotal = zones.reduce((n, z) => n + z.channels, 0); + const writeCount = zones.length > 0 ? zoneTotal : (preview?.included_count ?? 0); // ⚠ From the MODEL, not a constant. This was hard-coded to 128 for the UV-5R // and TD-H3, and this is the GENERIC dialog — the TH-D72 holds 1000, so the // confirm for a destructive write quoted a number that was simply wrong for @@ -514,8 +522,51 @@ export function ProgramRadioDialog({ · {skipped.length} skipped )} + {zones.length > 0 && ( + <> + {" "} + · {zones.length} zone + {zones.length === 1 ? "" : "s"} + + )} + {/* The zone map. Not decoration: these radios store no zone + names, so after the write the radio can only tell the + operator "zone 2" — this is the only place that says zone 2 + is their GMRS list. */} + {zones.length > 0 && ( +
+
+ Zones on the radio +
+
    + {zones.map((z) => ( +
  • + + + Zone {z.number} + {" "} + {z.list_name} + + + {z.channels} channel{z.channels === 1 ? "" : "s"} + +
  • + ))} +
+

+ {modelName} names no zones of its own — it shows them by + number, in this order. +

+
+ )} + + {zoneNotes.length > 0 && } + {receiveOnly.length > 0 && ( 0 + ? "These are dropped and the rest of their channel list closes up behind them, inside its own zone." + : "These are dropped and the remaining channels close up with no gaps." + } /> )} diff --git a/src/lib/types.ts b/src/lib/types.ts index 5489fe9..9ea57fc 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -455,6 +455,16 @@ export interface ExportPreviewRow { reason: string | null; } +/// One zone on a radio whose zones are fixed blocks of memories (the BT-9000: +/// 15 blocks of 64). The radio stores no zone names, so this map is the only +/// place the operator can learn which list landed in which zone. +export interface PreviewZone { + /// 1-based, as the radio's own zone selector shows it. + number: number; + list_name: string; + channels: number; +} + export interface ExportPreview { codeplug_id: number; radio_model: string; @@ -465,6 +475,12 @@ export interface ExportPreview { /// How many of `included_count` are receive-only. receive_only_count: number; rows: ExportPreviewRow[]; + /// The zone map, for fixed-zone radios; empty for every other model. + /// ⚠ A channel in two of the codeplug's lists lands in BOTH zones and is + /// programmed twice, so these summed can exceed `included_count`. + zones: PreviewZone[]; + /// What to tell the operator about that layout. + zone_notes: string[]; } // ---- direct radio programming (UV-5R) ---- From e5d4cbe21645780b686a3e247e717fd42d46c2aa Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 22:20:40 -0600 Subject: [PATCH 4/5] =?UTF-8?q?BT-9000:=20zones=20are=2099=20memories=20wi?= =?UTF-8?q?de,=20not=2064=20=E2=80=94=20measured=20on=20the=20radio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit laid one channel list into each zone using the geometry the manual gives ("up to 15 zones can be set, with 64 channels per zone") and the RT-950 Pro reference driver computes (`zone = index // 64 + 1`). Both are wrong. Programmed with them, a codeplug's second list went to memory 64 — which this radio calls zone 1, CHANNEL 65 — so the operator got one zone, which is exactly what he reported. Eleven memories were written and read back off the radio's own screen: | memory | radio says | |--------|-----------------| | 0 | zone 1 ch 1 | factory | 63 | zone 1 ch 64 | factory | 64 | zone 1 ch 65 | ⚠ kills the 64-wide model on its own | 99 | zone 2 ch 1 | stored by hand on the radio | 100 | zone 2 ch 2 | | 128 | zone 2 ch 30 | | 192 | zone 2 ch 94 | | 198 | zone 3 ch 1 | stored by hand on the radio | 296 | zone 3 ch 99 | PREDICTED, then confirmed | 297 | zone 4 ch 1 | PREDICTED, then confirmed | 959 | zone 10 ch 69 | PREDICTED, then confirmed So `zone_base = (zone - 1) * 99`, and there are ten zones, not fifteen. 99 is almost certainly the two-digit channel display. ⚠ 99 does not divide 960: nine zones of 99 and a tenth of 69. Anything that lays channels out by zone must ask each zone's capacity instead of multiplying, or it addresses memory 989 on a radio with 960 and `patch_image` drops those channels without a word. `ZoneLayout` now carries the memory total for exactly this, and a test drives the ragged edge through a 6-memory geometry so it arrives in four channels rather than nine hundred. Also ruled out along the way, so nobody re-derives them: zone membership is NOT in the channel record (memories 64 and 100 hold byte-identical records and sit in different zones), NOT in the 2 KB at 0x7800 that no published map describes (all 00/FF), and NOT anywhere in the clone image — creating a zone on the radio changed only VFO state, the settings shadow and a firmware journal. The vendor CPS cannot set zones either; it keeps 15 zone NAMES in its own .dat and no zone data in the radio. ⚠⚠ Hardware ladder step 3 ran against the wrong geometry in s128 and passed. It could not have failed: it wrote to computed addresses and compared the read-back to the same computation, never asking the radio what it called any of them. A check that closes the loop on itself proves the transport and nothing about the layout. Its doc now says so. The driver and the seed both said 64, so they agreed and nothing complained. `seed::tests::the_bt9000_zone_geometry_is_the_radios_own` holds the columns the layout actually reads to the constants that carry the measurement. Not yet programmed to the radio through the app. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- src-tauri/src/commands/export.rs | 240 ++++++++++++++---- src-tauri/src/commands/program.rs | 12 +- .../src/radios/binteradio_bt9000/hw_ladder.rs | 12 +- src-tauri/src/radios/binteradio_bt9000/mod.rs | 116 +++++++-- src-tauri/src/seed.rs | 49 +++- 5 files changed, 347 insertions(+), 82 deletions(-) diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index 11ddc55..b440f40 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -655,9 +655,9 @@ pub(crate) struct ZonedCodeplug { pub(crate) async fn resolve_codeplug_zone_slots( pool: &sqlx::SqlitePool, codeplug_id: i64, - per_zone: usize, - max_zones: usize, + layout: ZoneLayout, ) -> Result<(RadioModel, ZonedCodeplug), String> { + let ZoneLayout { per_zone, zones: max_zones, memories } = layout; let model = codeplug_model(pool, codeplug_id).await?; let groups = resolve_codeplug_groups(pool, codeplug_id).await?; @@ -698,6 +698,16 @@ pub(crate) async fn resolve_codeplug_zone_slots( let mut next_zone = 0usize; // 0-based let mut unplaced: Vec = Vec::new(); + // ⚠ Capacity is asked for, never multiplied. The last zone on these radios + // can be SHORT — the BT-9000's tenth holds 69 where the others hold 99, + // because 99 does not divide 960 — and `zone * per_zone + i` would place + // channels past the end of the memory, where the driver drops them without + // a word. + let capacity = |zone: usize| -> usize { + let base = zone * per_zone; + memories.saturating_sub(base).min(per_zone) + }; + for (list_name, ecs) in &lists { if ecs.is_empty() { warnings.push(format!( @@ -706,36 +716,42 @@ pub(crate) async fn resolve_codeplug_zone_slots( )); continue; } - let needed = ecs.len().div_ceil(per_zone); - if next_zone + needed > max_zones { - unplaced.push(format!("'{list_name}'")); + // How many zones this list needs, walking real capacities. + let (mut needed, mut room) = (0usize, 0usize); + while room < ecs.len() && next_zone + needed < max_zones { + room += capacity(next_zone + needed); + needed += 1; + } + if room < ecs.len() { + unplaced.push(format!("'{list_name}' ({} channels)", ecs.len())); continue; } let first_zone = next_zone; - for (i, ec) in ecs.iter().enumerate() { - let key = (ec.channel.id, ec.tg_number, ec.timeslot); - slots.push(SlotChannel { - slot: first_zone * per_zone + i, - name: names[name_of[&key]].clone(), - channel: ec.channel.clone(), - }); - } + let mut placed = 0usize; for z in 0..needed { - let taken = if z + 1 == needed { - ecs.len() - z * per_zone - } else { - per_zone - }; + let zone = first_zone + z; + let take = capacity(zone).min(ecs.len() - placed); + for i in 0..take { + let ec = &ecs[placed + i]; + let key = (ec.channel.id, ec.tg_number, ec.timeslot); + slots.push(SlotChannel { + slot: zone * per_zone + i, + name: names[name_of[&key]].clone(), + channel: ec.channel.clone(), + }); + } zones.push(ProgrammedZone { - number: first_zone + z + 1, + number: zone + 1, list_name: if needed == 1 { list_name.clone() } else { format!("{list_name} ({} of {needed})", z + 1) }, - channels: taken, + channels: take, }); + placed += take; } + debug_assert_eq!(placed, ecs.len()); if needed > 1 { warnings.push(format!( "'{list_name}' has {} channels and a zone holds {per_zone}, so it fills \ @@ -760,6 +776,19 @@ pub(crate) async fn resolve_codeplug_zone_slots( Ok((model, ZonedCodeplug { slots, zones, warnings })) } +/// A fixed-zone radio's memory geometry. +/// +/// `memories` is here because the last zone can be SHORT: the BT-9000 holds 960 +/// memories in zones of 99, so its tenth zone has 69. Carrying the total is what +/// lets the layout ask each zone's real capacity instead of assuming +/// `zones * per_zone` memories exist. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ZoneLayout { + pub per_zone: usize, + pub zones: usize, + pub memories: usize, +} + /// Whether `model` lays its memories out as fixed zone blocks, and how big /// they are. `Some((per_zone, max_zones))` selects /// [`resolve_codeplug_zone_slots`] over the flat [`resolve_codeplug_slots`]. @@ -775,20 +804,23 @@ pub(crate) async fn resolve_codeplug_zone_slots( /// nothing but slots and has no zone table anywhere to write, so where a /// channel lands IS its zone; a radio with a [`CodeplugProgrammer`] writes real /// zone records and owns the mapping itself. -pub(crate) fn fixed_zone_layout(model: &RadioModel) -> Option<(usize, usize)> { +pub(crate) fn fixed_zone_layout(model: &RadioModel) -> Option { if !model.zones_supported { return None; } - let (per, max) = match (model.channels_per_zone, model.max_zones) { - (Some(per), Some(max)) if per > 0 && max > 0 => (per as usize, max as usize), - _ => return None, - }; + let (per_zone, zones, memories) = + match (model.channels_per_zone, model.max_zones, model.memory_channels) { + (Some(per), Some(max), Some(mem)) if per > 0 && max > 0 && mem > 0 => { + (per as usize, max as usize, mem as usize) + } + _ => return None, + }; let driver = crate::radios::registry::driver_for_model(model)?; if driver.as_codeplug_programmer().is_some() { return None; } driver.as_image_programmer()?; - Some((per, max)) + Some(ZoneLayout { per_zone, zones, memories }) } #[tauri::command] @@ -858,9 +890,9 @@ pub async fn export_preview( // decided by `resolve_codeplug_zone_slots`, and a preview that works it out // a second way is a preview that can disagree with the write. let (zones, zone_notes) = match fixed_zone_layout(&model) { - Some((per_zone, max_zones)) => { + Some(layout) => { let (_model, zoned) = - resolve_codeplug_zone_slots(&state.pool, codeplug_id, per_zone, max_zones).await?; + resolve_codeplug_zone_slots(&state.pool, codeplug_id, layout).await?; let zones = zoned .zones .into_iter() @@ -1715,9 +1747,9 @@ mod tests { let _ = std::fs::remove_file(&db_path); let pool = crate::db::init_pool(&db_path).await.expect("init_pool"); - // 70 programmable 2 m channels, so one list can be made to outgrow a - // 64-memory zone, plus one the radio cannot use at all. - for i in 1..=70i64 { + // 150 programmable 2 m channels, so one list can be made to outgrow a + // 99-memory zone, plus one the radio cannot use at all. + for i in 1..=150i64 { sqlx::query( "INSERT INTO channels (id, name_long, name_short, rx_freq, mode, source) VALUES (?1, ?2, ?3, ?4, 'FM', 'manual')", @@ -1732,7 +1764,7 @@ mod tests { } sqlx::query( "INSERT INTO channels (id, name_long, name_short, rx_freq, mode, source) - VALUES (99, 'Way Out', 'OUT', 1200.0, 'FM', 'manual')", + VALUES (999, 'Way Out', 'OUT', 1200.0, 'FM', 'manual')", ) .execute(&pool) .await @@ -1750,7 +1782,7 @@ mod tests { "INSERT INTO channel_list_entries (channel_list_id, channel_id, position) VALUES (10, 1, 0), (10, 2, 1), (10, 3, 2), (11, 3, 0), (11, 4, 1), - (12, 99, 0)", + (12, 999, 0)", ) .execute(&pool) .await @@ -1784,11 +1816,17 @@ mod tests { // The model declares the layout; the resolver is chosen from it, not // from the driver key. let model = codeplug_model(&pool, 1).await.unwrap(); - let (per_zone, max_zones) = - fixed_zone_layout(&model).expect("the BT-9000 declares 15 zones of 64"); - assert_eq!((per_zone, max_zones), (64, 15)); + // ★★★ 99 and 10, MEASURED on the radio in s130 — not the manual's 64 + // and 15, which is what this test asserted when it was written and what + // put a channel list inside zone 1 on Tim's radio. + let layout = fixed_zone_layout(&model).expect("the BT-9000 declares its zones"); + assert_eq!( + (layout.per_zone, layout.zones, layout.memories), + (99, 10, 960), + "zones of 99, ten of them, over 960 memories" + ); - let (_model, zoned) = resolve_codeplug_zone_slots(&pool, 1, per_zone, max_zones) + let (_model, zoned) = resolve_codeplug_zone_slots(&pool, 1, layout) .await .expect("zone resolve"); @@ -1807,10 +1845,12 @@ mod tests { zoned.warnings ); - // 2. The slots are NOT dense: zone 2 starts at memory 64. That gap is - // the feature — it is the only thing that makes zone 2 the GMRS list. + // 2. The slots are NOT dense: zone 2 starts at memory 99. That gap is + // the feature — it is the only thing that makes zone 2 the GMRS + // list. ⚠ 99, not 64: memory 64 is zone 1 CHANNEL 65 on this radio, + // which is exactly how the first attempt at this produced one zone. let slots: Vec = zoned.slots.iter().map(|s| s.slot).collect(); - assert_eq!(slots, vec![0, 1, 2, 64, 65]); + assert_eq!(slots, vec![0, 1, 2, 99, 100]); // 3. A channel in two lists is programmed TWICE, once in each zone, // because membership here is position and nothing else. And it keeps @@ -1821,7 +1861,7 @@ mod tests { zoned.slots.iter().filter(|s| s.channel.id == 3).collect(); assert_eq!(shared.len(), 2, "a shared channel needs a memory in each zone"); assert_eq!(shared[0].slot, 2); - assert_eq!(shared[1].slot, 64); + assert_eq!(shared[1].slot, 99); assert_eq!(shared[0].name, shared[1].name, "same channel, same name"); // 4. A list too big for one zone spills into the next rather than @@ -1832,7 +1872,7 @@ mod tests { .unwrap(); sqlx::query( "INSERT INTO channel_list_entries (channel_list_id, channel_id, position) - SELECT 13, id, id FROM channels WHERE id <= 70", + SELECT 13, id, id FROM channels WHERE id <= 150", ) .execute(&pool) .await @@ -1849,20 +1889,20 @@ mod tests { .await .unwrap(); - let (_model, big) = resolve_codeplug_zone_slots(&pool, 1, per_zone, max_zones) + let (_model, big) = resolve_codeplug_zone_slots(&pool, 1, layout) .await .expect("zone resolve"); - assert_eq!(big.slots.len(), 72, "70 in BIG + 2 in GMRS, none dropped"); + assert_eq!(big.slots.len(), 152, "150 in BIG + 2 in GMRS, none dropped"); assert_eq!( big.zones .iter() .map(|z| (z.number, z.channels)) .collect::>(), - vec![(1, 64), (2, 6), (3, 2)], - "BIG fills zone 1 and spills 6 into zone 2; GMRS follows in zone 3" + vec![(1, 99), (2, 51), (3, 2)], + "BIG fills zone 1 and spills 51 into zone 2; GMRS follows in zone 3" ); - assert_eq!(big.slots[64].slot, 64, "the 65th channel is the first of zone 2"); - assert_eq!(big.slots[70].slot, 128, "GMRS still starts on a zone boundary"); + assert_eq!(big.slots[99].slot, 99, "the 100th channel is the first of zone 2"); + assert_eq!(big.slots[150].slot, 198, "GMRS still starts on a zone boundary"); assert!( big.warnings.iter().any(|w| w.contains("zones 1-2")), "a list that needed two zones has to say so: {:?}", @@ -1887,6 +1927,110 @@ mod tests { let _ = std::fs::remove_file(&db_path); } + /// ★★ The LAST zone can be short, and the layout must ask rather than + /// multiply. + /// + /// The BT-9000 holds 960 memories in zones of 99, so its tenth zone has 69. + /// `zone * per_zone + i` would place a channel at memory 990 — past the end + /// of the image, where `patch_image` skips it without a word. That is the + /// one failure mode of this layout that produces no error, no warning and + /// no channel. + /// + /// Run against a tiny hand-made geometry rather than the real one so the + /// ragged edge arrives in four channels instead of nine hundred. + #[tokio::test] + async fn the_last_zone_can_be_short() { + let dir = std::env::temp_dir().join(format!("cpm_ragged_{}", 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"); + + for i in 1..=6i64 { + sqlx::query( + "INSERT INTO channels (id, name_long, name_short, rx_freq, mode, source) + VALUES (?1, ?2, ?3, ?4, 'FM', 'manual')", + ) + .bind(i) + .bind(format!("Chan {i}")) + .bind(format!("C{i}")) + .bind(145.0 + (i as f64) * 0.01) + .execute(&pool) + .await + .unwrap(); + } + sqlx::query("INSERT INTO channel_lists (id, name) VALUES (10, 'ONE'), (11, 'TWO')") + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO channel_list_entries (channel_list_id, channel_id, position) + VALUES (10, 1, 0), (10, 2, 1), (11, 3, 0), (11, 4, 1), (11, 5, 2)", + ) + .execute(&pool) + .await + .unwrap(); + let model_id: (i64,) = + sqlx::query_as("SELECT id FROM radio_models WHERE model = 'BT-9000'") + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO radio_profiles (id, display_name, radio_model_id) VALUES (1, 'p', ?1)", + ) + .bind(model_id.0) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO codeplugs (id, name, radio_profile_id) VALUES (1, 'T', 1)") + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO codeplug_channel_lists (codeplug_id, channel_list_id, position) + VALUES (1, 10, 0), (1, 11, 1)", + ) + .execute(&pool) + .await + .unwrap(); + + // Two zones of 4, but only 6 memories: zone 2 holds 2, not 4. + let ragged = ZoneLayout { per_zone: 4, zones: 2, memories: 6 }; + let (_m, z) = resolve_codeplug_zone_slots(&pool, 1, ragged).await.unwrap(); + + // ONE takes zone 1. TWO has three channels and zone 2 holds two, so it + // does not fit at all — and it is REFUSED with a warning naming it, + // rather than half-written into memories 4 and 5 with the third + // vanishing past the end. + assert_eq!( + z.zones.iter().map(|x| (x.number, x.channels)).collect::>(), + vec![(1, 2)] + ); + assert_eq!(z.slots.iter().map(|s| s.slot).collect::>(), vec![0, 1]); + assert!( + z.warnings.iter().any(|w| w.contains("TWO")), + "a list that did not fit must be named: {:?}", + z.warnings + ); + assert!( + z.slots.iter().all(|s| s.slot < ragged.memories), + "nothing may be placed past the end of memory" + ); + + // Drop one channel from TWO and it fits the short zone exactly. + sqlx::query("DELETE FROM channel_list_entries WHERE channel_list_id = 11 AND channel_id = 5") + .execute(&pool) + .await + .unwrap(); + let (_m, z) = resolve_codeplug_zone_slots(&pool, 1, ragged).await.unwrap(); + assert_eq!( + z.zones.iter().map(|x| (x.number, x.channels)).collect::>(), + vec![(1, 2), (2, 2)] + ); + assert_eq!(z.slots.iter().map(|s| s.slot).collect::>(), vec![0, 1, 4, 5]); + + 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/commands/program.rs b/src-tauri/src/commands/program.rs index 7eb991a..e856e16 100644 --- a/src-tauri/src/commands/program.rs +++ b/src-tauri/src/commands/program.rs @@ -948,14 +948,10 @@ pub async fn program_radio( // digital-mode) channels drop out and the rest close up behind them. let (slots, zones, mut layout_warnings) = match export::fixed_zone_layout(&model) { - Some((per_zone, max_zones)) => { - let (_model, zoned) = export::resolve_codeplug_zone_slots( - &state.pool, - codeplug_id, - per_zone, - max_zones, - ) - .await?; + Some(layout) => { + let (_model, zoned) = + export::resolve_codeplug_zone_slots(&state.pool, codeplug_id, layout) + .await?; (zoned.slots, zoned.zones.len(), zoned.warnings) } None => { diff --git a/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs b/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs index ea87c01..0395651 100644 --- a/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs +++ b/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs @@ -102,8 +102,14 @@ fn program(slots: &[SlotChannel], tag: &str) -> Vec { /// /// 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 +/// index arithmetic, so a channel in the first and last slot of each of the 10 /// zones proves the whole 960-slot map at once. +/// +/// ⚠ This ran and PASSED in s128 against the wrong geometry — 15 zones of 64 — +/// and could not have caught it: it wrote to computed addresses and compared +/// the read-back against the same computation. The radio was never asked what +/// it called any of them. A check that closes the loop on itself proves the +/// transport and nothing about the layout. #[test] #[ignore = "writes to a real BT-9000 on the cable"] fn step3_full_codeplug_reaches_every_zone() { @@ -114,7 +120,9 @@ fn step3_full_codeplug_reaches_every_zone() { // 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, + // Capacity, not `CHANNELS_PER_ZONE`: the tenth zone is 69 long, and + // multiplying would address memory 989 on a radio with 960. + base + zone_capacity(zone + 1) - 1, &format!("Z{:02}LAST", zone + 1), 440.0 + zone as f64 * 0.1, 440.0 + zone as f64 * 0.1, diff --git a/src-tauri/src/radios/binteradio_bt9000/mod.rs b/src-tauri/src/radios/binteradio_bt9000/mod.rs index 6f90781..a57fbb3 100644 --- a/src-tauri/src/radios/binteradio_bt9000/mod.rs +++ b/src-tauri/src/radios/binteradio_bt9000/mod.rs @@ -8,9 +8,15 @@ //! 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. +//! 960 channels in **10 zones of 99** — nine full ones and a tenth holding 69. +//! Zones have **no names in the radio**: membership is `index / 99`, and the +//! vendor CPS keeps zone labels only in its own `.dat` file. There is nowhere +//! in the clone image to put them. +//! +//! ⚠ The manual says fifteen zones of sixty-four, and so does the RT-950 Pro +//! reference driver. Both are wrong; see [`CHANNELS_PER_ZONE`] for the eleven +//! memories the radio was asked about. This is the fourth published claim about +//! this radio that its own screen has refuted. //! //! ## Protocol //! @@ -92,13 +98,54 @@ 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 +/// ★★★ MEASURED ON THE RADIO (s130). **Not 64**, which is what the manual says +/// ("up to 15 zones can be set, with 64 channels per zone") and what the +/// RT-950 Pro reference driver computes (`zone = index // 64 + 1`). Both are +/// wrong for this radio, and believing them is what shipped a codeplug whose +/// second channel list landed inside zone 1. +/// +/// How it was settled: markers were written at known memories and the radio was +/// asked what it called each. Eleven points, one model, no exceptions — +/// including three written as PREDICTIONS before the radio was asked. +/// +/// | memory | radio says | | +/// |---|---|---| +/// | 0 | zone 1 ch 1 | factory | +/// | 63 | zone 1 ch 64 | factory | +/// | 64 | zone 1 ch **65** | ⚠ kills the 64-wide model on its own | +/// | 99 | zone 2 ch 1 | stored on the radio by hand | +/// | 100, 128, 192 | zone 2 ch 2, 30, 94 | | +/// | 198 | zone 3 ch 1 | stored on the radio by hand | +/// | 296 | zone 3 ch 99 | predicted, then confirmed | +/// | 297 | zone 4 ch 1 | predicted, then confirmed | +/// | 959 | zone 10 ch 69 | predicted, then confirmed | +/// +/// So `channel = memory - zone_base + 1`, `zone_base = (zone - 1) * 99`. 99 is +/// almost certainly the two-digit channel display. +pub(crate) const CHANNELS_PER_ZONE: usize = 99; + +/// ⚠ 99 does NOT divide 960: there are nine full zones and a **tenth holding +/// 69**, which the radio confirmed at memory 959. Anything laying channels out +/// by zone has to ask each zone's real capacity rather than multiply, or it +/// places channels past the end of memory, where `patch_image` skips them +/// without a word. +/// +/// `#[cfg(test)]` on both: the shipping path here does not need them, because +/// this driver writes by SLOT and never thinks in zones, and the layout that +/// does — `commands/export.rs` — takes its geometry from the model's own +/// columns so it can serve any fixed-zone radio. These carry the measurement +/// and hold the seed to it, which is exactly the disagreement that caused all +/// this. +#[cfg(test)] +pub(crate) const ZONE_COUNT: usize = CHANNEL_COUNT.div_ceil(CHANNELS_PER_ZONE); // 10 -/// 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); +/// How many memories zone `number` (1-based) actually holds. Every zone but the +/// last holds [`CHANNELS_PER_ZONE`]; the last is short. +#[cfg(test)] +pub(crate) fn zone_capacity(number: usize) -> usize { + let base = (number - 1) * CHANNELS_PER_ZONE; + CHANNEL_COUNT.saturating_sub(base).min(CHANNELS_PER_ZONE) +} // ============================================================ // Segment tables @@ -1453,17 +1500,48 @@ mod tests { assert!(validate_image(&vec![0u8; 0x10000]).is_err()); } + /// ★★★ The zone geometry, against the memories the RADIO was asked about. + /// + /// Every row here is a memory that was put on Tim's BT-9000 and read back + /// off its own screen in s130 — not a re-derivation of the constant. The + /// previous version of this test asserted memory 64 was zone 2, which is + /// what the manual and the reference driver both say and what the radio + /// flatly denies: it calls that memory zone 1, channel 65. #[test] - fn zones_are_positional_only() { - let mut records = Vec::new(); - for slot in [0usize, 63, 64, 959] { - records.push((slot, RADIO_CH1)); + fn the_zone_geometry_is_the_radios_own() { + // (memory, zone, channel-within-zone) — all measured, none derived. + const MEASURED: [(usize, usize, usize); 11] = [ + (0, 1, 1), + (63, 1, 64), + (64, 1, 65), + (98, 1, 99), + (99, 2, 1), + (100, 2, 2), + (128, 2, 30), + (192, 2, 94), + (198, 3, 1), + (297, 4, 1), + (959, 10, 69), + ]; + let image = image_with(&MEASURED.map(|(slot, _, _)| (slot, RADIO_CH1))); + let decoded = decode_channels(&image); + assert_eq!(decoded.len(), MEASURED.len()); + for (d, (mem, zone, channel)) in decoded.iter().zip(MEASURED) { + assert_eq!(d.index, mem); + assert_eq!(d.zone, zone, "memory {mem}"); + assert_eq!(mem - (zone - 1) * CHANNELS_PER_ZONE + 1, channel, "memory {mem}"); } - 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); + + // 99 does not divide 960, so the last zone is short — and the radio + // said so itself: memory 959 is zone 10 channel 69, not zone 10 + // channel 99 and not zone 15 anything. + assert_eq!(ZONE_COUNT, 10); + assert_eq!(zone_capacity(1), 99); + assert_eq!(zone_capacity(ZONE_COUNT), 69); + assert_eq!( + (1..=ZONE_COUNT).map(zone_capacity).sum::(), + CHANNEL_COUNT, + "every memory belongs to exactly one zone" + ); } } diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index 9f8f0ca..d82df45 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -323,9 +323,17 @@ fn models() -> Vec { // 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. + // 960 channels in FIXED zones of 99: nine full ones and a tenth + // holding 69. Zones carry no names in the radio — membership is + // index/99 and the vendor CPS keeps labels only in its own file. + // + // ⚠⚠ NOT the manual's "15 zones ... 64 channels per zone", and not + // the RT-950 Pro reference driver's `index // 64` either. Both are + // wrong, and this app shipped a codeplug on them: the second channel + // list went to memory 64, which the radio calls zone 1 CHANNEL 65, + // so the operator got one zone. Eleven memories were written and + // read back off the radio's own screen to settle it — three of them + // as predictions. See `CHANNELS_PER_ZONE` in the driver. // // ⚠ `zones_supported` was false for that reason, on the argument // that this app would be offering a name the radio cannot store. @@ -412,8 +420,8 @@ fn models() -> Vec { rx_bands: Some("[[18.0,520.0]]"), memory_channels: 960, zones_supported: true, - max_zones: Some(15), - channels_per_zone: Some(64), + max_zones: Some(10), + channels_per_zone: Some(99), // Per-channel scan-add flag (byte 15 bit 2), not named scan lists. scan_lists_supported: false, max_scan_lists: None, @@ -1086,6 +1094,37 @@ mod tests { assert!(covers(&tx, 146.52) && covers(&tx, 446.0)); } + /// ★★★ The BT-9000's zone geometry, seeded from what the RADIO says. + /// + /// These three columns are what `commands/export.rs::fixed_zone_layout` + /// reads, so they alone decide which memory each channel lands in — the + /// driver writes by slot and never thinks in zones at all. + /// + /// They read 15 and 64 until s130, matching the manual and the RT-950 Pro + /// reference driver, and the driver's own constants agreed with them. Being + /// wrong TOGETHER is why nothing caught it: a codeplug's second channel + /// list went to memory 64, the radio calls that memory zone 1 channel 65, + /// and the operator got one zone. Eleven memories were then written and + /// read back off the radio's screen — three of them as predictions — and + /// they give 99 and 10. + #[test] + fn the_bt9000_zone_geometry_is_the_radios_own() { + let m = models().into_iter().find(|m| m.model == "BT-9000").unwrap(); + assert!(m.zones_supported); + assert_eq!(m.channels_per_zone, Some(99), "zones are 99 wide, NOT the manual's 64"); + assert_eq!(m.max_zones, Some(10), "ten zones, NOT the manual's 15"); + assert_eq!(m.memory_channels, 960); + // ⚠ 99 does not divide 960. Nine full zones and a tenth of 69 — which + // is the radio's own answer for its last memory, 959: zone 10 ch 69. + let per = m.channels_per_zone.unwrap(); + let last = m.memory_channels - (m.max_zones.unwrap() - 1) * per; + assert_eq!(last, 69, "the last zone is short, and anything that multiplies loses it"); + assert_eq!( + crate::radios::binteradio_bt9000::CHANNELS_PER_ZONE as i64, per, + "the driver decodes a memory's zone with this too; the two must not drift" + ); + } + /// A gap in a seeded receiver is not a detail — it decides whether a channel /// is programmed or refused, and the radio does NOT refuse it politely. /// From 64a447b1027a1e895a8be8e5d1f34d3161485c8a Mon Sep 17 00:00:00 2001 From: ww8l Date: Wed, 2 Sep 2026 22:37:41 -0600 Subject: [PATCH 5/5] BT-9000: a codeplug that fits nowhere was a wipe, not a refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the branch. The zone layout introduced a regression with the worst possible failure mode, and six smaller problems. ★★★ THE ONE THAT MATTERS. `program_codeplug` refuses a codeplug with more channels than the radio holds. After the zone layout, an over-capacity codeplug no longer arrives as an over-long slot list — it arrives as an EMPTY one, because every list was refused. So the guard could not fire, `patch_image` filled all 960 records with 0xFF, and the radio came back blank, having been told it now "matches" the codeplug. The dialog helped: `zones.length > 0` meant both "not a fixed-zone radio" and "fixed-zone radio, nothing placed", so it fell back to the flat channel count and offered to write 961 channels that were not there. Three parts to the fix, because one is not enough: * `program_radio` refuses outright when the layout placed nothing and the codeplug wanted something — before the port is opened, so the mandatory pre-write backup is not the thing standing between the operator and an empty radio; * `ExportPreview` declares `fixed_zones` instead of leaving the frontend to infer it from a list that is empty in two different situations; * `ZonedCodeplug` reports the channels it REFUSED, and the preview marks those rows excluded. They were listed `included: true` with no reason, so the channel table promised to program channels the write was going to leave behind, and only the prose note disagreed. Also: * the confirm said "to slots 1-N", which is exactly what fixed-zone placement is not — 5 channels in two lists land in memories 1-3 and 100-101. It names zones now. That sentence is the last thing read before a destructive write. * `zones_written` was set by the command layer and read by nothing on the generic screen (#87's shape again), so the zone map was promised before the write and never confirmed after it. Now in the result. * `a_bt9000_codeplug_reaches_the_image_through_the_apps_own_pipeline` asserted "slots must be dense from 0" through a resolver production no longer calls for this radio — dense-from-0 IS the bug. It goes through `fixed_zone_layout` now, like `program_radio` does. * five stale "15 zones of 64" comments, including one in the seed that said "the flag now says what is true — fifteen zones of sixty-four" three lines above `Some(10)` and `Some(99)`, inside the block whose subject is that 15/64 was measured wrong. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUipy7p4gqmziJmxKnjmJM --- src-tauri/src/commands/export.rs | 181 ++++++++++++++++-- src-tauri/src/commands/program.rs | 35 +++- src-tauri/src/models.rs | 8 + src-tauri/src/radios/binteradio_bt9000/mod.rs | 4 +- src-tauri/src/seed.rs | 16 +- .../codeplugs/ProgramRadioDialog.tsx | 31 ++- src/lib/types.ts | 6 +- 7 files changed, 245 insertions(+), 36 deletions(-) diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index b440f40..f5a03c4 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -601,9 +601,9 @@ pub(crate) async fn resolve_codeplug_slots( /// One zone as it will exist on a radio whose zones are FIXED BLOCKS of /// memories rather than named lists of them. /// -/// The BT-9000 is the shape this exists for: 15 zones of 64, and a memory's -/// zone is `index / 64` with no zone record and no zone name stored anywhere on -/// the radio. So "put this channel list in its own zone" is not a table to +/// The BT-9000 is the shape this exists for: 10 zones of 99 (the last one 69 +/// long), and a memory's zone is `index / 99` with no zone record and no zone +/// name stored anywhere on the radio. So "put this channel list in its own zone" is not a table to /// write — it is a decision about which memory slots the channels land in, and /// it belongs here in the resolver rather than in a driver, which sees slots. /// @@ -631,6 +631,11 @@ pub(crate) struct ZonedCodeplug { /// zone, a list with nothing programmable in it, lists past the radio's /// last zone. pub warnings: Vec, + /// Channels that this layout could NOT place: they are in a list that got + /// no zone, and in no list that did. The preview marks them excluded, so + /// its channel table cannot say "will be programmed" about a channel the + /// write is going to leave behind. + pub refused: Vec, } /// Resolve a codeplug into a fixed-zone radio's memory: one channel list per @@ -697,6 +702,7 @@ pub(crate) async fn resolve_codeplug_zone_slots( let mut warnings: Vec = Vec::new(); let mut next_zone = 0usize; // 0-based let mut unplaced: Vec = Vec::new(); + let mut refused: Vec = Vec::new(); // ⚠ Capacity is asked for, never multiplied. The last zone on these radios // can be SHORT — the BT-9000's tenth holds 69 where the others hold 99, @@ -724,6 +730,7 @@ pub(crate) async fn resolve_codeplug_zone_slots( } if room < ecs.len() { unplaced.push(format!("'{list_name}' ({} channels)", ecs.len())); + refused.extend(ecs.iter().map(|ec| ec.channel.id)); continue; } let first_zone = next_zone; @@ -773,7 +780,14 @@ pub(crate) async fn resolve_codeplug_zone_slots( )); } - Ok((model, ZonedCodeplug { slots, zones, warnings })) + // A channel refused in one list but placed in another IS programmed, so it + // is not refused at all. + let placed: HashSet = slots.iter().map(|s| s.channel.id).collect(); + refused.retain(|id| !placed.contains(id)); + refused.sort_unstable(); + refused.dedup(); + + Ok((model, ZonedCodeplug { slots, zones, warnings, refused })) } /// A fixed-zone radio's memory geometry. @@ -889,11 +903,11 @@ pub async fn export_preview( // here rather than derived in the UI: which list lands in which zone is // decided by `resolve_codeplug_zone_slots`, and a preview that works it out // a second way is a preview that can disagree with the write. - let (zones, zone_notes) = match fixed_zone_layout(&model) { + let (fixed_zones, zones, zone_notes, refused) = match fixed_zone_layout(&model) { Some(layout) => { let (_model, zoned) = resolve_codeplug_zone_slots(&state.pool, codeplug_id, layout).await?; - let zones = zoned + let zones: Vec = zoned .zones .into_iter() .map(|z| PreviewZone { @@ -902,11 +916,31 @@ pub async fn export_preview( channels: z.channels, }) .collect(); - (zones, zoned.warnings) + (true, zones, zoned.warnings, zoned.refused) } - None => (Vec::new(), Vec::new()), + None => (false, Vec::new(), Vec::new(), Vec::new()), }; + // ⚠ A channel the zone layout could not place is NOT going to be + // programmed, and the rows above were built by the flat expansion, which + // knows nothing about zones — so it had them marked `included` with no + // reason. The prose note named the list; the table contradicted it. + if !refused.is_empty() { + let refused: HashSet = refused.into_iter().collect(); + for row in rows.iter_mut().filter(|r| r.included) { + if refused.contains(&row.channel_id) { + row.included = false; + row.receive_only = false; + row.reason = Some( + "its channel list did not fit in the radio's zones — see the note above" + .to_string(), + ); + included -= 1; + excluded += 1; + } + } + } + Ok(ExportPreview { codeplug_id, radio_model: model.display_name, @@ -915,6 +949,7 @@ pub async fn export_preview( excluded_count: excluded, receive_only_count: receive_only, rows, + fixed_zones, zones, zone_notes, }) @@ -1648,8 +1683,19 @@ mod tests { .await .unwrap(); - let (model, slots) = resolve_codeplug_slots(&pool, 1).await.expect("resolve slots"); + // ⚠ Through the layout `program_radio` ACTUALLY uses. This called + // `resolve_codeplug_slots` and asserted "slots are dense from 0", which + // stopped being the app's behaviour the day this radio's zones were + // honoured — dense-from-0 is precisely the bug that put a second + // channel list inside zone 1. A test named for the app's own pipeline + // has to walk the app's own pipeline. + let model = codeplug_model(&pool, 1).await.unwrap(); assert_eq!(model.driver_key.as_deref(), Some("binteradio_bt9000")); + let layout = fixed_zone_layout(&model).expect("the BT-9000 is a fixed-zone radio"); + let (_model, zoned) = resolve_codeplug_zone_slots(&pool, 1, layout) + .await + .expect("resolve slots"); + let slots = zoned.slots; // 6 in, 5 out. Each of the three verdicts is represented, and all three // come from frequencies keyed on the real radio in s128: @@ -1677,10 +1723,11 @@ mod tests { "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 - // anything on a radio with no zone names. + // One list, so one zone: dense from 0 HERE because zone 1's base is 0, + // not because packing is dense. A second list would start at 99. + assert_eq!(zoned.zones.len(), 1); for (i, s) in slots.iter().enumerate() { - assert_eq!(s.slot, i, "slots must be dense from 0"); + assert_eq!(s.slot, i, "the first zone starts at memory 0"); assert!( s.name.chars().count() <= 12, "{:?} is longer than the radio's 12-character field", @@ -1865,7 +1912,7 @@ mod tests { assert_eq!(shared[0].name, shared[1].name, "same channel, same name"); // 4. A list too big for one zone spills into the next rather than - // losing channels. 70 into zones of 64 is 64 + 6. + // losing channels. 150 into zones of 99 is 99 + 51. sqlx::query("INSERT INTO channel_lists (id, name) VALUES (13, 'BIG')") .execute(&pool) .await @@ -1927,6 +1974,114 @@ mod tests { let _ = std::fs::remove_file(&db_path); } + /// ★★★ A codeplug that cannot be laid out places NOTHING, and nothing is + /// not a codeplug — it is a wipe. + /// + /// Before the zone layout existed, an over-capacity codeplug was refused by + /// the driver: `program_codeplug` compares `req.channels.len()` against the + /// radio's memory count. After it, the same codeplug arrives as an EMPTY + /// slot list rather than an over-long one, so that guard cannot fire — + /// `patch_image` would fill all 960 records with 0xFF and the radio would + /// come back blank, having been told it now "matches" the codeplug. + /// + /// This pins the two halves of the fix: the resolver reports the channels + /// it refused, and it is possible to tell "nothing to place" apart from + /// "nothing was asked for". + #[tokio::test] + async fn a_codeplug_that_fits_nowhere_is_refused_rather_than_written_as_empty() { + let dir = std::env::temp_dir().join(format!("cpm_nofit_{}", 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"); + + for i in 1..=5i64 { + sqlx::query( + "INSERT INTO channels (id, name_long, name_short, rx_freq, mode, source) + VALUES (?1, ?2, ?3, ?4, 'FM', 'manual')", + ) + .bind(i) + .bind(format!("Chan {i}")) + .bind(format!("C{i}")) + .bind(145.0 + (i as f64) * 0.01) + .execute(&pool) + .await + .unwrap(); + } + sqlx::query("INSERT INTO channel_lists (id, name) VALUES (10, 'TOO BIG')") + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO channel_list_entries (channel_list_id, channel_id, position) + SELECT 10, id, id FROM channels", + ) + .execute(&pool) + .await + .unwrap(); + let model_id: (i64,) = + sqlx::query_as("SELECT id FROM radio_models WHERE model = 'BT-9000'") + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO radio_profiles (id, display_name, radio_model_id) VALUES (1, 'p', ?1)", + ) + .bind(model_id.0) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO codeplugs (id, name, radio_profile_id) VALUES (1, 'T', 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(); + + // A radio with four memories, and one list of five: it fits nowhere. + let tiny = ZoneLayout { per_zone: 2, zones: 2, memories: 4 }; + let (_m, z) = resolve_codeplug_zone_slots(&pool, 1, tiny).await.unwrap(); + assert!(z.slots.is_empty(), "nothing could be placed"); + assert!(z.zones.is_empty()); + assert_eq!(z.refused, vec![1, 2, 3, 4, 5], "every channel is reported refused"); + assert!(z.warnings.iter().any(|w| w.contains("TOO BIG"))); + + // ⚠ And the caller can tell that from "the codeplug is empty", which is + // the distinction the guard in `program_radio` turns on: here the flat + // resolution still has five channels wanting to go somewhere. + let (_m, wanted) = resolve_codeplug_slots(&pool, 1).await.unwrap(); + assert_eq!(wanted.len(), 5); + + // A channel refused in one list but PLACED in another is not refused. + sqlx::query("INSERT INTO channel_lists (id, name) VALUES (11, 'FITS')") + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO channel_list_entries (channel_list_id, channel_id, position) + VALUES (11, 3, 0)", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO codeplug_channel_lists (codeplug_id, channel_list_id, position) + VALUES (1, 11, 1)", + ) + .execute(&pool) + .await + .unwrap(); + let (_m, z) = resolve_codeplug_zone_slots(&pool, 1, tiny).await.unwrap(); + assert_eq!(z.slots.len(), 1, "FITS still gets its zone"); + assert_eq!(z.refused, vec![1, 2, 4, 5], "channel 3 is programmed, so not refused"); + + let _ = std::fs::remove_file(&db_path); + } + /// ★★ The LAST zone can be short, and the layout must ask rather than /// multiply. /// diff --git a/src-tauri/src/commands/program.rs b/src-tauri/src/commands/program.rs index e856e16..852e77a 100644 --- a/src-tauri/src/commands/program.rs +++ b/src-tauri/src/commands/program.rs @@ -939,26 +939,45 @@ pub async fn program_radio( } else if let Some(imager) = driver.as_image_programmer() { // How the channels land in memory. Two layouts, chosen by the model: // - // * Fixed-zone radios (the BT-9000: 15 blocks of 64, a memory's zone is - // its index / 64) get one channel list per zone, so the zones the - // operator switches between on the radio ARE the codeplug's lists. + // * Fixed-zone radios (the BT-9000: 10 blocks of 99, the last one 69 + // long, and a memory's zone is its index / 99) get one channel list + // per zone, so the zones the operator switches between on the radio + // ARE the codeplug's lists. // Slots are deliberately NOT dense — the gap after a short list is // what keeps the next list in its own zone. // * Everything else packs contiguously from slot 0; excluded (e.g. // digital-mode) channels drop out and the rest close up behind them. + // What the codeplug asks for, before any layout: the emptiness test + // below has to tell "nothing to program" apart from "nothing could be + // placed", and only the flat resolution knows the difference. + let (_model, slots_wanted) = + export::resolve_codeplug_slots(&state.pool, codeplug_id).await?; let (slots, zones, mut layout_warnings) = match export::fixed_zone_layout(&model) { Some(layout) => { let (_model, zoned) = export::resolve_codeplug_zone_slots(&state.pool, codeplug_id, layout) .await?; + // ⚠ A layout that placed NOTHING must not reach the port. + // `program_codeplug` refuses a codeplug with more channels + // than the radio holds — but after the zone layout runs, an + // over-capacity codeplug arrives as an EMPTY slot list + // instead of an over-long one, so that guard cannot fire and + // the write would sail through, blanking all 960 memories to + // "match" a codeplug whose channels it could not place. + if zoned.slots.is_empty() && !slots_wanted.is_empty() { + return Err(format!( + "None of this codeplug's channel lists fit in the \ + {}'s {} zones, so programming it would clear the radio \ + rather than fill it.\n\n{}", + model.display_name, + layout.zones, + zoned.warnings.join("\n") + )); + } (zoned.slots, zoned.zones.len(), zoned.warnings) } - None => { - let (_model, slots) = - export::resolve_codeplug_slots(&state.pool, codeplug_id).await?; - (slots, 0, Vec::new()) - } + None => (slots_wanted, 0, Vec::new()), }; // The radio profile's settings + the model's schema. Used by the diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index b2f55f5..5e10569 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -443,6 +443,14 @@ pub struct ExportPreview { /// the rows. pub receive_only_count: usize, pub rows: Vec, + /// Whether this radio lays its memories out as fixed zone blocks at all. + /// + /// ⚠ Declared rather than inferred from `zones` being non-empty. Those are + /// two different things: a fixed-zone radio whose every channel list was + /// refused ALSO has no zones, and treating that as "not a zone radio" made + /// the dialog fall back to a channel count for a write that would have + /// placed nothing and blanked the radio. + pub fixed_zones: bool, /// The zone map, for radios that lay their memories out as fixed zone /// blocks. Empty for everything else — including zone radios whose zones /// are named records the driver writes (the AnyTone), because there the diff --git a/src-tauri/src/radios/binteradio_bt9000/mod.rs b/src-tauri/src/radios/binteradio_bt9000/mod.rs index a57fbb3..692877f 100644 --- a/src-tauri/src/radios/binteradio_bt9000/mod.rs +++ b/src-tauri/src/radios/binteradio_bt9000/mod.rs @@ -539,7 +539,9 @@ pub(crate) fn validate_image(image: &[u8]) -> Result<(), String> { #[derive(Serialize, PartialEq, Debug, Clone)] pub struct Bt9000DecodedChannel { pub index: usize, - /// 1-based zone, `index / 64 + 1`. The radio stores no zone names. + /// 1-based zone, `index / 99 + 1` — see [`CHANNELS_PER_ZONE`], which is 99 + /// because the radio says so and not 64 because the manual does. The radio + /// stores no zone names. pub zone: usize, pub name: String, pub rx_mhz: f64, diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index d82df45..0dda8a8 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -337,14 +337,14 @@ fn models() -> Vec { // // ⚠ `zones_supported` was false for that reason, on the argument // that this app would be offering a name the radio cannot store. - // That confused the LABEL with the ZONE. The radio has fifteen of - // them and the operator switches between them; declaring otherwise - // did not withhold a name, it dumped every channel list into one - // undifferentiated run of memories. The flag now says what is true — - // fifteen zones of sixty-four — and `commands/export.rs` - // (`fixed_zone_layout`) reads those two numbers to lay one channel - // list into each zone. The names stay ours: the Program dialog shows - // the zone map, because the radio cannot. + // That confused the LABEL with the ZONE. The radio has ten of them + // and the operator switches between them; declaring otherwise did + // not withhold a name, it dumped every channel list into one + // undifferentiated run of memories. The flag now says what is true, + // and `commands/export.rs` (`fixed_zone_layout`) reads these three + // columns to lay one channel list into each zone. The names stay + // ours: the Program dialog shows the zone map, because the radio + // cannot. // -------------------------------------------------------- ModelSeed { manufacturer: "Binteradio", diff --git a/src/components/codeplugs/ProgramRadioDialog.tsx b/src/components/codeplugs/ProgramRadioDialog.tsx index 9dc281f..d92fd87 100644 --- a/src/components/codeplugs/ProgramRadioDialog.tsx +++ b/src/components/codeplugs/ProgramRadioDialog.tsx @@ -234,10 +234,16 @@ export function ProgramRadioDialog({ // so the memories written is the zone map's total, not the deduped // `included_count`. Empty for every other model, which keeps `writeCount` // exactly what it was. + // ⚠ `fixed_zones`, not `zones.length > 0`. A fixed-zone radio whose channel + // lists were ALL refused has no zones either, and falling back to + // `included_count` there quoted a channel count for a write that would have + // placed nothing and blanked the radio. The backend refuses that write; this + // stops the dialog describing it in the first place. + const zoned = preview?.fixed_zones ?? false; const zones = preview?.zones ?? []; const zoneNotes = preview?.zone_notes ?? []; const zoneTotal = zones.reduce((n, z) => n + z.channels, 0); - const writeCount = zones.length > 0 ? zoneTotal : (preview?.included_count ?? 0); + const writeCount = zoned ? zoneTotal : (preview?.included_count ?? 0); // ⚠ From the MODEL, not a constant. This was hard-coded to 128 for the UV-5R // and TD-H3, and this is the GENERIC dialog — the TH-D72 holds 1000, so the // confirm for a destructive write quoted a number that was simply wrong for @@ -274,10 +280,18 @@ export function ProgramRadioDialog({

This will write {writeCount} channel - {writeCount === 1 ? "" : "s"} to slots 1–{writeCount} and{" "} - clear the remaining {clearCount} slot - {clearCount === 1 ? "" : "s"} so the radio matches “{codeplugName}”. - A full backup is saved first. + {writeCount === 1 ? "" : "s"}{" "} + {/* Not "slots 1–N" on a fixed-zone radio: each list starts + at its own zone, so 5 channels in two lists land in + memories 1–3 and 100–101. The deliberate gaps are the + whole point of the layout, and this is the sentence + somebody reads before a destructive write. */} + {zones.length > 0 + ? `into ${zones.length} zone${zones.length === 1 ? "" : "s"}` + : `to slots 1–${writeCount}`}{" "} + and clear the remaining {clearCount} memor + {clearCount === 1 ? "y" : "ies"} so the radio matches “ + {codeplugName}”. A full backup is saved first.

{(skipped.length > 0 || receiveOnly.length > 0) && ( @@ -659,6 +673,13 @@ export function ProgramRadioDialog({ : `Wrote ${program.channels_written} channel${program.channels_written === 1 ? "" : "s"} — verification warning`) + (program.settings_written != null ? ` · ${program.settings_written} setting${program.settings_written === 1 ? "" : "s"}` + : "") + + // The zone map is shown BEFORE the write; without this the + // result never confirmed that the zones it promised are the + // zones that went out. `zones_written` was set by the + // command layer and read by nothing on this screen. + (program.zones_written > 0 + ? ` · ${program.zones_written} zone${program.zones_written === 1 ? "" : "s"}` : "") } note={program.note ?? undefined} diff --git a/src/lib/types.ts b/src/lib/types.ts index 9ea57fc..d6f1393 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -456,7 +456,7 @@ export interface ExportPreviewRow { } /// One zone on a radio whose zones are fixed blocks of memories (the BT-9000: -/// 15 blocks of 64). The radio stores no zone names, so this map is the only +/// 10 blocks of 99, the last one 69 long). The radio stores no zone names, so this map is the only /// place the operator can learn which list landed in which zone. export interface PreviewZone { /// 1-based, as the radio's own zone selector shows it. @@ -475,6 +475,10 @@ export interface ExportPreview { /// How many of `included_count` are receive-only. receive_only_count: number; rows: ExportPreviewRow[]; + /// Whether this radio lays its memories out as fixed zone blocks at all. + /// ⚠ Not the same as `zones.length > 0`: a fixed-zone radio whose every + /// channel list was refused also has no zones. + fixed_zones: boolean; /// The zone map, for fixed-zone radios; empty for every other model. /// ⚠ A channel in two of the codeplug's lists lands in BOTH zones and is /// programmed twice, so these summed can exceed `included_count`.