From 78172070188316907098a305eb3388ddfa30b708 Mon Sep 17 00:00:00 2001 From: ww8l Date: Sat, 10 Oct 2026 11:06:05 -0600 Subject: [PATCH] Write a radio's settings on their own, from every place, on every radio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consistency pass (Tim): any radio that can program settings separately from channels now offers it in the same two places. - Program dialog: the generic dialog gets the AnyTone's Channel set | Profile cards whenever the driver writes settings on its own (MD-380, TH-D72, TM-D710, BT-9000, UV-5R, ID-5100). Profile writes the saved profile's settings with the channels untouched, guarded like the profile page. - UV-5R and ID-5100 gain a settings-only SettingsWriter. The UV-5R sends only the 16-byte blocks that changed; the ID-5100 runs the factory-reset guard (check_image) and shows its press-POWER instruction. Both share their upload-and-verify with the codeplug program. - Profile page: Write to radio on every cable radio with a writer (the AnyTone and TD-H3 were hidden behind a generic-dialog-only gate). For the AnyTone it points at Verify (the commit reboots the radio) and rescans ports. - Card radios (FT5D, ID-52, TH-D75): "Write settings to card" — the profile's settings patched into the radio's own file, memories untouched, by a new CardSettingsWriter capability and write_card_settings command that follow each format's export file rules (shared per format). Proven at the desk on the real card files (radios/card_settings_write.rs): a file's own settings change 0 bytes; one flipped setting changes 1 byte; the FT5D checksum validates. - One profileWriteBlocked guard for every settings-write affordance. Not run on a radio: the UV-5R and ID-5100 settings-only writes, and a radio loading a settings-only card file. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/commands/program.rs | 65 ++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/radios/baofeng_uv5r/mod.rs | 76 ++++--- src-tauri/src/radios/baofeng_uv5r/settings.rs | 124 +++++++++- src-tauri/src/radios/card_settings_write.rs | 150 +++++++++++++ src-tauri/src/radios/driver.rs | 30 +++ src-tauri/src/radios/icom_id5100/mod.rs | 77 ++++--- src-tauri/src/radios/icom_id5100/settings.rs | 66 +++++- src-tauri/src/radios/icom_id52/memory.rs | 84 ++++--- src-tauri/src/radios/icom_id52/mod.rs | 1 + src-tauri/src/radios/kenwood_thd75/memory.rs | 69 ++++-- src-tauri/src/radios/kenwood_thd75/mod.rs | 1 + src-tauri/src/radios/mod.rs | 2 + src-tauri/src/radios/registry.rs | 20 +- src-tauri/src/radios/yaesu_ft5d/mod.rs | 1 + src-tauri/src/radios/yaesu_ft5d/sd_image.rs | 89 ++++++-- .../codeplugs/ProgramRadioDialog.tsx | 212 +++++++++++++++++- src/components/profiles/ProfileEditor.tsx | 184 ++++++++++----- src/components/radioPort.tsx | 8 +- src/lib/api.ts | 5 + src/lib/radioProgramming.ts | 34 +++ src/lib/types.ts | 12 + 22 files changed, 1097 insertions(+), 214 deletions(-) create mode 100644 src-tauri/src/radios/card_settings_write.rs diff --git a/src-tauri/src/commands/program.rs b/src-tauri/src/commands/program.rs index 6717420..fd2512b 100644 --- a/src-tauri/src/commands/program.rs +++ b/src-tauri/src/commands/program.rs @@ -786,6 +786,71 @@ pub async fn write_radio_settings( Ok(report) } +/// What a settings-only card write produced. +#[derive(Serialize, Debug)] +pub struct CardSettingsWritten { + /// Settings that now hold the profile's value. + pub fields_written: usize, + /// The file written — a new one beside the radio's saves, or the picked + /// file patched in place, by the format's own rule. + pub path: String, + /// Values left out because they are outside their schema's range (#87). + pub note: Option, +} + +/// Write a profile's settings into a card radio's own file, every memory left +/// as the file has it — the card counterpart of [`write_radio_settings`]. The +/// radio then loads the file from its own menu, exactly as after an export. +/// +/// The target goes through the format's `resolve_target` and the same +/// file-type check as a codeplug export, so it obeys the same file rules (a new +/// file beside the saves, or the picked file in place with its `.orig`). +#[tauri::command] +pub async fn write_card_settings( + state: State<'_, AppState>, + profile_id: i64, + path: String, +) -> Result { + let target = profile_target(&state.pool, profile_id).await?; + let saved: Option = + sqlx::query_scalar("SELECT non_channel_settings FROM radio_profiles WHERE id = ?1") + .bind(profile_id) + .fetch_optional(&state.pool) + .await + .estr()? + .flatten(); + // After the last await: the exporter is not Sync, so it must not be held + // across one. + let exporter = target + .driver + .as_codeplug_exporter() + .filter(|e| e.as_card_settings_writer().is_some()) + .ok_or_else(|| { + format!( + "{} has no card file that takes settings on their own.", + target.driver.display_name() + ) + })?; + let saved = saved.ok_or( + "This profile has no saved settings to write. Load them from the radio's card, \ + or set some, and Save first.", + )?; + let mut settings: serde_json::Value = serde_json::from_str(&saved) + .map_err(|e| format!("saved profile settings are not valid JSON: {e}"))?; + let dropped = crate::radios::settings_bounds::strip_out_of_range(&target.schema, &mut settings); + let map = settings.as_object().ok_or("saved profile settings are not an object")?; + + let file = exporter.resolve_target(&path)?; + crate::commands::write_paths::check_write_target(&file, exporter.target_extensions())?; + let writer = exporter.as_card_settings_writer().expect("checked above"); + let fields_written = writer.export_settings(&file, map)?; + Ok(CardSettingsWritten { + fields_written, + path: file, + note: crate::radios::settings_bounds::note_line(&dropped), + }) +} + /// Push the DMR **call-sign database** (caller-ID / "UserDB") to a radio that /// has one: pull a capacity-capped, country/continent-prioritized batch from /// the local `dmr_users` library and hand it to the driver, which owns the diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fc5f90b..466c8ee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -219,6 +219,7 @@ pub fn run() { commands::program::read_radio_settings, commands::program::read_ft5d_settings_from_backup, commands::program::read_id52_settings_from_card, + commands::program::write_card_settings, commands::program::read_thd75_settings_from_card, commands::program::find_memory_cards, commands::program::write_radio_settings, diff --git a/src-tauri/src/radios/baofeng_uv5r/mod.rs b/src-tauri/src/radios/baofeng_uv5r/mod.rs index f8a0a0f..990ece4 100644 --- a/src-tauri/src/radios/baofeng_uv5r/mod.rs +++ b/src-tauri/src/radios/baofeng_uv5r/mod.rs @@ -165,6 +165,12 @@ impl RadioDriver for BaofengUv5r { fn as_settings_reader(&self) -> Option<&dyn crate::radios::driver::SettingsReader> { Some(self) } + + /// Settings on their own, channels untouched — so the Program dialog's + /// Profile option exists here as on every radio that can write settings. + fn as_settings_writer(&self) -> Option<&dyn crate::radios::driver::SettingsWriter> { + Some(self) + } } impl ImageRestorer for BaofengUv5r { @@ -324,30 +330,16 @@ impl ImageProgrammer for BaofengUv5r { half-written contents with a fresh backup of them.", ) }; - reident_with_retry(&mut *p).map_err(restore_hint)?; - // Record every range as it goes out, and hand that same list to the - // verifier — so what is checked is exactly what was written, and the two - // cannot drift apart. (#62) - let mut written: Vec = Vec::new(); - if settings_written.is_some() { - for &range in settings::SETTINGS_MAIN_RANGES { - write_region(&mut *p, &image, range.0, range.1).map_err(restore_hint)?; - written.push(WrittenRange::main(range)); - } - for &range in settings::SETTINGS_AUX_RANGES { - write_aux_region(&mut *p, &image, range.0, range.1).map_err(restore_hint)?; - written.push(WrittenRange::aux(range)); - } + let (main, aux): (&[Range], &[Range]) = if settings_written.is_some() { + (settings::SETTINGS_MAIN_RANGES, settings::SETTINGS_AUX_RANGES) } else { - for &range in &[CHANNEL_ADDR, NAME_ADDR] { - write_region(&mut *p, &image, range.0, range.1).map_err(restore_hint)?; - written.push(WrittenRange::main(range)); - } - } + (&[CHANNEL_ADDR, NAME_ADDR], &[]) + }; + let checked = upload_and_verify(&mut *p, &image, main, aux).map_err(restore_hint)?; // 4. Read back and verify (non-fatal: a write that ack'd every block // succeeded; verification is a best-effort confirmation). - let (verified, note, channels) = match verify_after_write(&mut *p, &image, &written) { + let (verified, note, channels) = match checked { Ok((ok, note, ch)) => (ok, note, ch), Err(e) => ( false, @@ -384,6 +376,40 @@ impl ImageProgrammer for BaofengUv5r { } } +/// A radio-address range `[start, end)`. +pub(crate) type Range = (u16, u16); + +/// A verification outcome: matched, a note when it did not, and the channels +/// read back. +pub(crate) type Verified = (bool, Option, Vec); + +/// Re-identify, upload `main` and `aux` (radio-address ranges), then read back +/// and compare exactly the bytes sent. Shared by the codeplug program and the +/// settings-only write so the two cannot drift. +/// +/// The outer error is a WRITE failure — the caller attaches its backup path. +/// The inner result is the verification, which is non-fatal by contract (#62): +/// every range that went out is recorded and handed to the verifier, so what +/// is checked is exactly what was written. +pub(crate) fn upload_and_verify( + p: &mut dyn SerialPort, + image: &[u8], + main: &[Range], + aux: &[Range], +) -> Result, String> { + reident_with_retry(p)?; + let mut written: Vec = Vec::new(); + for &range in main { + write_region(p, image, range.0, range.1)?; + written.push(WrittenRange::main(range)); + } + for &range in aux { + write_aux_region(p, image, range.0, range.1)?; + written.push(WrittenRange::aux(range)); + } + Ok(verify_after_write(p, image, &written)) +} + /// Narrow the driver's own richer decode to the generic sanity-sample shape. fn decoded_to_sample(c: DecodedChannel) -> DecodedChannelSample { DecodedChannelSample { @@ -1221,14 +1247,14 @@ mod tests { } #[test] - fn capabilities_derive_image_programmer_and_settings_read() { + fn capabilities_derive_image_programmer_and_settings_read_write() { let caps = DriverCapabilities::of(&DRIVER); assert!(caps.program_image); assert!(caps.read_settings); - // Read-only settings: the UV-5R's profile settings go out inside the - // image `program_codeplug` uploads, never as a standalone push. This is - // what the `SettingsReader`/`SettingsWriter` split (3.6d) exists to say. - assert!(!caps.write_settings); + // Settings go out inside the image `program_codeplug` uploads AND on + // their own (s136), so the Program dialog's Profile option exists here + // as on every cable radio that can write settings. + assert!(caps.write_settings); assert!(!caps.write_channels); assert!(!caps.write_callsign_db); assert!(!caps.export); diff --git a/src-tauri/src/radios/baofeng_uv5r/settings.rs b/src-tauri/src/radios/baofeng_uv5r/settings.rs index 43431b4..e1cd553 100644 --- a/src-tauri/src/radios/baofeng_uv5r/settings.rs +++ b/src-tauri/src/radios/baofeng_uv5r/settings.rs @@ -25,9 +25,9 @@ use std::collections::HashMap; use serde_json::Value; -use crate::radios::driver::{SettingsCapture, SettingsReader}; +use crate::radios::driver::{SettingsCapture, SettingsReader, SettingsWriteReport, SettingsWriter}; -use super::{download, ident_radio, open_port, BaofengUv5r}; +use super::{backup_filename, download, ident_radio, open_port, upload_and_verify, BaofengUv5r}; impl SettingsReader for BaofengUv5r { /// Download the full image and decode the profile's settings out of it. One @@ -49,6 +49,106 @@ impl SettingsReader for BaofengUv5r { } } +impl SettingsWriter for BaofengUv5r { + /// The profile's settings alone, channels untouched — the Program path's + /// own settings upload with the channel patch left out. Download and back + /// up, apply the settings to that image, upload the settings ranges (the + /// channels inside them go back exactly as read), then read back and compare + /// every written byte. Nothing is uploaded when no byte changed. + fn write_settings( + &self, + port: &str, + settings: &Value, + schema_json: &str, + backup_dir: &std::path::Path, + ) -> Result { + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let backup_path = backup_dir.join(backup_filename("presettings", "", &stamp)); + let mut p = open_port(port)?; + let (magic, ident) = ident_radio(&mut *p)?; + if !magic.starts_with("UV5R") { + return Err("the connected radio did not identify as a UV-5R".into()); + } + let base = download(&mut *p, &ident)?; + std::fs::write(&backup_path, &base) + .map_err(|e| format!("could not write backup {}: {e}", backup_path.display()))?; + + let mut image = base.clone(); + let settings_json = + serde_json::to_string(settings).map_err(|e| format!("invalid profile settings: {e}"))?; + let fields_written = apply_profile_settings(&mut image, schema_json, &settings_json)?; + let report = |verified, note| SettingsWriteReport { + fields_written, + verified: Some(verified), + note, + backup_path: backup_path.to_string_lossy().to_string(), + expected_path: None, + windows_written: Vec::new(), + }; + if image == base { + return Ok(report( + true, + Some("every setting already matched the radio — nothing was written".into()), + )); + } + + // Only the 16-byte blocks whose bytes changed. The settings ranges also + // hold every channel and name, and a settings write has no business + // re-sending them: a dropped cable would leave channels half-rewritten + // that were never meant to change. + let main = changed_runs(&base, &image, SETTINGS_MAIN_RANGES, super::main_offset); + let aux = changed_runs(&base, &image, SETTINGS_AUX_RANGES, super::aux_offset); + + std::thread::sleep(std::time::Duration::from_secs(1)); + let restore_hint = |e: String| { + crate::radios::driver::with_restore_hint( + e, + &backup_path, + "Restore it with \"Restore backup…\" in the Program dialog and pick that file.", + ) + }; + let checked = upload_and_verify(&mut *p, &image, &main, &aux).map_err(restore_hint)?; + Ok(match checked { + Ok((ok, note, _)) => report(ok, note), + Err(e) => report( + false, + Some(format!( + "Settings written, but read-back verification could not run ({e}). \ + Power-cycle the radio and use Read to confirm." + )), + ), + }) + } +} + +/// The parts of `ranges` (radio addresses) whose bytes differ between two +/// images, as runs of whole 0x10-byte blocks — the unit the radio is written +/// in. A block past the end of either image (no aux data was read) is skipped. +pub(crate) fn changed_runs( + before: &[u8], + after: &[u8], + ranges: &[(u16, u16)], + offset: fn(u16) -> usize, +) -> Vec<(u16, u16)> { + let mut runs: Vec<(u16, u16)> = Vec::new(); + for &(start, end) in ranges { + let mut addr = start; + while addr < end { + let off = offset(addr); + let differs = off + 0x10 <= before.len().min(after.len()) + && before[off..off + 0x10] != after[off..off + 0x10]; + if differs { + match runs.last_mut() { + Some(last) if last.1 == addr => last.1 = addr + 0x10, + _ => runs.push((addr, addr + 0x10)), + } + } + addr += 0x10; + } + } + runs +} + // Aux-block relocation: the bytes read from radio 0x1EC0 onward are stored in // the image immediately after the main block + names (which end at 0x1808). pub const AUX_IMAGE_BASE: usize = 0x1808; @@ -561,6 +661,26 @@ fn read_text(image: &[u8], off: usize, len: usize) -> String { s.trim_end().to_string() } +#[cfg(test)] +mod narrow_tests { + use super::*; + + /// One changed settings byte goes out as one 16-byte block — never the + /// channel block that shares its range. + #[test] + fn a_settings_write_sends_only_the_blocks_that_changed() { + let before = vec![0u8; 0x1808 + 0x140]; + let mut after = before.clone(); + after[super::super::main_offset(0x0E28) + 3] = 1; // in the settings struct + let runs = changed_runs(&before, &after, SETTINGS_MAIN_RANGES, super::super::main_offset); + assert_eq!(runs, [(0x0E20, 0x0E30)]); + after[super::super::main_offset(0x0E30)] = 1; // the next block: one run + let runs = changed_runs(&before, &after, SETTINGS_MAIN_RANGES, super::super::main_offset); + assert_eq!(runs, [(0x0E20, 0x0E40)]); + assert!(changed_runs(&before, &before, SETTINGS_MAIN_RANGES, super::super::main_offset).is_empty()); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/radios/card_settings_write.rs b/src-tauri/src/radios/card_settings_write.rs new file mode 100644 index 0000000..f70ed8d --- /dev/null +++ b/src-tauri/src/radios/card_settings_write.rs @@ -0,0 +1,150 @@ +//! The settings-only card write (s136), proven against real files the radios +//! wrote — no radio needed. For every file in the scratchpad folders: +//! +//! 1. Writing a file's OWN settings back changes nothing at all: everything +//! the settings write leaves alone, it leaves byte-identical. +//! 2. Flipping one on/off setting changes only that setting's byte(s) — plus, +//! on the FT5D, the checksum, which must still validate. +//! +//! `#[ignore]`d: the files are personal radio saves in gitignored scratchpad/. +//! +//! ```sh +//! cargo test --lib radios::card_settings_write -- --ignored --nocapture +//! ``` + +use serde_json::{Map, Value}; + +use crate::radios::driver::RadioDriver; + +const ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "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/../scratchpad"); + +fn files(dir: &str, ext: &str, prefix: &str) -> Vec { + let mut v: Vec<_> = std::fs::read_dir(format!("{ROOT}/{dir}")) + .unwrap_or_else(|e| panic!("{dir}: {e}")) + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext)) + && p.file_name().unwrap().to_string_lossy().starts_with(prefix) + }) + .collect(); + v.sort(); + assert!(!v.is_empty(), "no .{ext} files in {dir}"); + v +} + +/// The first on/off setting in a decoded map, flipped. +fn flip_one(decoded: &Value) -> (String, Map) { + let map = decoded.as_object().unwrap(); + let (k, v) = map.iter().find(|(_, v)| v.is_boolean()).expect("an on/off setting"); + let mut one = Map::new(); + one.insert(k.clone(), Value::Bool(!v.as_bool().unwrap())); + (k.clone(), one) +} + +fn differing(a: &[u8], b: &[u8]) -> Vec { + assert_eq!(a.len(), b.len()); + (0..a.len()).filter(|&i| a[i] != b[i]).collect() +} + +fn scratch(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("cpm-card-settings-{name}")); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +#[ignore = "needs the real card files in scratchpad/"] +fn ft5d_settings_only_writes_touch_settings_and_checksum_alone() { + use crate::radios::yaesu_ft5d::{settings::decode_settings, DRIVER}; + let ex = DRIVER.as_codeplug_exporter().unwrap(); + let w = ex.as_card_settings_writer().expect("a card settings writer"); + for src in files("ft5d", "dat", "ft5d_01") { + let orig = std::fs::read(&src).unwrap(); + let dir = scratch("ft5d"); + let target = dir.join("BACKUP.dat"); + std::fs::write(&target, &orig).unwrap(); + let t = target.to_str().unwrap(); + + let own = decode_settings(&orig); + w.export_settings(t, own.as_object().unwrap()).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), orig, "{src:?}: own settings changed bytes"); + assert!(dir.join("BACKUP.dat.orig").exists(), "the .orig is kept"); + + let (key, one) = flip_one(&own); + w.export_settings(t, &one).unwrap(); + let after = std::fs::read(&target).unwrap(); + let d = differing(&orig, &after); + let body: Vec<_> = d.iter().filter(|&&i| i < orig.len() - 4).collect(); + assert!(!body.is_empty() && body.len() <= 2, "{key}: {d:x?}"); + assert_eq!(decode_settings(&after)[&key], one[&key]); + assert!(crate::radios::yaesu_ft5d::sd_image::patch_settings(&after, &Map::new()).is_ok(), "checksum validates"); + println!("{src:?}: own settings = 0 bytes; {key} flipped = {body:x?} + checksum"); + } +} + +#[test] +#[ignore = "needs the real card files in scratchpad/"] +fn id52_settings_only_writes_touch_settings_alone() { + use crate::radios::icom_id52::{icf::IcfFile, settings::decode_settings, DRIVER}; + let ex = DRIVER.as_codeplug_exporter().unwrap(); + let w = ex.as_card_settings_writer().expect("a card settings writer"); + for src in files("id52", "icf", "id52_0") { + let text = std::fs::read_to_string(&src).unwrap(); + let orig = IcfFile::parse(&text).unwrap().image().to_vec(); + let dir = scratch("id52"); + let target = dir.join("Setting.icf"); + std::fs::write(&target, &text).unwrap(); + let t = target.to_str().unwrap(); + + let own = decode_settings(&orig); + w.export_settings(t, own.as_object().unwrap()).unwrap(); + let back = IcfFile::parse(&std::fs::read_to_string(&target).unwrap()).unwrap(); + assert_eq!(back.image(), orig.as_slice(), "{src:?}: own settings changed bytes"); + + let (key, one) = flip_one(&own); + w.export_settings(t, &one).unwrap(); + let after = IcfFile::parse(&std::fs::read_to_string(&target).unwrap()).unwrap(); + let d = differing(&orig, after.image()); + assert!(!d.is_empty() && d.len() <= 2, "{key}: {d:x?}"); + assert_eq!(decode_settings(after.image())[&key], one[&key]); + println!("{src:?}: own settings = 0 bytes; {key} flipped = {d:x?}"); + } +} + +#[test] +#[ignore = "needs the real card files in scratchpad/"] +fn thd75_settings_only_writes_a_new_file_touching_settings_alone() { + use crate::radios::kenwood_thd75::{d75::D75File, settings::decode_settings, DRIVER}; + let ex = DRIVER.as_codeplug_exporter().unwrap(); + let w = ex.as_card_settings_writer().expect("a card settings writer"); + // Only the radio's own saves: the folder also holds an MCP-D75 file, which + // the driver refuses by design (it is a different, unmeasured layout). + for src in files("thd75/card", "d75", "") { + let raw = std::fs::read(&src).unwrap(); + let Ok(parsed) = D75File::parse(&raw) else { + println!("{src:?}: not a radio save — skipped, as the driver refuses it"); + continue; + }; + let orig = parsed.body().to_vec(); + let dir = scratch("thd75"); + std::fs::write(dir.join(src.file_name().unwrap()), &raw).unwrap(); + + let own = decode_settings(&orig); + let t1 = ex.resolve_target(dir.to_str().unwrap()).unwrap(); + w.export_settings(&t1, own.as_object().unwrap()).unwrap(); + assert_eq!(std::fs::read(&t1).unwrap(), raw, "{src:?}: own settings changed bytes"); + + let (key, one) = flip_one(&own); + std::fs::remove_file(&t1).unwrap(); // keep the template the newest + let t2 = ex.resolve_target(dir.to_str().unwrap()).unwrap(); + w.export_settings(&t2, &one).unwrap(); + let after = D75File::parse(&std::fs::read(&t2).unwrap()).unwrap(); + let d = differing(&orig, after.body()); + assert!(!d.is_empty() && d.len() <= 2, "{key}: {d:x?}"); + assert_eq!(decode_settings(after.body())[&key], one[&key]); + assert_eq!(std::fs::read(dir.join(src.file_name().unwrap())).unwrap(), raw, "the radio's own save is untouched"); + println!("{src:?}: own settings = 0 bytes; {key} flipped = {d:x?}; new file {t2}"); + } +} diff --git a/src-tauri/src/radios/driver.rs b/src-tauri/src/radios/driver.rs index 3567fda..e5d13b2 100644 --- a/src-tauri/src/radios/driver.rs +++ b/src-tauri/src/radios/driver.rs @@ -605,6 +605,21 @@ pub(crate) struct ExportRequest<'a> { pub profile_settings: Option<&'a str>, } +/// Card formats whose file takes the profile's settings ON THEIR OWN: the +/// radio's own file patched with settings only, every memory left as it is. +/// The card counterpart of [`SettingsWriter`], reached through +/// [`CodeplugExporter::as_card_settings_writer`]. +pub(crate) trait CardSettingsWriter { + /// Patch only `settings` into the radio's file at `target` — already + /// through [`CodeplugExporter::resolve_target`], so it follows exactly the + /// file rules the export does — returning the fields applied. + fn export_settings( + &self, + target: &str, + settings: &serde_json::Map, + ) -> Result; +} + /// File-format exporters (CHIRP-style CSV, AnyTone dual-CSV bundle, …). The /// `export_format` key matches `radio_models.export_format`. pub(crate) trait CodeplugExporter { @@ -614,6 +629,15 @@ pub(crate) trait CodeplugExporter { /// Write the codeplug file(s) rooted at `path`, returning channels written. fn export(&self, path: &str, req: &ExportRequest) -> Result; + /// The format's settings-only write, when its file can take the + /// profile's settings on their own (the card radios). Declared, not + /// inferred, like every other capability: a format either implements + /// [`CardSettingsWriter`] or it does not, so the profile page's button and + /// the write behind it cannot disagree. + fn as_card_settings_writer(&self) -> Option<&dyn CardSettingsWriter> { + None + } + /// Extensions this exporter's finished file can end in, lowercase and /// without dots. /// @@ -671,6 +695,9 @@ pub struct DriverCapabilities { pub after_write: Option<&'static str>, /// [`RadioDriver::usb_direct`]: no port picker; the radio is found by id. pub usb_direct: bool, + /// [`CodeplugExporter::as_card_settings_writer`]: the profile's settings + /// can be written into the radio's own card file without its memories. + pub write_card_settings: bool, } impl DriverCapabilities { @@ -696,6 +723,9 @@ impl DriverCapabilities { .as_image_programmer() .and_then(ImageProgrammer::after_write_instruction), usb_direct: driver.usb_direct(), + write_card_settings: driver + .as_codeplug_exporter() + .is_some_and(|e| e.as_card_settings_writer().is_some()), } } } diff --git a/src-tauri/src/radios/icom_id5100/mod.rs b/src-tauri/src/radios/icom_id5100/mod.rs index 8c10873..637578a 100644 --- a/src-tauri/src/radios/icom_id5100/mod.rs +++ b/src-tauri/src/radios/icom_id5100/mod.rs @@ -83,9 +83,13 @@ impl RadioDriver for IcomId5100 { Some(self) } - // No `SettingsWriter`: settings live in the clone image, so they are written - // by the codeplug program (`carries_profile_settings`). A standalone settings - // write would be the same full clone and the same press-POWER restart. + /// Settings on their own, channels untouched — the same full clone and + /// press-POWER restart as a program, offered so the Program dialog's + /// Profile option exists here as on every radio that can write settings. + /// A codeplug program still carries the profile's settings too. + fn as_settings_writer(&self) -> Option<&dyn crate::radios::driver::SettingsWriter> { + Some(self) + } } /// A fresh session for the next clone. Each clone is its own session, as in @@ -143,6 +147,44 @@ fn read_back(port: &str) -> Result, String> { } } +/// Clone `image` to the radio, then read it back once the operator has pressed +/// POWER and count any written byte that did not stick. Shared by the codeplug +/// program and the settings-only write so the two cannot drift. +/// +/// An upload failure is an error, with `hint` attaching the backup path. The +/// verification is non-fatal: the radio confirmed the clone, so a failed +/// read-back is a reporting problem, returned as `(false, note)`. +pub(super) fn clone_back_and_verify( + port: &str, + base: &[u8], + image: &[u8], + hint: impl Fn(String) -> String, +) -> Result<(bool, Option), String> { + let mut p = reopen(port).map_err(&hint)?; + protocol::upload(&mut *p, image).map_err(&hint)?; + drop(p); + Ok(match read_back(port) { + Ok(after) => match mismatches(base, image, &after) { + 0 => (true, None), + n => ( + false, + Some(format!( + "The radio confirmed the write, but {n} bytes of what was written did \ + not read back the same. Power-cycle the radio and use Download to \ + confirm what it is holding." + )), + ), + }, + Err(e) => ( + false, + Some(format!( + "Write completed, but read-back verification could not run ({e}). \ + Power-cycle the radio and use Download to confirm." + )), + ), + }) +} + /// Addresses that did not read back as written. /// /// Everything below [`VOLATILE_BASE`] must match — that is the whole memory @@ -282,33 +324,8 @@ impl ImageProgrammer for IcomId5100 { on the radio before this write.", ) }; - let mut p = reopen(port).map_err(restore_hint)?; - protocol::upload(&mut *p, &built.image).map_err(restore_hint)?; - drop(p); - - // 4. Read back and verify. Non-fatal: the radio confirmed the clone, so - // a failed read-back is a reporting problem, not a write problem. - let reread = read_back(port); - let (verified, note) = match reread { - Ok(after) => match mismatches(&base, &built.image, &after) { - 0 => (true, None), - n => ( - false, - Some(format!( - "The radio confirmed the write, but {n} bytes of what was written did \ - not read back the same. Power-cycle the radio and use Download to \ - confirm what it is holding." - )), - ), - }, - Err(e) => ( - false, - Some(format!( - "Write completed, but read-back verification could not run ({e}). \ - Power-cycle the radio and use Download to confirm." - )), - ), - }; + // 4. Upload, then read back and verify. + let (verified, note) = clone_back_and_verify(port, &base, &built.image, restore_hint)?; let channels_written = req.channels.len(); Ok(CodeplugProgramReport { diff --git a/src-tauri/src/radios/icom_id5100/settings.rs b/src-tauri/src/radios/icom_id5100/settings.rs index 9844ed9..42f028b 100644 --- a/src-tauri/src/radios/icom_id5100/settings.rs +++ b/src-tauri/src/radios/icom_id5100/settings.rs @@ -23,7 +23,9 @@ use serde_json::{Map, Value}; -use crate::radios::driver::{SettingsCapture, SettingsReader}; +use crate::radios::driver::{ + with_restore_hint, SettingsCapture, SettingsReader, SettingsWriteReport, SettingsWriter, +}; use crate::radios::icom_id52::settings::{apply_settings_with, decode_settings_with, SF, SK}; use super::protocol; @@ -279,6 +281,68 @@ fn enforce_limits(image: &[u8], applicable: &mut Map, notes: &mut } } +impl SettingsWriter for super::IcomId5100 { + /// The profile's settings alone, channels untouched: the codeplug + /// program's own steps with the channel build left out. Download and back + /// up, apply the settings, clone the image back, then read back once the + /// operator has pressed POWER (`after_write_instruction`) and count any + /// written byte that did not stick. Nothing is cloned when no byte changed. + fn write_settings( + &self, + port: &str, + settings: &Value, + _schema_json: &str, + backup_dir: &std::path::Path, + ) -> Result { + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let backup_path = backup_dir.join(format!("id5100-presettings-{stamp}.img")); + let mut p = super::reopen(port)?; + let base = protocol::download(&mut *p)?; + std::fs::write(&backup_path, &base) + .map_err(|e| format!("could not write backup {}: {e}", backup_path.display()))?; + drop(p); + + let mut image = base.clone(); + let (fields_written, notes) = apply(&mut image, settings); + let report = |verified, note: Option| { + let note = match (note, notes.is_empty()) { + (n, true) => n, + (None, false) => Some(notes.join("; ")), + (Some(n), false) => Some(format!("{n} {}", notes.join("; "))), + }; + SettingsWriteReport { + fields_written, + verified: Some(verified), + note, + backup_path: backup_path.to_string_lossy().to_string(), + expected_path: None, + windows_written: Vec::new(), + } + }; + if image == base { + return Ok(report( + true, + Some("every setting already matched the radio — nothing was written".into()), + )); + } + + // The backstop every upload passes: one memory the radio cannot hold + // makes it refuse the whole clone and factory-reset. These channels + // came off the radio, so this passes by construction — it stays anyway. + super::check_image(&image)?; + + let restore_hint = |e: String| { + with_restore_hint( + e, + &backup_path, + "Keep that file. Put it back with \"Restore backup…\" in the Program dialog.", + ) + }; + let (verified, note) = super::clone_back_and_verify(port, &base, &image, restore_hint)?; + Ok(report(verified, note)) + } +} + impl SettingsReader for super::IcomId5100 { /// One clone read: decoded for the form, and kept whole as the backup. fn read_settings(&self, port: &str, _schema_json: &str) -> Result { diff --git a/src-tauri/src/radios/icom_id52/memory.rs b/src-tauri/src/radios/icom_id52/memory.rs index ab54ceb..4d606ff 100644 --- a/src-tauri/src/radios/icom_id52/memory.rs +++ b/src-tauri/src/radios/icom_id52/memory.rs @@ -68,7 +68,7 @@ use crate::commands::export::{expanded_names, CodeplugGroup, ExpandedChannel}; use crate::models::RadioModel; -use crate::radios::driver::{CodeplugExporter, ExportRequest}; +use crate::radios::driver::{CardSettingsWriter, CodeplugExporter, ExportRequest}; use super::icf::IcfFile; use super::IcomId52; @@ -317,18 +317,24 @@ impl CodeplugExporter for IcomId52 { if path.to_ascii_lowercase().ends_with(".csv") { return super::memory_csv::write_csv(path, req); } - // A target that does not exist yet is a NEW file on the card, and the - // template is the newest settings file sitting beside it. A target that - // does exist is the operator pointing at one file and meaning it. - let template = if std::path::Path::new(path).is_file() { - path.to_string() - } else { - let dir = std::path::Path::new(path) - .parent() - .ok_or("There is no folder to write into.")?; - newest_settings_file(dir)? - }; - patch_icf(&template, path, req) + patch_icf(path, |icf| { + let written = write_codeplug(icf, req.channels, req.groups, req.model)?; + // Settings ride in the same file as the memories, so a codeplug + // export is also a settings write. Nothing happens if the profile + // has none — an empty profile leaves the radio's settings as they were. + if let Some(json) = req.profile_settings { + let parsed: serde_json::Value = serde_json::from_str(json) + .map_err(|e| format!("This profile's settings are not valid JSON: {e}"))?; + if let Some(map) = parsed.as_object() { + super::settings::apply_settings(icf.image_mut(), map); + } + } + Ok(written) + }) + } + + fn as_card_settings_writer(&self) -> Option<&dyn CardSettingsWriter> { + Some(self) } /// A folder means "make me a new file in here", named the way the radio @@ -347,6 +353,20 @@ impl CodeplugExporter for IcomId52 { } } +impl CardSettingsWriter for IcomId52 { + /// The `.icf` file rules of the export, with only the settings applied. + fn export_settings( + &self, + path: &str, + settings: &serde_json::Map, + ) -> Result { + if path.to_ascii_lowercase().ends_with(".csv") { + return Err("A Memory CH .csv carries no settings — pick the .icf.".into()); + } + patch_icf(path, |icf| Ok(super::settings::apply_settings(icf.image_mut(), settings))) + } +} + /// Every `.icf` in a folder that this driver can actually patch, oldest first. /// The radio's own names sort chronologically, which is what makes "newest" /// mean anything. @@ -404,26 +424,32 @@ fn next_settings_file(dir: &std::path::Path) -> std::path::PathBuf { /// codeplug does not describe (the repeater list, DV call signs, Bluetooth /// pairings) has to survive the round trip. `.orig` is written once and never /// overwritten, so a second export cannot clobber the only pristine copy. -fn patch_icf(template: &str, path: &str, req: &ExportRequest) -> Result { - let text = std::fs::read_to_string(template).map_err(|e| { +/// The `.icf` file rule, shared by the export and the settings-only write. +/// +/// A target that does not exist yet is a NEW file on the card, and the template +/// is the newest settings file sitting beside it. A target that does exist is +/// the operator pointing at one file and meaning it: it is patched in place, +/// with the untouched original kept once as `.orig`. +fn patch_icf( + path: &str, + patch: impl FnOnce(&mut IcfFile) -> Result, +) -> Result { + let template = if std::path::Path::new(path).is_file() { + path.to_string() + } else { + let dir = std::path::Path::new(path) + .parent() + .ok_or("There is no folder to write into.")?; + newest_settings_file(dir)? + }; + let text = std::fs::read_to_string(&template).map_err(|e| { format!( - "Could not read {path}: {e}. Pick a settings file the radio saved for \ + "Could not read {template}: {e}. Pick a settings file the radio saved for \ itself with SET > SD Card > Save Setting — they live in ID-52/Setting/." ) })?; let mut icf = IcfFile::parse(&text)?; - let written = write_codeplug(&mut icf, req.channels, req.groups, req.model)?; - - // Settings ride in the same file as the memories, so a codeplug export is - // also a settings write. Nothing happens if the profile has none — an empty - // profile leaves the radio's own settings exactly as they were. - if let Some(json) = req.profile_settings { - let parsed: serde_json::Value = serde_json::from_str(json) - .map_err(|e| format!("This profile's settings are not valid JSON: {e}"))?; - if let Some(map) = parsed.as_object() { - super::settings::apply_settings(icf.image_mut(), map); - } - } + let count = patch(&mut icf)?; // Only when we are overwriting the operator's file. Writing a NEW one leaves // theirs untouched by construction, so a `.orig` would be a copy of a file @@ -436,7 +462,7 @@ fn patch_icf(template: &str, path: &str, req: &ExportRequest) -> Result Result { - let dir = std::path::Path::new(path) - .parent() - .ok_or("There is no folder to write into.")?; - let template = newest_config_file(dir)?; - patch_d75(&template, path, req) + patch_d75(path, |file| { + let written = write_codeplug(file, req.channels, req.groups, req.model)?; + // Settings ride in the same file as the memories, so a codeplug + // export is also a settings write — the same arrangement as the + // ID-52's `.icf`. Nothing happens if the profile has none. + if let Some(json) = req.profile_settings { + let parsed: serde_json::Value = serde_json::from_str(json) + .map_err(|e| format!("This profile's settings are not valid JSON: {e}"))?; + if let Some(map) = parsed.as_object() { + super::settings::apply_settings(file.body_mut(), map); + } + } + Ok(written) + }) + } + + fn as_card_settings_writer(&self) -> Option<&dyn CardSettingsWriter> { + Some(self) } /// Name the file this write will create: the radio's own @@ -664,6 +677,18 @@ impl CodeplugExporter for KenwoodThd75 { } } +impl CardSettingsWriter for KenwoodThd75 { + /// The export's file rule — the newest save as the template, written as a + /// NEW file — with only the settings applied. + fn export_settings( + &self, + path: &str, + settings: &serde_json::Map, + ) -> Result { + patch_d75(path, |file| Ok(super::settings::apply_settings(file.body_mut(), settings))) + } +} + /// Every `.d75` in a folder that this driver can actually patch, oldest first. /// The radio's `MMDDYYYY_HHMMSS` names do **not** sort chronologically as text, /// so this sorts by the date they encode. @@ -728,8 +753,19 @@ fn next_config_file(dir: &std::path::Path) -> std::path::PathBuf { } /// Patch a config file and write the result to `path`. -fn patch_d75(template: &str, path: &str, req: &ExportRequest) -> Result { - let raw = std::fs::read(template).map_err(|e| { +/// The `.d75` file rule, shared by the export and the settings-only write: the +/// newest config the radio saved in `path`'s folder is the template, and the +/// result is always a NEW file at `path` — the radio's own saves are never +/// modified. +fn patch_d75( + path: &str, + patch: impl FnOnce(&mut D75File) -> Result, +) -> Result { + let dir = std::path::Path::new(path) + .parent() + .ok_or("There is no folder to write into.")?; + let template = newest_config_file(dir)?; + let raw = std::fs::read(&template).map_err(|e| { format!( "Could not read {template}: {e}. Pick a config file the radio saved for itself with \ Menu > Configuration > SD Card > Save Setting — they live in \ @@ -737,22 +773,9 @@ fn patch_d75(template: &str, path: &str, req: &ExportRequest) -> Result (true, false), + "baofeng_uv5r" => (true, true), // All three card radios: no cable, so no cable settings session. // The FT5D's settings ride in its microSD backup, the ID-52's in // its `.icf` and the TH-D75's in its `.d75`, none of which goes @@ -145,12 +147,10 @@ mod tests { // every exposed range was measured there rather than read off // the published sheet that was wrong about five of them. "kenwood_tmd710" => (true, true), - // ID-5100 (#49): reads, but no standalone write. Its settings - // live in the clone image, so they ride out inside the codeplug - // program (`carries_profile_settings`) exactly like the UV-5R — - // a separate settings write would be the same full clone and - // the same press-POWER restart. - "icom_id5100" => (true, false), + // ID-5100 (#49): both. Settings live in the clone image and ride + // out inside the codeplug program, AND write on their own (the + // same clone and press-POWER restart) for the Profile option. + "icom_id5100" => (true, true), // MD-380 (#42): both. Settings live in the codeplug image, and // a codeplug program carries the radio's own through untouched, // so the profile's are a separate write — the AnyTone's shape. diff --git a/src-tauri/src/radios/yaesu_ft5d/mod.rs b/src-tauri/src/radios/yaesu_ft5d/mod.rs index cac69d5..9b9cb44 100644 --- a/src-tauri/src/radios/yaesu_ft5d/mod.rs +++ b/src-tauri/src/radios/yaesu_ft5d/mod.rs @@ -290,6 +290,7 @@ mod tests { diagnostics: false, after_write: None, usb_direct: false, + write_card_settings: true, } ); } diff --git a/src-tauri/src/radios/yaesu_ft5d/sd_image.rs b/src-tauri/src/radios/yaesu_ft5d/sd_image.rs index 8e971fd..ebef1ac 100644 --- a/src-tauri/src/radios/yaesu_ft5d/sd_image.rs +++ b/src-tauri/src/radios/yaesu_ft5d/sd_image.rs @@ -75,7 +75,7 @@ use std::collections::HashMap; use crate::commands::export::{expanded_names, tx_frequency, CodeplugGroup, ExpandedChannel}; use crate::error::MapErrString; use crate::models::RadioModel; -use crate::radios::driver::{CodeplugExporter, ExportRequest}; +use crate::radios::driver::{CardSettingsWriter, CodeplugExporter, ExportRequest}; use super::YaesuFt5d; @@ -527,30 +527,75 @@ impl CodeplugExporter for YaesuFt5d { /// beside it as `BACKUP.dat.orig`, written once and never overwritten, so a /// second export cannot clobber the only pristine copy. fn export(&self, path: &str, req: &ExportRequest) -> Result { - let template = std::fs::read(path).map_err(|e| { - format!( - "Could not read {path}: {e}. Pick FT5D/BACKUP/BACKUP.dat on the \ - radio's microSD card — back the radio up first if it is not there." - ) - })?; - let image = patch_backup( - &template, - req.channels, - req.groups, - req.model, - req.profile_settings, - )?; - - let orig = format!("{path}.orig"); - if !std::path::Path::new(&orig).exists() { - std::fs::write(&orig, &template) - .map_err(|e| format!("Could not save the original as {orig}: {e}"))?; - } - std::fs::write(path, &image).estr()?; - Ok(req.channels.len()) + patch_in_place(path, |template| { + let image = patch_backup( + template, + req.channels, + req.groups, + req.model, + req.profile_settings, + )?; + Ok((image, req.channels.len())) + }) + } + + fn as_card_settings_writer(&self) -> Option<&dyn CardSettingsWriter> { + Some(self) } } +impl CardSettingsWriter for YaesuFt5d { + /// The same file and rules as the export — in place, `.orig` kept once — + /// with only the settings applied and the checksum recomputed. + fn export_settings( + &self, + path: &str, + settings: &serde_json::Map, + ) -> Result { + patch_in_place(path, |template| patch_settings(template, settings)) + } +} + +/// The FT5D's file rule, shared by the export and the settings-only write: +/// read `path` (the operator's `BACKUP.dat`, which is what the radio's Restore +/// menu reads), patch it, keep the untouched original as `.orig` — written once +/// and never overwritten, so a second write cannot clobber the only pristine +/// copy — and write the result back in place. +fn patch_in_place( + path: &str, + patch: impl FnOnce(&[u8]) -> Result<(Vec, usize), String>, +) -> Result { + let template = std::fs::read(path).map_err(|e| { + format!( + "Could not read {path}: {e}. Pick FT5D/BACKUP/BACKUP.dat on the \ + radio's microSD card — back the radio up first if it is not there." + ) + })?; + let (image, count) = patch(&template)?; + let orig = format!("{path}.orig"); + if !std::path::Path::new(&orig).exists() { + std::fs::write(&orig, &template) + .map_err(|e| format!("Could not save the original as {orig}: {e}"))?; + } + std::fs::write(path, &image).estr()?; + Ok(count) +} + +/// The settings half of [`patch_backup`] on its own: validate, apply the +/// profile's settings, recompute the checksum. Every memory, flag and bank is +/// the template's. Returns the image and the fields applied. +pub(crate) fn patch_settings( + template: &[u8], + settings: &serde_json::Map, +) -> Result<(Vec, usize), String> { + validate_backup(template)?; + let mut img = template.to_vec(); + let n = super::settings::apply_settings(&mut img, settings); + // ★ Never optional: a wrong sum makes the radio factory-reset itself. + write_checksum(&mut img); + Ok((img, n)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/components/codeplugs/ProgramRadioDialog.tsx b/src/components/codeplugs/ProgramRadioDialog.tsx index a0ab87c..3682c3c 100644 --- a/src/components/codeplugs/ProgramRadioDialog.tsx +++ b/src/components/codeplugs/ProgramRadioDialog.tsx @@ -12,6 +12,8 @@ import { Undo2, MemoryStick, Ear, + ListChecks, + Settings2, } from "lucide-react"; import { api } from "../../lib/api"; import type { @@ -20,6 +22,7 @@ import type { MemoryCard, CodeplugProgramReport, RadioIdent, + SettingsWriteReport, } from "../../lib/types"; import { FooterClose, Modal } from "../overlays"; import { PortSelect, usePortChoice } from "../radioPort"; @@ -32,6 +35,8 @@ import { useDriverCapabilities, type ProgramDialogProps, usbPortFor, + profileWriteBlocked, + savedSettingsEmpty, } from "../../lib/radioProgramming"; // Shown on every exit the dialog refuses while a radio operation is running — @@ -64,6 +69,7 @@ export function ProgramRadioDialog({ codeplugId, codeplugName, model, + profileId, }: ProgramDialogProps) { const driverKey = driverKeyOf(model); const caps = useDriverCapabilities(driverKey || null); @@ -97,12 +103,48 @@ export function ProgramRadioDialog({ // be programmed over the cable". Same shape as #65: the gate existed, the // button just never consulted it. const canWriteOverCable = showCable && cableCapable; + // The Profile payload: the driver writes settings on its own (SettingsWriter). + const canWriteProfile = showCable && caps?.write_settings === true; // Cable or card — anything at all that ends with a codeplug on the radio. const canWriteAnything = canWriteOverCable || media != null; const [preview, setPreview] = useState(null); const [busy, setBusy] = useState< - null | "identify" | "download" | "program" | "restore" | "media" + null | "identify" | "download" | "program" | "restore" | "media" | "settings" >(null); + // What the write sends: the codeplug's channels, or the profile's settings. + // The same choice the AnyTone dialog offers, for every radio whose driver + // can write settings on its own — so no radio's settings live only in the + // profile editor while its channels live here. + const [payload, setPayload] = useState<"channels" | "profile">("channels"); + const [settingsResult, setSettingsResult] = useState(null); + const profileMode = canWriteProfile && payload === "profile"; + // The Profile payload writes the SAVED profile, so the profile page's rule + // applies here too (`profileWriteBlocked`): a never-saved or empty profile + // has nothing to send, and must not open a radio session to change nothing. + // Unsaved edits live in the profile editor, out of this dialog's sight; the + // explanation below says it is the saved profile that goes out. + const [profileBlocked, setProfileBlocked] = useState(null); + useEffect(() => { + if (!profileMode || profileId == null) { + setProfileBlocked(null); + return; + } + api + .getRadioProfile(profileId) + .then((p) => + setProfileBlocked( + profileWriteBlocked( + { + neverSaved: !p.non_channel_settings, + dirty: false, + nothingSet: savedSettingsEmpty(p.non_channel_settings), + }, + "radio", + ), + ), + ) + .catch(() => setProfileBlocked(null)); + }, [profileMode, profileId]); const [ident, setIdent] = useState(null); const [download, setDownload] = useState(null); const [program, setProgram] = useState(null); @@ -126,6 +168,8 @@ export function ProgramRadioDialog({ setIdent(null); setDownload(null); setProgram(null); + setPayload("channels"); + setSettingsResult(null); setConfirming(false); setMediaWritten(null); setError(null); @@ -145,10 +189,14 @@ export function ProgramRadioDialog({ }, [open]); const run = async ( - kind: "identify" | "download" | "program" | "restore" | "media", + kind: "identify" | "download" | "program" | "restore" | "media" | "settings", fn: () => Promise, ) => { setError(null); + // A settings result belongs to the Profile write that produced it. Any + // other action leaves it stale — and a green "verified ✓" above a failed + // program is the worst kind of stale — so every other run clears it. + if (kind !== "settings") setSettingsResult(null); setBusy(kind); try { await fn(); @@ -172,6 +220,18 @@ export function ProgramRadioDialog({ setDownload(await api.downloadImage(driverKey, port)); }); + const doWriteSettings = () => + run("settings", async () => { + setConfirming(false); + setDownload(null); + setIdent(null); + setProgram(null); + if (profileId == null) { + throw "This codeplug has no radio profile — attach one under Codeplugs first."; + } + setSettingsResult(await api.writeRadioSettings(port, profileId)); + }); + const doProgram = () => run("program", async () => { setConfirming(false); @@ -282,8 +342,31 @@ export function ProgramRadioDialog({ // TH-D72. The buttons an operator must reach at any moment, and the // confirm that answers them, belong in the footer. <> + {/* Confirm a settings write (the Profile payload) */} + {confirming && profileMode && ( +
+
+ Confirm settings write to {modelName} +
+

+ This writes the radio profile’s saved settings to the radio. + Channels, zones and contacts are not touched. A backup is saved + first. + {caps?.after_write && {caps.after_write}} +

+
+ + +
+
+ )} + {/* Confirm write */} - {confirming && ( + {confirming && !profileMode && (
Confirm write to {modelName} @@ -415,7 +498,22 @@ export function ProgramRadioDialog({ )} - {canWriteOverCable && ( + {profileMode && ( + + )} + {canWriteOverCable && !profileMode && ( + ); + })} +
+ )} + + {profileMode && ( +
+ Writes this profile’s saved radio settings (the ones from{" "} + Download from radio in the profile editor) to the{" "} + {modelName}. A setting left blank in the profile is left as the radio + has it, and the channel set is not touched. + {profileId == null && ( + This codeplug has no radio profile — attach one first. + )} + {profileBlocked && ( + + {profileBlocked} + + )} +
+ )} + {/* No port to pick: the radio is found on the USB bus by its id. */} {showCable && usbDirect && (
@@ -527,7 +694,7 @@ export function ProgramRadioDialog({ writes as soon as you click, so a "3 DMR channels are being dropped" warning that only appears in the cable confirmation never reaches half the radios. */} - {preview && ( + {preview && !profileMode && (
{/* The breakdown below is worth showing even for a radio @@ -634,6 +801,16 @@ export function ProgramRadioDialog({
)} + {busy === "settings" && ( +
+ + + Backing up → writing settings → verifying… keep the radio on and + the cable connected. + {caps?.after_write && {caps.after_write}} + +
+ )} {busy === "restore" && caps?.after_write && (
@@ -690,6 +867,23 @@ export function ProgramRadioDialog({ /> )} + {settingsResult && ( + + )} + {program && ( setConfirming(true)} - disabled={!port || busy || dirty || neverSaved || nothingSet} - title={ - dirty || neverSaved - ? "Save this profile first — the radio gets the saved values" - : nothingSet - ? "This profile holds no settings to write" - : undefined - } + disabled={!port || busy || blocked !== null} + title={blocked ?? undefined} > {busy ? : } Write to radio
- {(dirty || neverSaved || nothingSet) && ( -

- {neverSaved - ? "This profile has not been saved yet. The radio is written from the saved profile, so Save first." - : dirty - ? "This profile has unsaved changes. The radio is written from the saved profile, so Save first." - : "This profile holds no settings yet, so there is nothing to write. Download from the radio or set one, then Save."} + {blocked && ( +

{blocked}

+ )} + {busy && afterWrite && ( +

+ + {afterWrite}

)} {confirming && ( @@ -211,6 +211,7 @@ function WriteToRadioBar({ This changes settings on the radio. Channels are left untouched, and a backup of the radio is written beside the app's data first. + {afterWrite && {afterWrite}} + {canWrite && ( + + )}
- + {canWrite && writeBlocked && ( +

{writeBlocked}

+ )}
); } @@ -918,6 +975,12 @@ export function ProfileEditor({ fields.some((f) => values[f.key] !== saved[f.key]), [name, notes, values, saved, fields, profile], ); + // What every settings-write affordance on this page checks first. + const writeState = { + neverSaved: !profile.non_channel_settings, + dirty, + nothingSet: Object.values(saved).every((v) => v === ""), + }; // Values that came off the radio (or its card) are the new starting point, // not an edit — a radio is allowed to hold a value this app's schema does not @@ -1060,26 +1123,22 @@ export function ProfileEditor({ /> )} {/* The inverse, gated on the driver's write capability for the same - reason. Card radios never get it: their settings are patched - into the file the export writes, not sent over a cable. - ⚠ And only for radios on the GENERIC programming UI. A radio - with a bespoke dialog already offers this write there, in a - place that can speak to what makes it unusual — the AnyTone's - settings commit reboots the radio and re-enumerates USB, so it - reports `verified: null` plus an `expected_path` to diff in a - fresh session, and a generic bar would show neither. */} - {caps?.write_settings && - fields.length > 0 && - (model.programming_ui ?? "generic") === "generic" && ( - v === "")} - usbPort={usbPort} - /> - )} + reason — on EVERY cable radio that has it, its own Program dialog + or not, so the profile page offers the same pair everywhere (Tim, + s136). Card radios write theirs into the card file instead — + the CardSettingsBar's Write settings to card. The AnyTone's + commit reboots the radio, so its result comes back + `verified: null`; the bar reports that as unverified rather than + as a pass. */} + {caps?.write_settings && fields.length > 0 && ( + + )} {/* A card radio's settings come off its microSD rather than a cable, so it gets a file picker where the others get a port picker. Keyed on the export format — the same key that names the @@ -1090,6 +1149,9 @@ export function ProfileEditor({ format={model.export_format} read={cardReader} onLoaded={loadFromRadio} + profileId={profile.id} + canWrite={caps?.write_card_settings === true} + writeBlocked={profileWriteBlocked(writeState, "card")} /> )} {/* Stays keyed to the AnyTone on purpose: this bar drives diff --git a/src/components/radioPort.tsx b/src/components/radioPort.tsx index be64e34..c899fba 100644 --- a/src/components/radioPort.tsx +++ b/src/components/radioPort.tsx @@ -14,8 +14,12 @@ export function usePortChoice(usbPort: string | null, onError?: (e: string) => v const [ports, setPorts] = useState([]); const [port, setPort] = useState(usbPort ?? ""); - const refresh = async () => { + /// `forget` drops the current choice first — for after a radio that + /// reboots and re-enumerates USB on commit (the AnyTone), whose old port + /// name is gone. + const refresh = async (forget = false) => { if (usbPort) return; + if (forget) setPort(""); try { const list = await api.listSerialPorts(); setPorts(list); @@ -68,7 +72,7 @@ export function PortSelect({ ))} -
diff --git a/src/lib/api.ts b/src/lib/api.ts index 139b744..e6a44ae 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,5 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import type { + CardSettingsWritten, Channel, ChannelFilter, ChannelInput, @@ -367,6 +368,10 @@ export const api = { // card. Same decoded shape again. See radios/kenwood_thd75/settings.rs. readThd75SettingsFromCard: (path: string) => invoke("read_thd75_settings_from_card", { path }), + // A card radio's settings alone, patched into its own file on the card — + // every memory left as the file has it. The radio loads it from its menu. + writeCardSettings: (profileId: number, path: string) => + invoke("write_card_settings", { profileId, path }), writeRadioSettings: (port: string, profileId: number) => invoke("write_radio_settings", { port, profileId }), // One program command for every radio (3.6e). Dispatches on capability: diff --git a/src/lib/radioProgramming.ts b/src/lib/radioProgramming.ts index c3f3460..a3d0171 100644 --- a/src/lib/radioProgramming.ts +++ b/src/lib/radioProgramming.ts @@ -52,6 +52,40 @@ export function usbPortFor(driverKey: string): string { return `usb:${driverKey}`; } +/// Why a profile's settings cannot be written yet, or `null` when they can. +/// One rule for every place that writes them — the profile page's radio and +/// card bars and the Program dialog's Profile option: the radio is written +/// from the SAVED profile, so unsaved edits block, and a profile holding no +/// value at all (#128's blank start) has nothing to send. +export function profileWriteBlocked( + state: { neverSaved: boolean; dirty: boolean; nothingSet: boolean }, + destination: "radio" | "card", +): string | null { + if (state.neverSaved) { + return `This profile has not been saved yet. The ${destination} is written from the saved profile, so Save first.`; + } + if (state.dirty) { + return `This profile has unsaved changes. The ${destination} is written from the saved profile, so Save first.`; + } + if (state.nothingSet) { + return "This profile holds no settings yet, so there is nothing to write."; + } + return null; +} + +/// Whether saved `non_channel_settings` JSON holds no value at all — every +/// field blank, the state a fresh profile starts in. Unparseable JSON is not +/// "nothing": the write path reports it properly. +export function savedSettingsEmpty(json: string | null | undefined): boolean { + if (!json) return true; + try { + const v = JSON.parse(json) as Record; + return Object.values(v).every((x) => x === "" || x === null); + } catch { + return false; + } +} + /// Can this model be programmed over a cable at all? False for export-only /// models (NULL `driver_key`), which is what the generic dialog gates on. export function isProgrammable(model: RadioModel | null): boolean { diff --git a/src/lib/types.ts b/src/lib/types.ts index ac2cbfe..6ab7a6d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -202,6 +202,9 @@ export interface DriverCapabilities { /// The radio IS the USB device (the MD-380): no serial-port picker, and the /// driver finds it by its USB id. usb_direct: boolean; + /// The card file takes the profile's settings on their own (the card + /// radios' counterpart of `write_settings`). + write_card_settings: boolean; } // One field definition inside non_channel_settings_schema (JSON). @@ -721,6 +724,15 @@ export interface AnytoneVerifyResult { // `SettingsWriteReport`). `verified` is null when the driver cannot read back // in the same session — the AnyTone reboots on commit, so it verifies via a // separate fresh-session byte-diff against `expected_path`. +/// What a settings-only card write produced (`write_card_settings`). +export interface CardSettingsWritten { + fields_written: number; + /// The file written: a new one beside the radio's saves, or the picked file + /// patched in place, by the format's own rule. + path: string; + note: string | null; +} + export interface SettingsWriteReport { fields_written: number; verified: boolean | null;