Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
719 changes: 713 additions & 6 deletions src-tauri/src/commands/export.rs

Large diffs are not rendered by default.

62 changes: 54 additions & 8 deletions src-tauri/src/commands/program.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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",
Expand Down Expand Up @@ -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!(
Expand Down
31 changes: 31 additions & 0 deletions src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,17 @@ pub struct ExportPreviewRow {
pub reason: Option<String>,
}

/// 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,
Expand All @@ -432,6 +443,26 @@ pub struct ExportPreview {
/// the rows.
pub receive_only_count: usize,
pub rows: Vec<ExportPreviewRow>,
/// 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<PreviewZone>,
/// 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<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
12 changes: 10 additions & 2 deletions src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,14 @@ fn program(slots: &[SlotChannel], tag: &str) -> Vec<u8> {
///
/// 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() {
Expand All @@ -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,
Expand Down
Loading