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
65 changes: 65 additions & 0 deletions src-tauri/src/commands/program.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

/// 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<CardSettingsWritten, String> {
let target = profile_target(&state.pool, profile_id).await?;
let saved: Option<String> =
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
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
76 changes: 51 additions & 25 deletions src-tauri/src/radios/baofeng_uv5r/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<WrittenRange> = 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,
Expand Down Expand Up @@ -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<String>, Vec<DecodedChannel>);

/// 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<Result<Verified, String>, String> {
reident_with_retry(p)?;
let mut written: Vec<WrittenRange> = 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 {
Expand Down Expand Up @@ -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);
Expand Down
124 changes: 122 additions & 2 deletions src-tauri/src/radios/baofeng_uv5r/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<SettingsWriteReport, String> {
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;
Expand Down Expand Up @@ -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::*;
Expand Down
Loading
Loading