diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index b2b6046..f5a03c4 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,245 @@ 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: 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. +/// +/// 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, + /// 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 +/// 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, + 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?; + + // 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(); + 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, + // 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!( + "'{list_name}' has no channels this radio can program — it gets no zone, \ + and the lists after it move up one." + )); + continue; + } + // 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())); + refused.extend(ecs.iter().map(|ec| ec.channel.id)); + continue; + } + let first_zone = next_zone; + let mut placed = 0usize; + for z in 0..needed { + 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: zone + 1, + list_name: if needed == 1 { + list_name.clone() + } else { + format!("{list_name} ({} of {needed})", z + 1) + }, + 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 \ + 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" } + )); + } + + // 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. +/// +/// `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`]. +/// +/// ⚠ 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 { + if !model.zones_supported { + 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(ZoneLayout { per_zone, zones, memories }) +} + #[tauri::command] pub async fn export_preview( state: State<'_, AppState>, @@ -660,6 +899,48 @@ 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 (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: Vec = zoned + .zones + .into_iter() + .map(|z| PreviewZone { + number: z.number, + list_name: z.list_name, + channels: z.channels, + }) + .collect(); + (true, zones, zoned.warnings, zoned.refused) + } + 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, @@ -668,6 +949,9 @@ pub async fn export_preview( excluded_count: excluded, receive_only_count: receive_only, rows, + fixed_zones, + zones, + zone_notes, }) } @@ -1399,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: @@ -1428,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", @@ -1479,6 +1775,417 @@ 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"); + + // 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')", + ) + .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 (999, '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, 999, 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(); + // ★★★ 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, layout) + .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 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, 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 + // 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, 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 + // losing channels. 150 into zones of 99 is 99 + 51. + 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 <= 150", + ) + .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, layout) + .await + .expect("zone resolve"); + 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, 99), (2, 51), (3, 2)], + "BIG fills zone 1 and spills 51 into zone 2; GMRS follows in zone 3" + ); + 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: {:?}", + 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); + } + + /// ★★★ 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. + /// + /// 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 c14a1e7..852e77a 100644 --- a/src-tauri/src/commands/program.rs +++ b/src-tauri/src/commands/program.rs @@ -937,14 +937,55 @@ 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?; - - // 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. + // How the channels land in memory. Two layouts, chosen by the model: + // + // * 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 => (slots_wanted, 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 + // 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", @@ -996,6 +1037,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..5e10569 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,26 @@ 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 + /// 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/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 5e321a9..692877f 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 @@ -492,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, @@ -689,6 +738,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 +770,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 +800,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)); @@ -888,6 +958,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 +1025,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 +1061,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 +1118,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 +1132,7 @@ impl ImageProgrammer for BinteradioBt9000 { expected_path: None, windows_written: Vec::new(), skipped: Vec::new(), - warnings: Vec::new(), + warnings, }) } } @@ -1245,9 +1359,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] @@ -1271,6 +1411,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] @@ -1319,17 +1502,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/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, diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index 2a831f7..0dda8a8 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -323,10 +323,28 @@ 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 — so `zones_supported` is false: this app - // would be offering a name the radio cannot store. + // 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. + // 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", @@ -401,9 +419,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(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, @@ -1076,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. /// diff --git a/src/components/codeplugs/ProgramRadioDialog.tsx b/src/components/codeplugs/ProgramRadioDialog.tsx index 4539e40..d92fd87 100644 --- a/src/components/codeplugs/ProgramRadioDialog.tsx +++ b/src/components/codeplugs/ProgramRadioDialog.tsx @@ -229,7 +229,21 @@ 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. + // ⚠ `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 = 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 @@ -266,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) && ( @@ -514,8 +536,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." + } /> )} @@ -604,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 5489fe9..d6f1393 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: +/// 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. + number: number; + list_name: string; + channels: number; +} + export interface ExportPreview { codeplug_id: number; radio_model: string; @@ -465,6 +475,16 @@ 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`. + zones: PreviewZone[]; + /// What to tell the operator about that layout. + zone_notes: string[]; } // ---- direct radio programming (UV-5R) ----