From 783a8d66a86751e9a6066228dc87c25175cff148 Mon Sep 17 00:00:00 2001 From: ww8l Date: Sat, 10 Oct 2026 14:34:12 -0600 Subject: [PATCH] MD-380 contacts, both ways: md380tools user DB and codeplug contact fill User DB (md380tools patched firmware): build md380tools' linear user database from the DMR Contacts library, prioritized, and write it to SPI flash at 0x100000 through the patched firmware's DFU block-1 commands, then read it all back. Gated on the patched firmware's USB strings and the 16 MB chip; every erase and write is bounds-checked above the codeplug in protocol.rs. A dropped erase (the firmware ignores one sent while the chip is busy) is resent. Hardware-proven on Tim's UHF MD-380: 269,452 users written and read back, codeplug intact. Refused on Windows (STTub30 hides the USB strings). Codeplug contacts (stock firmware): "Fill spare contacts with DMR users" under Channel set fills the codeplug's 1000 contacts with prioritized users after the talkgroups. Hardware-proven: verified, radio's Contacts list = talkgroups then the users. The Program dialog gains a User DB card; the AnyTone's DMR-user picker is now a shared component. write_callsign_db resolves the radio from the codeplug, so no profile is needed; its report says whether the driver read the DB back. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/commands/export.rs | 5 + src-tauri/src/commands/program.rs | 98 ++++-- .../radios/anytone_atd890uv/callsign_db.rs | 1 + src-tauri/src/radios/driver.rs | 36 ++ src-tauri/src/radios/icom_id52/mod.rs | 2 + src-tauri/src/radios/kenwood_thd75/mod.rs | 2 + .../src/radios/kenwood_tmd710/program.rs | 1 + src-tauri/src/radios/registry.rs | 23 +- src-tauri/src/radios/tyt_md380/mod.rs | 75 +++++ src-tauri/src/radios/tyt_md380/program.rs | 121 +++++++ src-tauri/src/radios/tyt_md380/protocol.rs | 136 ++++++++ src-tauri/src/radios/tyt_md380/userdb.rs | 316 ++++++++++++++++++ src-tauri/src/radios/yaesu_ft5d/mod.rs | 2 + .../codeplugs/AnytoneProgramDialog.tsx | 193 +---------- src/components/codeplugs/DmrUserPicker.tsx | 222 ++++++++++++ .../codeplugs/ProgramRadioDialog.tsx | 282 +++++++++++++--- src/lib/api.ts | 12 +- src/lib/types.ts | 10 + 18 files changed, 1275 insertions(+), 262 deletions(-) create mode 100644 src-tauri/src/radios/tyt_md380/userdb.rs create mode 100644 src/components/codeplugs/DmrUserPicker.tsx diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index eaf79d3..c76578c 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -248,6 +248,9 @@ pub(crate) struct ResolvedCodeplug { pub channels: Vec, pub scan_lists: Vec, pub scan_list_overrides: Vec, + /// See [`CodeplugPayload::contact_users`](crate::radios::driver::CodeplugPayload); + /// empty here, filled by `program_radio` when the operator asks. + pub contact_users: Vec, } impl ResolvedCodeplug { @@ -259,6 +262,7 @@ impl ResolvedCodeplug { channels: &self.channels, scan_lists: &self.scan_lists, scan_list_overrides: &self.scan_list_overrides, + contact_users: &self.contact_users, } } } @@ -367,6 +371,7 @@ pub(crate) async fn resolve_codeplug_payload( channels, scan_lists, scan_list_overrides, + contact_users: Vec::new(), }) } diff --git a/src-tauri/src/commands/program.rs b/src-tauri/src/commands/program.rs index fd2512b..9426e79 100644 --- a/src-tauri/src/commands/program.rs +++ b/src-tauri/src/commands/program.rs @@ -856,42 +856,28 @@ pub async fn write_card_settings( /// the local `dmr_users` library and hand it to the driver, which owns the /// on-flash layout and the write discipline. /// -/// Radio-wide rather than codeplug-scoped, but profile-anchored like the other -/// settings commands so the driver resolves the same way. Verification is -/// FUNCTIONAL (the radio's own caller-ID screen) — this region has no golden -/// image to byte-diff. Brick-capable; UI-gated. +/// Radio-wide rather than codeplug-scoped: the codeplug only names the radio. +/// Verification is the driver's — the AnyTone's is FUNCTIONAL (the radio's own +/// caller-ID screen), the MD-380 reads its database back (`verified`). +/// Brick-capable; UI-gated. /// /// Registry-dispatched (Chunk 3.6d) — replaces `program_anytone_callsign_db`. #[tauri::command] pub async fn write_callsign_db( state: State<'_, AppState>, - profile_id: i64, + codeplug_id: i64, port: String, max_count: Option, priority_countries: Vec, ) -> Result { - let target = profile_target(&state.pool, profile_id).await?; - let db_writer = target.driver.as_callsign_db_writer().ok_or_else(|| { - format!( - "{} has no on-board call-sign database", - target.driver.display_name() - ) - })?; + // The radio comes from the codeplug's model, as for `program_radio`: the + // database is the library's, so a profile has nothing to add to it. + let model = export::codeplug_model(&state.pool, codeplug_id).await?; + let db_writer = registry::driver_for_model(&model) + .and_then(|d| d.as_callsign_db_writer()) + .ok_or_else(|| format!("{} has no on-board call-sign database", model.display_name))?; - let users = select_prioritized_dmr_users(&state.pool, max_count, &priority_countries).await?; - let mut records: Vec = users - .into_iter() - .map(|u| CallsignRecord { - dmr_id: u.dmr_id as u32, - group_call: false, - name: u.display_name(), - callsign: u.callsign, - city: u.city, - state: u.state, - country: u.country, - comment: u.remarks, - }) - .collect(); + let mut records = user_records(&state.pool, max_count, &priority_countries).await?; // Append the talkgroup library as GROUP-call entries (issue #30). The // caller-ID DB is what the radio's TX screen resolves a talkgroup ID @@ -932,11 +918,7 @@ pub async fn write_callsign_db( // talkgroups but no downloaded user library is still worth writing — it is // exactly what gives the TX screen its names. if records.is_empty() { - return Err( - "No DMR contacts or talkgroups to write — open DMR Contacts and Refresh the \ - library first (or widen the country selection)." - .to_string(), - ); + return Err(crate::radios::driver::NO_DMR_USERS.to_string()); } tauri::async_runtime::spawn_blocking(move || { @@ -949,6 +931,36 @@ pub async fn write_callsign_db( .estr()? } +/// The library's DMR users, capped and prioritized, as call-sign records. +async fn user_records( + pool: &sqlx::SqlitePool, + max_count: Option, + priority_countries: &[String], +) -> Result, String> { + let users = select_prioritized_dmr_users(pool, max_count, priority_countries).await?; + Ok(users + .into_iter() + .map(|u| CallsignRecord { + dmr_id: u.dmr_id as u32, + group_call: false, + name: u.display_name(), + callsign: u.callsign, + city: u.city, + state: u.state, + country: u.country, + comment: u.remarks, + }) + .collect()) +} + +/// Which library users a program puts in the codeplug's spare contacts — the +/// same cap and priority the call-sign DB write takes. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct UserContactFill { + pub max_count: Option, + pub priority_countries: Vec, +} + // ============================================================ // Write / upload // ============================================================ @@ -976,6 +988,7 @@ pub async fn program_radio( state: State<'_, AppState>, codeplug_id: i64, port: String, + user_contacts: Option, ) -> Result { let model = export::codeplug_model(&state.pool, codeplug_id).await?; let driver = registry::driver_for_model(&model).ok_or_else(|| { @@ -985,13 +998,34 @@ pub async fn program_radio( ) })?; + // Refused for every other driver, not ignored: a fill the operator chose + // must not silently become a program without it. + let capacity = driver.as_codeplug_programmer().and_then(|p| p.user_contact_capacity()); + if user_contacts.is_some() && capacity.is_none() { + return Err(format!( + "{} has no codeplug contact list to fill with DMR users", + model.display_name + )); + } + let backup_dir = app.path().app_data_dir().estr()?.join("radio-backups"); std::fs::create_dir_all(&backup_dir).estr()?; let report = if let Some(programmer) = driver.as_codeplug_programmer() { // `resolved` owns every row the planner needs, so it moves into the // blocking task wholesale — the payload borrows from it in there. - let resolved = export::resolve_codeplug_payload(&state.pool, codeplug_id).await?; + let mut resolved = export::resolve_codeplug_payload(&state.pool, codeplug_id).await?; + if let (Some(fill), Some(cap)) = (&user_contacts, capacity) { + // Never more than the radio can hold, whatever the request says: + // the planner keeps at most `cap`, so the rest is only memory. + let cap = cap as i64; + let max = fill.max_count.map_or(cap, |m| m.min(cap)); + resolved.contact_users = + user_records(&state.pool, Some(max), &fill.priority_countries).await?; + if resolved.contact_users.is_empty() { + return Err(crate::radios::driver::NO_DMR_USERS.to_string()); + } + } let report = tauri::async_runtime::spawn_blocking(move || { // One radio operation per port: this guard lives for the whole // blocking session and releases on the way out, panic included. (#67) diff --git a/src-tauri/src/radios/anytone_atd890uv/callsign_db.rs b/src-tauri/src/radios/anytone_atd890uv/callsign_db.rs index d9cadf4..1733aac 100644 --- a/src-tauri/src/radios/anytone_atd890uv/callsign_db.rs +++ b/src-tauri/src/radios/anytone_atd890uv/callsign_db.rs @@ -322,6 +322,7 @@ impl CallsignDbWriter for AnytoneAtd890uv { entries_written, windows_written: result.windows_written, note: result.note, + verified: false, }) } } diff --git a/src-tauri/src/radios/driver.rs b/src-tauri/src/radios/driver.rs index e5d13b2..494a527 100644 --- a/src-tauri/src/radios/driver.rs +++ b/src-tauri/src/radios/driver.rs @@ -438,6 +438,11 @@ pub(crate) struct CodeplugPayload<'a> { /// Explicit per-channel scan-list assignments (`codeplug_channel_scan_lists`). /// A list named here is *manually managed* — see `scan-list-per-channel-field`. pub scan_list_overrides: &'a [ChannelScanListOverride], + /// DMR users from the local library to fill the codeplug's spare contact + /// slots with, already prioritized — so a stock radio names a caller from + /// its own contact list. Empty unless the operator asked for it, and read + /// only by a driver with a [`CodeplugProgrammer::user_contact_capacity`]. + pub contact_users: &'a [CallsignRecord], } /// A channel that could not be programmed, and why. Surfaced in the preview so @@ -521,6 +526,13 @@ pub(crate) trait CodeplugProgrammer: Send + Sync { payload: &CodeplugPayload, backup_dir: &Path, ) -> Result; + + /// How many contacts the codeplug holds, when [`CodeplugPayload::contact_users`] + /// reaches the radio: its contact list doubles as a caller-ID list (the + /// MD-380's 1000). `None` for a radio that ignores them. + fn user_contact_capacity(&self) -> Option { + None + } } /// Radios programmed by targeted per-channel/bank writes rather than a full @@ -536,6 +548,10 @@ pub(crate) trait ChannelWriter { ) -> Result; } +/// What every DMR-user write says when the library selection is empty. +pub(crate) const NO_DMR_USERS: &str = "No DMR users to write — open DMR Contacts and Refresh \ + the library first (or widen the country selection)."; + /// Outcome of a committed call-sign DB write. #[derive(Debug, Clone, serde::Serialize)] pub struct CallsignDbReport { @@ -546,6 +562,9 @@ pub struct CallsignDbReport { /// How to verify — this region has no golden image to byte-diff, so /// verification is functional (the radio's own caller-ID screen). pub note: String, + /// Whether the driver read the database back and found it as written. + /// The AnyTone's is checked on the radio's screen instead, so `false`. + pub verified: bool, } /// Radios that can take one of their own backups and put it back. @@ -585,6 +604,12 @@ pub(crate) trait CallsignDbWriter: Send + Sync { port: &str, records: &[CallsignRecord], ) -> Result; + + /// What the radio needs before this write can work, for the Program + /// dialog to say up front (the MD-380's patched firmware). + fn requirement(&self) -> Option<&'static str> { + None + } } /// Everything an exporter gets to write a codeplug file. The file-side twin of @@ -689,6 +714,11 @@ pub struct DriverCapabilities { /// third of a second against four minutes.) pub programs_settings: bool, pub write_callsign_db: bool, + /// [`CallsignDbWriter::requirement`]. + pub callsign_db_requirement: Option<&'static str>, + /// [`CodeplugProgrammer::user_contact_capacity`]: a program can fill the + /// codeplug's spare contacts with DMR users, up to this many contacts. + pub codeplug_contact_capacity: Option, pub export: bool, pub diagnostics: bool, /// [`ImageProgrammer::after_write_instruction`], for the Program dialog. @@ -717,6 +747,12 @@ impl DriverCapabilities { .as_image_programmer() .is_some_and(ImageProgrammer::carries_profile_settings), write_callsign_db: driver.as_callsign_db_writer().is_some(), + callsign_db_requirement: driver + .as_callsign_db_writer() + .and_then(CallsignDbWriter::requirement), + codeplug_contact_capacity: driver + .as_codeplug_programmer() + .and_then(|p| p.user_contact_capacity()), export: driver.as_codeplug_exporter().is_some(), diagnostics: driver.as_diagnostics().is_some(), after_write: driver diff --git a/src-tauri/src/radios/icom_id52/mod.rs b/src-tauri/src/radios/icom_id52/mod.rs index 902c4c6..8368ebd 100644 --- a/src-tauri/src/radios/icom_id52/mod.rs +++ b/src-tauri/src/radios/icom_id52/mod.rs @@ -191,6 +191,8 @@ mod tests { program_codeplug: false, programs_settings: false, write_callsign_db: false, + callsign_db_requirement: None, + codeplug_contact_capacity: None, export: true, diagnostics: false, after_write: None, diff --git a/src-tauri/src/radios/kenwood_thd75/mod.rs b/src-tauri/src/radios/kenwood_thd75/mod.rs index c3d3961..f0647ef 100644 --- a/src-tauri/src/radios/kenwood_thd75/mod.rs +++ b/src-tauri/src/radios/kenwood_thd75/mod.rs @@ -127,6 +127,8 @@ mod tests { program_codeplug: false, programs_settings: false, write_callsign_db: false, + callsign_db_requirement: None, + codeplug_contact_capacity: None, export: true, diagnostics: false, after_write: None, diff --git a/src-tauri/src/radios/kenwood_tmd710/program.rs b/src-tauri/src/radios/kenwood_tmd710/program.rs index 3c360fb..e93b392 100644 --- a/src-tauri/src/radios/kenwood_tmd710/program.rs +++ b/src-tauri/src/radios/kenwood_tmd710/program.rs @@ -361,6 +361,7 @@ mod tests { channels: chans, scan_lists: &[], scan_list_overrides: &[], + contact_users: &[], } } diff --git a/src-tauri/src/radios/registry.rs b/src-tauri/src/radios/registry.rs index 4c628db..fe49a22 100644 --- a/src-tauri/src/radios/registry.rs +++ b/src-tauri/src/radios/registry.rs @@ -172,20 +172,35 @@ mod tests { } } - /// Only the AnyTone carries an on-board call-sign DB, so `write_callsign_db` - /// must reject the others by name rather than opening their port. + /// Only the AnyTone and the MD-380 (md380tools' user DB) carry an on-board + /// call-sign DB, so `write_callsign_db` must reject the others by name + /// rather than opening their port. #[test] - fn only_the_anytone_writes_a_callsign_db() { + fn only_the_anytone_and_md380_write_a_callsign_db() { for d in all_drivers() { assert_eq!( d.as_callsign_db_writer().is_some(), - d.key() == "anytone_atd890uv", + matches!(d.key(), "anytone_atd890uv" | "tyt_md380"), "{} call-sign DB capability", d.key() ); } } + /// Only the MD-380's codeplug contacts double as its caller-ID list, so + /// only its program takes a fill of DMR users. + #[test] + fn only_the_md380_fills_codeplug_contacts_with_users() { + for d in all_drivers() { + assert_eq!( + d.as_codeplug_programmer().and_then(|p| p.user_contact_capacity()), + (d.key() == "tyt_md380").then_some(1000), + "{} codeplug user contacts", + d.key() + ); + } + } + /// `generate_codeplug` (3.8) resolves an exporter from the model's /// `export_format` string, so a renamed key silently downgrades a DMR export /// to the CHIRP analog CSV instead of failing. Lock the key, and lock the diff --git a/src-tauri/src/radios/tyt_md380/mod.rs b/src-tauri/src/radios/tyt_md380/mod.rs index 1741d68..a46e4eb 100644 --- a/src-tauri/src/radios/tyt_md380/mod.rs +++ b/src-tauri/src/radios/tyt_md380/mod.rs @@ -9,6 +9,7 @@ //! - `memory` — channel / zone / contact / RX group / scan list records. //! - `program` — the codeplug build: a database codeplug into a read image. //! - `settings` — General Settings, side buttons and menu switches. +//! - `userdb` — md380tools' caller-ID database in SPI flash (patched firmware). //! - `stt` — Windows: the same requests through ST's STTub30 driver. //! - `protocol` — the DFU session. Read measured on Tim's radio; write is //! dmrconfig's sequence against a fake, NOT yet run on hardware. @@ -22,6 +23,7 @@ pub(crate) mod memory; pub(crate) mod program; pub(crate) mod protocol; pub(crate) mod settings; +pub(crate) mod userdb; #[cfg(windows)] mod stt; #[cfg(test)] @@ -100,6 +102,73 @@ impl RadioDriver for TytMd380 { fn as_settings_writer(&self) -> Option<&dyn crate::radios::driver::SettingsWriter> { Some(self) } + + /// md380tools' caller-ID database — patched firmware only, checked by USB + /// name before anything is sent (`userdb.rs`). + fn as_callsign_db_writer(&self) -> Option<&dyn crate::radios::driver::CallsignDbWriter> { + Some(self) + } +} + +impl crate::radios::driver::CallsignDbWriter for TytMd380 { + fn requirement(&self) -> Option<&'static str> { + Some( + "Needs md380tools' patched firmware on the radio, and macOS or Linux — on \ + TYT's own firmware, choose Channel set and fill the spare contacts with DMR \ + users instead.", + ) + } + + /// Build md380tools' linear user database from the records and write it to + /// SPI flash at 0x100000, then read it all back. + /// + /// No backup is taken, as on the AnyTone: the region is self-contained and + /// regenerable from the DMR Contacts library, and nothing else lives in it + /// — every erase and write is bounds-checked to it in `protocol.rs`. + fn write_callsign_db( + &self, + _port: &str, + records: &[crate::radios::driver::CallsignRecord], + ) -> Result { + // ⚠ Before ANY tool command: stock firmware never sees one. + if !protocol::patched_firmware()? { + return Err("This MD-380 runs TYT's stock firmware, which has no caller-ID \ + database — that needs md380tools' patched firmware. On stock firmware \ + caller names come from the codeplug's own contact list: program the \ + codeplug with \"Fill spare contacts with DMR users\" instead." + .into()); + } + let userdb::Built { image, users, left_out } = userdb::build(records); + if users == 0 { + return Err(crate::radios::driver::NO_DMR_USERS.into()); + } + let blocks = userdb::blocks_for(image.len()); + // No band check: the database is the same on every band, and a VHF + // unit on md380tools takes it as readily as Tim's UHF one. + session(|link, _id| userdb::write(link, &image))?; + Ok(crate::radios::driver::CallsignDbReport { + entries_written: users, + windows_written: vec![format!( + "0x{:06X}-0x{:06X}", + blocks[0], + blocks[blocks.len() - 1] + 0xFFFF + )], + note: format!( + "{users} users written to the radio's flash and read back. The radio shows them \ + only when md380tools' menu is set to: Utilities > MD380Tools > Display Setup > \ + Show Calls > User DB (or TA & UserDB).{}", + if left_out > 0 { + format!( + " {left_out} lower-priority users did not fit in the 15 MB the \ + firmware reads and were left out." + ) + } else { + String::new() + } + ), + verified: true, + }) + } } fn identity(id: &Ident) -> RadioIdentity { @@ -273,6 +342,12 @@ impl CodeplugProgrammer for TytMd380 { Ok(program::plan_program(payload)?.preview()) } + /// Stock firmware names a private caller only from the codeplug's own + /// contacts, so its 1000 slots are the caller-ID list a stock radio has. + fn user_contact_capacity(&self) -> Option { + Some(layout::CONTACT_COUNT) + } + fn program( &self, _port: &str, diff --git a/src-tauri/src/radios/tyt_md380/program.rs b/src-tauri/src/radios/tyt_md380/program.rs index 8823a6c..63f6a0a 100644 --- a/src-tauri/src/radios/tyt_md380/program.rs +++ b/src-tauri/src/radios/tyt_md380/program.rs @@ -353,6 +353,40 @@ pub(crate) fn plan_program(payload: &CodeplugPayload) -> Result { )); } + // ---- spare contacts: DMR users, so stock firmware names a private caller + // from its own list. After the talkgroups, which own the low indexes the + // channels point at; in the library's priority order, so a full list keeps + // the operator's chosen countries. A user whose ID is already a private + // contact (a talkgroup row marked Private) is that contact already. + let mut users = 0usize; + let mut left_out = 0usize; + for u in payload.contact_users.iter().filter(|u| !u.group_call) { + if !(1..=0xFF_FFFF).contains(&u.dmr_id) || contact_of.contains_key(&(u.dmr_id as i64, true)) { + continue; + } + if contacts.len() >= CONTACT_COUNT { + left_out += 1; + continue; + } + contact_of.insert((u.dmr_id as i64, true), contacts.len() as u16 + 1); + let first = u.name.split_whitespace().next().unwrap_or(""); + let label = format!("{} {first}", u.callsign.trim()); + contacts.push(Contact { + id: u.dmr_id, + call_type: CallType::Private, + name: truncate(label.trim(), NAME_UNITS), + flags: Contact::UNUSED[3] & !3, + }); + users += 1; + } + if left_out > 0 { + warnings.push(format!( + "{users} DMR users added as contacts; {left_out} more did not fit — the MD-380 holds \ + {CONTACT_COUNT} contacts and the talkgroups use {}", + contacts.len() - users + )); + } + // ---- zones: one per channel list, split into 16s let mut zones = Vec::new(); for g in payload.groups { @@ -496,6 +530,89 @@ mod tests { } } + fn user(id: u32, call: &str, name: &str) -> crate::radios::driver::CallsignRecord { + crate::radios::driver::CallsignRecord { + dmr_id: id, + group_call: false, + callsign: call.into(), + name: name.into(), + city: None, + state: None, + country: None, + comment: None, + } + } + + /// Users follow the talkgroups — whose indexes the channels point at — as + /// Private contacts named "CALL First", in the order given; a group record, + /// an ID that is no DMR ID, and a repeat are left out. + #[test] + fn library_users_fill_the_contacts_after_the_talkgroups() { + let m = model(); + let mut rpt = chan(1, "RMH700", 445.2, "DMR"); + rpt.dmr_color_code = Some(7); + let rows = vec![row(rpt.clone(), Some((3108, "CO", 1))), row(rpt, Some((91, "WW", 1)))]; + let mut tg = user(3108, "", "Colorado"); + tg.group_call = true; + let users = vec![ + user(3108176, "WW8L", "Tim Annable"), + tg, + user(0, "BAD", "No Id"), + user(1023001, "K0ABC", "Jose Ruiz Garcia"), + user(3108176, "WW8L", "Tim Annable"), + user(3100001, "KD0LONGCALL", "Maximiliano"), + ]; + let p = plan_program(&CodeplugPayload { + model: &m, + groups: &[], + channels: &rows, + scan_lists: &[], + scan_list_overrides: &[], + contact_users: &users, + }) + .unwrap(); + assert_eq!((p.channels[0].contact, p.channels[1].contact), (1, 2)); + let got: Vec<_> = p.contacts.iter().map(|c| (c.id, c.call_type, c.name.as_str())).collect(); + assert_eq!( + got, + [ + (3108, CallType::Group, "CO"), + (91, CallType::Group, "WW"), + (3108176, CallType::Private, "WW8L Tim"), + (1023001, CallType::Private, "K0ABC Jose"), + (3100001, CallType::Private, "KD0LONGCALL Maxi"), + ] + ); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + } + + /// A full list keeps the talkgroups and the first users, and says how many + /// users it dropped. + #[test] + fn users_past_the_thousandth_contact_are_dropped_with_a_warning() { + let m = model(); + let rpt = chan(1, "RMH700", 445.2, "DMR"); + let rows = vec![row(rpt.clone(), Some((3108, "CO", 1))), row(rpt, Some((91, "WW", 1)))]; + let users: Vec<_> = (0..1005).map(|i| user(3_000_000 + i, "N0CALL", "Op")).collect(); + let p = plan_program(&CodeplugPayload { + model: &m, + groups: &[], + channels: &rows, + scan_lists: &[], + scan_list_overrides: &[], + contact_users: &users, + }) + .unwrap(); + assert_eq!(p.contacts.len(), CONTACT_COUNT); + assert_eq!(p.contacts[2].id, 3_000_000); + assert_eq!(p.contacts[CONTACT_COUNT - 1].id, 3_000_000 + 997); + assert!(p.warnings.iter().any(|w| w.contains("998 DMR users") && w.contains("7 more")), "{:?}", p.warnings); + // and the image the plan writes holds all 1000 + let base = vec![0xFFu8; IMAGE_LEN]; + let img = p.apply(&base).unwrap(); + assert_eq!(count_used(&img).contacts, CONTACT_COUNT); + } + #[test] fn a_repeater_with_two_talkgroups_is_two_channels_sharing_contacts_and_one_zone() { let m = model(); @@ -520,6 +637,7 @@ mod tests { channels: &rows, scan_lists: &[], scan_list_overrides: &[], + contact_users: &[], }) .unwrap(); assert_eq!(p.channels.len(), 3); @@ -564,6 +682,7 @@ mod tests { channels: &rows, scan_lists: &[], scan_list_overrides: &[], + contact_users: &[], }) .unwrap(); assert_eq!(p.zones.len(), 2); @@ -587,6 +706,7 @@ mod tests { channels: &rows, scan_lists: &[], scan_list_overrides: &[], + contact_users: &[], }) .unwrap(); let mut base = vec![0u8; IMAGE_LEN]; @@ -631,6 +751,7 @@ mod tests { channels: &rows, scan_lists: &sl, scan_list_overrides: &[], + contact_users: &[], }) .unwrap(); let mut base = vec![0xA5u8; IMAGE_LEN]; diff --git a/src-tauri/src/radios/tyt_md380/protocol.rs b/src-tauri/src/radios/tyt_md380/protocol.rs index c0c68f1..2fdf99e 100644 --- a/src-tauri/src/radios/tyt_md380/protocol.rs +++ b/src-tauri/src/radios/tyt_md380/protocol.rs @@ -438,6 +438,90 @@ pub(crate) fn write_sectors(l: &mut dyn Link, image: &[u8], sectors: &[u32]) -> wait_idle(l) } +// --- md380tools' SPI-flash commands (patched firmware only) --------------- +// +// The patched firmware hooks DFU block 1 for its own commands (`tooldfu.h`): +// a DNLOAD to wValue 1 carrying a command byte and little-endian u32 args, +// then status, then — for a read — an UPLOAD from wValue 1. Stock firmware has +// none of this, which is why the caller gates on the patched firmware's USB +// strings first. Every address is bounds-checked HERE, below the callers, so +// nothing in this module can erase or write the codeplug (0x0-0x3FFFF) or the +// patched firmware's settings (0xF0000-0xFFFFF). + +const TOOL_BLOCK: u16 = 1; +/// Lowest address a tool erase or write may touch: md380tools' user database. +const TOOL_FLOOR: u32 = 0x10_0000; +const TOOL_CEILING: u32 = 0x100_0000; + +fn tool_command(l: &mut dyn Link, data: &[u8]) -> Result<(), String> { + l.ctl_out(DNLOAD, TOOL_BLOCK, data)?; + wait_dnload_idle(l) +} + +/// The SPI flash's JEDEC id (`ef 40 18` = the 16 MB W25Q128). +pub(crate) fn spi_flash_id(l: &mut dyn Link) -> Result<[u8; 3], String> { + tool_command(l, &[0x05])?; + let id = l.ctl_in(UPLOAD, TOOL_BLOCK, 4)?; + id.get(..3) + .and_then(|b| b.try_into().ok()) + .ok_or_else(|| "the radio did not report its flash chip".to_string()) +} + +/// 1 KiB of SPI flash at `addr`. +pub(crate) fn spi_read(l: &mut dyn Link, addr: u32) -> Result, String> { + let a = addr.to_le_bytes(); + tool_command(l, &[0x01, a[0], a[1], a[2], a[3]])?; + let data = l.ctl_in(UPLOAD, TOOL_BLOCK, 1024)?; + if data.len() != 1024 { + return Err(format!("the radio returned {} bytes of flash at {addr:#x}", data.len())); + } + Ok(data) +} + +/// Erase the 64 KiB block at `addr`. Returns before the chip has finished. +pub(crate) fn spi_erase_64k(l: &mut dyn Link, addr: u32) -> Result<(), String> { + assert!( + addr >= TOOL_FLOOR && addr.is_multiple_of(0x1_0000) && addr < TOOL_CEILING, + "refusing to erase {addr:#x}: outside the user-database region" + ); + let a = addr.to_le_bytes(); + tool_command(l, &[0x03, a[0], a[1], a[2], a[3]]) +} + +/// Program `data` (at most 1 KiB, 256-byte pages) at `addr`. +pub(crate) fn spi_write(l: &mut dyn Link, addr: u32, data: &[u8]) -> Result<(), String> { + assert!( + addr >= TOOL_FLOOR + && addr.is_multiple_of(256) + && data.len() <= 1024 + && addr as usize + data.len() <= TOOL_CEILING as usize, + "refusing to write {addr:#x}: outside the user-database region" + ); + let mut msg = vec![0x04]; + msg.extend_from_slice(&addr.to_le_bytes()); + msg.extend_from_slice(&(data.len() as u32).to_le_bytes()); + msg.extend_from_slice(data); + tool_command(l, &msg) +} + +/// Whether the radio on the bus runs md380tools' patched firmware, from its +/// USB strings alone ("Patched MD380" / "Travis Goodspeed KK4VCZ") — before +/// anything is sent to block 1, which stock firmware does not implement. +pub(crate) fn patched_firmware() -> Result { + #[cfg(windows)] + if super::stt::present() { + // TYT's driver does not hand us the USB strings without opening the + // device, so the firmware cannot be confirmed — and an unconfirmed + // radio gets nothing on block 1. + return Err("Writing the user database needs the radio's USB name, which TYT's Windows \ + driver does not expose. Use the app on macOS or Linux for this step." + .into()); + } + let info = find_usb()?; + Ok(info.product_string().is_some_and(|p| p.trim() == "Patched MD380") + || info.manufacturer_string().is_some_and(|m| m.trim() == "Travis Goodspeed KK4VCZ")) +} + /// Wait for the radio to come back on the bus after [`reboot`]. Measured s136: /// it re-enumerates on its own within a few seconds, on the Mac and on Windows. pub(crate) fn wait_for_radio() -> Result<(), String> { @@ -481,6 +565,15 @@ pub(crate) mod fake { last_cmd: Vec, /// (wValue, data, busy seen) pending: Option<(u16, Vec, bool)>, + /// md380tools' SPI flash (16 MB), created on first use. The codeplug is + /// its first 256 KiB on a real radio; it is kept apart here so a stray + /// erase of it shows up as a changed `image`. + pub flash: Option>, + pub flash_id: [u8; 3], + /// Erases the fake drops, once each — what the real firmware does with + /// an erase that arrives while the chip is still busy. + pub ignore_erase: Vec, + tool_reply: Vec, } /// Tim's MD-380's ident reply, as measured s136. @@ -501,10 +594,50 @@ pub(crate) mod fake { addr_block: None, last_cmd: vec![], pending: None, + flash: None, + flash_id: [0xEF, 0x40, 0x18], + ignore_erase: vec![], + tool_reply: vec![], } } + fn tool(&mut self, data: &[u8]) -> Result<(), String> { + let flash = self.flash.get_or_insert_with(|| vec![0xA5; 0x100_0000]); + let u32le = |b: &[u8]| u32::from_le_bytes(b.try_into().unwrap()) as usize; + match data { + [0x05] => self.tool_reply = self.flash_id.to_vec(), + [0x01, a @ ..] => { + let at = u32le(a); + self.tool_reply = flash[at..at + 1024].to_vec(); + } + [0x03, a @ ..] => { + let at = u32le(a) & !0xFFFF; + if let Some(i) = self.ignore_erase.iter().position(|&x| x == at) { + self.ignore_erase.remove(i); + self.log.push(format!("erase {at:#x} ignored")); + return Ok(()); + } + if at < 0x4_0000 { + let end = (at + 0x1_0000).min(IMAGE_LEN); + self.image[at..end].fill(0xFF); + } + flash[at..at + 0x1_0000].fill(0xFF); + self.log.push(format!("erase {at:#x}")); + } + [0x04, rest @ ..] => { + let (at, n) = (u32le(&rest[..4]), u32le(&rest[4..8])); + flash[at..at + n].copy_from_slice(&rest[8..8 + n]); + self.log.push(format!("spi write {at:#x}")); + } + other => return Err(format!("unexpected tool command {other:02x?}")), + } + Ok(()) + } + fn commit(&mut self, value: u16, data: Vec) -> Result<(), String> { + if value == TOOL_BLOCK { + return self.tool(&data); + } if value == 0 { self.log.push(format!("cmd {}", hex(&data))); match data.as_slice() { @@ -575,6 +708,9 @@ pub(crate) mod fake { } Ok(vec![0, 5, 0, 0, self.state, 0]) } + UPLOAD if value == TOOL_BLOCK => { + Ok(self.tool_reply[..(len as usize).min(self.tool_reply.len())].to_vec()) + } UPLOAD if value == 0 && self.last_cmd == [0xA2, 0x01] => { self.state = STATE_UPLOAD_IDLE; Ok(self.ident.clone()) diff --git a/src-tauri/src/radios/tyt_md380/userdb.rs b/src-tauri/src/radios/tyt_md380/userdb.rs new file mode 100644 index 0000000..4e478f0 --- /dev/null +++ b/src-tauri/src/radios/tyt_md380/userdb.rs @@ -0,0 +1,316 @@ +//! md380tools' user database: caller ID for radios running Travis Goodspeed's +//! patched firmware. +//! +//! The patched firmware looks a caller's DMR ID up in a text database it keeps +//! in the radio's SPI flash at 0x100000, above the codeplug (0x0-0x3FFFF) and +//! its own menu settings (0xF0000-0xFFFFF). Stock firmware ignores the region, +//! and has no commands to write it, so this is patched-firmware only. +//! +//! Sources: md380tools (`Makefile` flashdb, `applet/src/usersdb.c`, +//! `md380_tool.py`, `applet/src/tooldfu.h`) and editcp (`dfu.go` +//! `WriteMD380Users`, `userdb.go`) — see scratchpad/tyt_md380/CONTACTS- +//! RESEARCH.md. Hardware-proven on Tim's UHF MD-380 (2026-10-10): 269,452 +//! users written and read back, the codeplug intact after. +//! +//! ## Format — md380tools' "linear" database +//! +//! The body's byte count in ASCII decimal, `\n`, then one line per user, +//! `ID,CALLSIGN,NAME,CITY,STATE,NICKNAME,COUNTRY\n`, **sorted by ID** (the +//! firmware binary-searches it). ASCII only, no commas inside a field, and the +//! firmware reads at most 100 bytes of a line. Built the way editcp builds it, +//! not with md380tools' awk scripts, which assume an older radioid column order. +//! +//! The indexed format newer firmware also reads is half the size but needs a +//! newer build; the linear one is read by every patched build. + +use std::time::Duration; + +use super::protocol::{self, Link}; +use crate::radios::driver::CallsignRecord; + +/// Where the database starts, and the lowest address this module will ever +/// erase or write. Everything below is the codeplug and the firmware's own +/// settings. +pub(crate) const BASE: u32 = 0x10_0000; +/// The 16 MB chip a database needs. Some VHF units carry a 1 MB part and +/// cannot hold one at all. +pub(crate) const FLASH_SIZE: u32 = 0x100_0000; +/// The firmware refuses a declared size over 15 MB − 1. +pub(crate) const MAX_BODY: usize = 15 * 1024 * 1024 - 1; +const BLOCK: u32 = 0x1_0000; +const CHUNK: usize = 1024; +/// The firmware's per-line read limit. +const MAX_LINE: usize = 100; + +/// Plain ASCII with no commas, trimmed, double spaces collapsed — editcp's +/// `normalize`. Anything outside printable ASCII is dropped (a transliteration +/// table would need a dependency; the firmware cannot show it either way). +fn field(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.trim().chars() { + let c = if c == ',' { ';' } else { c }; + if !(' '..='~').contains(&c) { + continue; + } + if c == ' ' && out.ends_with(' ') { + continue; + } + out.push(c); + } + out.trim().to_string() +} + +/// A built database and how much of the selection it holds. +pub(crate) struct Built { + pub image: Vec, + pub users: usize, + /// Users past the 15 MB the firmware reads, lowest priority first to go. + pub left_out: usize, +} + +/// The database image: `\n`, users only, sorted by ID, first +/// occurrence of an ID kept. Talkgroups are not callers and are left out — +/// their names come from the codeplug's own contact list. +/// +/// `records` arrive in the library's priority order, so what does not fit in +/// the firmware's 15 MB is the end of that order, not the top of the ID range. +pub(crate) fn build(records: &[CallsignRecord]) -> Built { + // Room for the header too: the whole image has to end inside the chip, + // and "15728639\n" is 9 bytes. + let budget = MAX_BODY - 16; + let mut seen = std::collections::HashSet::new(); + let mut lines: Vec<(u32, String)> = Vec::new(); + let mut size = 0usize; + let mut left_out = 0usize; + for u in records.iter().filter(|r| !r.group_call) { + if !seen.insert(u.dmr_id) { + continue; + } + let name = field(&u.name); + let nick = name.split(' ').next().unwrap_or("").to_string(); + let mut line = format!( + "{},{},{},{},{},{},{}", + u.dmr_id, + field(&u.callsign), + name, + field(u.city.as_deref().unwrap_or("")), + field(u.state.as_deref().unwrap_or("")), + nick, + field(u.country.as_deref().unwrap_or("")), + ); + // A line past the firmware's read limit loses its tail fields; cut + // it here instead, on a char boundary (it is ASCII). + line.truncate(MAX_LINE - 1); + if left_out > 0 || size + line.len() + 1 > budget { + left_out += 1; + continue; + } + size += line.len() + 1; + lines.push((u.dmr_id, line)); + } + lines.sort_by_key(|(id, _)| *id); + let mut body = String::with_capacity(size); + for (_, line) in &lines { + body.push_str(line); + body.push('\n'); + } + Built { image: format!("{}\n{body}", body.len()).into_bytes(), users: lines.len(), left_out } +} + +/// The 64 KiB blocks an image starting at [`BASE`] occupies. Every one is +/// at or above `BASE`, block-aligned, and inside the chip — asserted, because +/// an erase below `BASE` takes the codeplug with it. +pub(crate) fn blocks_for(len: usize) -> Vec { + let n = (len as u32).div_ceil(BLOCK); + let blocks: Vec = (0..n).map(|i| BASE + i * BLOCK).collect(); + for &b in &blocks { + assert!(b >= BASE && b.is_multiple_of(BLOCK) && b + BLOCK <= FLASH_SIZE, "block {b:#x} out of bounds"); + } + blocks +} + +/// Write `image` at [`BASE`] and read it back. The radio must be in a +/// programming session already ([`protocol::begin`]). +/// +/// Erase is md380tools' 0x03, which returns before the chip has finished — a +/// second erase sent while it is still busy is IGNORED. So each erase is given +/// time and its block read back as blank before the next goes out (editcp +/// waits 500 ms blind; this checks). Writes are md380tools' 0x04, 1 KiB at a +/// time, the last chunk padded with 0xFF as editcp does. +pub(crate) fn write(l: &mut dyn Link, image: &[u8]) -> Result<(), String> { + let id = protocol::spi_flash_id(l)?; + if !matches!(id, [0xEF, 0x40, 0x18] | [0x10, 0xDC, 0x01]) { + return Err(format!( + "This radio's flash chip ({}) is not the 16 MB part a user database needs — some \ + MD-380s have a 1 MB chip. Nothing was written.", + protocol::hex(&id) + )); + } + for block in blocks_for(image.len()) { + erase(l, block)?; + } + for (i, chunk) in image.chunks(CHUNK).enumerate() { + let mut data = chunk.to_vec(); + data.resize(CHUNK, 0xFF); + protocol::spi_write(l, BASE + (i * CHUNK) as u32, &data)?; + } + verify(l, image) +} + +/// Erase one block and see it blank: the first and last 1 KiB. +/// +/// The firmware's erase command returns at once and does not wait for the +/// chip, so an erase that arrives while the previous one is still running is +/// dropped without a word. That is what stopped Tim's first run at 0x130000 +/// (2026-10-10): three blocks erased, the fourth never did. So each attempt +/// waits editcp's 500 ms before looking, polls past the chip's 2 s worst case, +/// and a block still not blank gets its erase sent again. md380tools covers +/// the same hole by sending every block's erase sixteen times. Anything this +/// misses, the read-back at the end still catches. +fn erase(l: &mut dyn Link, block: u32) -> Result<(), String> { + for _ in 0..3 { + protocol::spi_erase_64k(l, block)?; + l.sleep(Duration::from_millis(500)); + for _ in 0..10 { + let first = protocol::spi_read(l, block)?; + let last = protocol::spi_read(l, block + BLOCK - CHUNK as u32)?; + if first.iter().chain(&last).all(|&b| b == 0xFF) { + return Ok(()); + } + l.sleep(Duration::from_millis(250)); + } + } + Err(format!( + "The radio's flash block at {block:#x} would not erase. The codeplug is untouched; \ + reconnect the radio and run the write again." + )) +} + +/// Read the whole image back and compare. The tools this follows skip this. +fn verify(l: &mut dyn Link, image: &[u8]) -> Result<(), String> { + for (i, chunk) in image.chunks(CHUNK).enumerate() { + let addr = BASE + (i * CHUNK) as u32; + let back = protocol::spi_read(l, addr)?; + if back[..chunk.len()] != *chunk { + return Err(format!( + "The user database did not read back as written at {addr:#x}. The codeplug is \ + untouched; run the write again." + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rec(id: u32, call: &str, name: &str, city: &str, group: bool) -> CallsignRecord { + CallsignRecord { + dmr_id: id, + group_call: group, + callsign: call.into(), + name: name.into(), + city: Some(city.into()), + state: Some("Colorado".into()), + country: Some("United States".into()), + comment: None, + } + } + + #[test] + fn the_image_is_a_sized_header_then_users_sorted_by_id() { + let img = build(&[ + rec(3108176, "WW8L", "Tim Annable", "Fort Collins, CO", false), + rec(91, "", "Worldwide", "", true), + rec(1023001, "K0ABC", "José Ruiz", "Denver", false), + rec(3108176, "DUP", "Dup", "", false), + ]); + assert_eq!((img.users, img.left_out), (2, 0)); + let text = String::from_utf8(img.image).unwrap(); + let (head, body) = text.split_once('\n').unwrap(); + assert_eq!(head.parse::().unwrap(), body.len()); + let lines: Vec<&str> = body.lines().collect(); + assert_eq!( + lines, + [ + "1023001,K0ABC,Jos Ruiz,Denver,Colorado,Jos,United States", + "3108176,WW8L,Tim Annable,Fort Collins; CO,Colorado,Tim,United States", + ] + ); + } + + /// The whole sequence against the simulated radio: the image lands at + /// 0x100000, reads back, and nothing below 0x100000 — the codeplug — is + /// erased or changed. + /// Past 15 MB the END of the priority order is what goes, and the image + /// still ends inside the chip. + #[test] + fn a_selection_past_fifteen_megabytes_drops_the_lowest_priority() { + let long = "x".repeat(80); + // Priority order runs DOWN the ID range, so a by-ID cut would keep the + // wrong end. + let records: Vec<_> = (0..200_000u32).rev().map(|i| rec(1_000_000 + i, "N0CALL", &long, "", false)).collect(); + let b = build(&records); + assert!(b.left_out > 0 && b.users + b.left_out == 200_000); + assert!(b.image.len() as u32 <= FLASH_SIZE - BASE); + let _ = blocks_for(b.image.len()); + let text = String::from_utf8(b.image).unwrap(); + let first = text.lines().nth(1).unwrap(); + let kept_lowest = 1_000_000 + 200_000 - b.users as u32; + assert!(first.starts_with(&format!("{kept_lowest},")), "{first}"); + } + + /// An erase the firmware dropped is sent again, and the write goes on. + #[test] + fn a_dropped_erase_is_sent_again() { + use crate::radios::tyt_md380::layout::IMAGE_LEN; + use crate::radios::tyt_md380::protocol::fake::FakeRadio; + let mut r = FakeRadio::new(vec![0x5A; IMAGE_LEN]); + r.ignore_erase = vec![0x13_0000]; + let records: Vec<_> = (0..20_000u32).map(|i| rec(1_000_000 + i, "N0CALL", "Some Operator", "Town", false)).collect(); + let img = build(&records).image; + assert!(img.len() > 0x4_0000); + write(&mut r, &img).unwrap(); + let erases: Vec<_> = r.log.iter().filter(|l| l.starts_with("erase 0x130000")).collect(); + assert_eq!(erases, ["erase 0x130000 ignored", "erase 0x130000"]); + assert_eq!(&r.flash.as_ref().unwrap()[BASE as usize..][..img.len()], &img[..]); + } + + #[test] + fn a_write_lands_at_the_base_and_never_touches_the_codeplug() { + use crate::radios::tyt_md380::layout::IMAGE_LEN; + use crate::radios::tyt_md380::protocol::fake::FakeRadio; + let codeplug: Vec = (0..IMAGE_LEN).map(|i| i as u8).collect(); + let mut r = FakeRadio::new(codeplug.clone()); + protocol::begin(&mut r).unwrap(); + let records: Vec<_> = + (0..3000).map(|i| rec(3_100_000 + i, &format!("K{i}"), "Some Body", "Town", false)).collect(); + let img = build(&records).image; + assert!(img.len() > 0x1_0000, "spans more than one block"); + write(&mut r, &img).unwrap(); + let flash = r.flash.as_ref().unwrap(); + assert_eq!(&flash[BASE as usize..BASE as usize + img.len()], img.as_slice()); + assert_eq!(r.image, codeplug, "the codeplug is untouched"); + assert!(r.log.iter().filter(|l| l.starts_with("erase")).all(|l| l != "erase 0x0")); + } + + #[test] + fn a_one_megabyte_radio_is_refused_before_anything_is_erased() { + use crate::radios::tyt_md380::layout::IMAGE_LEN; + use crate::radios::tyt_md380::protocol::fake::FakeRadio; + let mut r = FakeRadio::new(vec![0; IMAGE_LEN]); + r.flash_id = [0xEF, 0x40, 0x14]; + protocol::begin(&mut r).unwrap(); + let e = write(&mut r, &build(&[rec(1, "A", "B", "C", false)]).image).unwrap_err(); + assert!(e.contains("16 MB"), "{e}"); + assert!(!r.log.iter().any(|l| l.starts_with("erase"))); + } + + #[test] + fn every_block_is_at_or_above_the_database_base() { + assert_eq!(blocks_for(1), [0x10_0000]); + assert_eq!(blocks_for(0x1_0001), [0x10_0000, 0x11_0000]); + assert!(blocks_for(0x30_0000).iter().all(|&b| b >= BASE && b.is_multiple_of(0x1_0000))); + } +} diff --git a/src-tauri/src/radios/yaesu_ft5d/mod.rs b/src-tauri/src/radios/yaesu_ft5d/mod.rs index 9b9cb44..dcc41a9 100644 --- a/src-tauri/src/radios/yaesu_ft5d/mod.rs +++ b/src-tauri/src/radios/yaesu_ft5d/mod.rs @@ -286,6 +286,8 @@ mod tests { program_codeplug: false, programs_settings: false, write_callsign_db: false, + callsign_db_requirement: None, + codeplug_contact_capacity: None, export: true, diagnostics: false, after_write: None, diff --git a/src/components/codeplugs/AnytoneProgramDialog.tsx b/src/components/codeplugs/AnytoneProgramDialog.tsx index f809796..6ffae77 100644 --- a/src/components/codeplugs/AnytoneProgramDialog.tsx +++ b/src/components/codeplugs/AnytoneProgramDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { RefreshCw, Upload, @@ -10,9 +10,6 @@ import { Users, Settings2, Undo2, - ArrowUp, - ArrowDown, - X, } from "lucide-react"; import { confirm as confirmDialog } from "@tauri-apps/plugin-dialog"; import { api } from "../../lib/api"; @@ -24,11 +21,11 @@ import type { AnytoneLatestProgram, AnytonePatchWriteResult, AnytoneVerifyResult, - DmrExportPreview, PortInfo, } from "../../lib/types"; import { FooterClose, Modal } from "../overlays"; -import { Button, Spinner, Select, TextInput } from "../ui"; +import { DmrUserPicker, useDmrUserPick } from "./DmrUserPicker"; +import { Button, Spinner, Select } from "../ui"; import type { ProgramDialogProps } from "../../lib/radioProgramming"; type Payload = "channels" | "userdb" | "profile"; @@ -80,20 +77,9 @@ export function AnytoneProgramDialog({ const [verify, setVerify] = useState(null); const [restored, setRestored] = useState(null); const [callsignResult, setCallsignResult] = useState(null); + const dbPick = useDmrUserPick(open, payload === "userdb"); + const dbPreview = dbPick.preview; - // UserDB (Call-sign Database) batch selection — mirrors the DMR-export dialog. - const [dbCountries, setDbCountries] = useState([]); - const [dbContinents, setDbContinents] = useState([]); - const [dbPriority, setDbPriority] = useState([]); - const [dbAddChoice, setDbAddChoice] = useState(""); - const [dbMaxText, setDbMaxText] = useState(""); - const [dbPreview, setDbPreview] = useState(null); - const [dbPreviewing, setDbPreviewing] = useState(false); - - const dbMaxCount = useMemo(() => { - const n = parseInt(dbMaxText, 10); - return Number.isFinite(n) && n > 0 ? n : null; - }, [dbMaxText]); // Verify/Restore target the in-memory result when we have it, else the newest // image on disk — so they stay reachable even when a program's result never @@ -147,14 +133,8 @@ export function AnytoneProgramDialog({ setVerify(null); setRestored(null); setCallsignResult(null); - setDbPriority([]); - setDbAddChoice(""); - setDbMaxText(""); - setDbPreview(null); refreshPorts(); api.latestAnytoneProgram().then(setLatest).catch(() => {}); - api.listDmrUserCountries().then(setDbCountries).catch(() => {}); - api.listDmrContinents().then(setDbContinents).catch(() => {}); api .programAnytonePreview(codeplugId) .then(setPreview) @@ -162,44 +142,6 @@ export function AnytoneProgramDialog({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); - // Debounced Call-sign DB preview (how many contacts, by country) whenever the - // UserDB payload is active and its selection changes. - useEffect(() => { - if (!open || payload !== "userdb") return; - setDbPreviewing(true); - const t = setTimeout(() => { - api - .previewDmrExport(dbMaxCount, dbPriority) - .then(setDbPreview) - .catch(() => setDbPreview(null)) - .finally(() => setDbPreviewing(false)); - }, 200); - return () => clearTimeout(t); - }, [open, payload, dbMaxCount, dbPriority]); - - const dbAvailableToAdd = useMemo(() => { - const chosen = new Set(dbPriority.map((p) => p.toLowerCase())); - return { - continents: dbContinents.filter((c) => !chosen.has(c.toLowerCase())), - countries: dbCountries.filter((c) => !chosen.has(c.toLowerCase())), - }; - }, [dbContinents, dbCountries, dbPriority]); - - const addDbPriority = () => { - if (!dbAddChoice) return; - setDbPriority((p) => [...p, dbAddChoice]); - setDbAddChoice(""); - }; - const removeDbPriority = (i: number) => - setDbPriority((p) => p.filter((_, idx) => idx !== i)); - const moveDbPriority = (i: number, dir: -1 | 1) => - setDbPriority((p) => { - const j = i + dir; - if (j < 0 || j >= p.length) return p; - const next = [...p]; - [next[i], next[j]] = [next[j], next[i]]; - return next; - }); const run = async (kind: Exclude, fn: () => Promise) => { setError(null); @@ -229,19 +171,17 @@ export function AnytoneProgramDialog({ setSettingsResult(null); setCallsignResult(null); try { - if (payload === "profile" || payload === "userdb") { + if (payload === "profile") { if (profileId == null) { throw new Error( "This codeplug has no radio profile — attach one under Codeplugs first.", ); } - if (payload === "profile") { - setSettingsResult(await api.writeRadioSettings(port, profileId)); - } else { - setCallsignResult( - await api.writeCallsignDb(profileId, port, dbMaxCount, dbPriority), - ); - } + setSettingsResult(await api.writeRadioSettings(port, profileId)); + } else if (payload === "userdb") { + setCallsignResult( + await api.writeCallsignDb(codeplugId, port, dbPick.maxCount, dbPick.priority), + ); } else { setResult(await api.programRadio(codeplugId, port)); } @@ -526,116 +466,7 @@ export function AnytoneProgramDialog({ - - -
- - Priority order (matched first; everyone else fills any remaining space) - -
- - -
- {dbPriority.length > 0 && ( -
    - {dbPriority.map((p, i) => ( -
  • - {i + 1}. - {p} -
    - - - -
    -
  • - ))} -
- )} -
- -
- {dbPreviewing || !dbPreview ? ( -
- Calculating… -
- ) : ( - <> -
- {dbPreview.will_export.toLocaleString()} of{" "} - {dbPreview.total_available.toLocaleString()} contacts will be written -
- {dbPreview.by_country.length > 0 && ( -
- {dbPreview.by_country.slice(0, 8).map((b) => ( - - {b.country}: {b.count.toLocaleString()} - - ))} - {dbPreview.by_country.length > 8 && …} -
- )} - - )} -
+ )} {payload === "channels" && previewError && ( diff --git a/src/components/codeplugs/DmrUserPicker.tsx b/src/components/codeplugs/DmrUserPicker.tsx new file mode 100644 index 0000000..8805b1a --- /dev/null +++ b/src/components/codeplugs/DmrUserPicker.tsx @@ -0,0 +1,222 @@ +import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react"; +import { ArrowUp, ArrowDown, X } from "lucide-react"; +import { api } from "../../lib/api"; +import type { DmrExportPreview } from "../../lib/types"; +import { Button, Spinner, Select, TextInput } from "../ui"; + +/// Which users from the local DMR Contacts library a write takes: a size cap and +/// a country/continent priority, previewed live. Shared by every place that +/// sends library users to a radio (the AnyTone call-sign DB, the MD-380 user DB +/// and the MD-380's spare codeplug contacts) so the three choose the same way. +export type DmrUserPick = { + maxCount: number | null; + priority: string[]; + preview: DmrExportPreview | null; + previewing: boolean; + maxText: string; + setMaxText: (s: string) => void; + setPriority: Dispatch>; + countries: string[]; + continents: string[]; +}; + +/// `open` resets the choice each time the dialog opens; the preview runs only +/// while `active` — the card holding the picker is the selected one. `cap` is a +/// hard limit the radio imposes (the MD-380 codeplug's 1000 contacts): a blank +/// or larger max is previewed and sent as the cap. +export function useDmrUserPick(open: boolean, active: boolean, cap: number | null = null): DmrUserPick { + const [countries, setCountries] = useState([]); + const [continents, setContinents] = useState([]); + const [priority, setPriority] = useState([]); + const [maxText, setMaxText] = useState(""); + const [preview, setPreview] = useState(null); + const [previewing, setPreviewing] = useState(false); + + const maxCount = useMemo(() => { + const n = parseInt(maxText, 10); + const typed = Number.isFinite(n) && n > 0 ? n : null; + if (cap == null) return typed; + return typed == null ? cap : Math.min(typed, cap); + }, [maxText, cap]); + + // The pickable lists load the first time the picker is actually shown, not + // on every open of a dialog that may never show it (a radio with no DMR). + const [listsLoaded, setListsLoaded] = useState(false); + useEffect(() => { + if (!open) return; + setPriority([]); + setMaxText(""); + setPreview(null); + }, [open]); + useEffect(() => { + if (!open || !active || listsLoaded) return; + setListsLoaded(true); + api.listDmrUserCountries().then(setCountries).catch(() => {}); + api.listDmrContinents().then(setContinents).catch(() => {}); + }, [open, active, listsLoaded]); + + // Debounced: how many users, by country, the current choice takes. + useEffect(() => { + if (!open || !active) return; + setPreviewing(true); + const t = setTimeout(() => { + api + .previewDmrExport(maxCount, priority) + .then(setPreview) + .catch(() => setPreview(null)) + .finally(() => setPreviewing(false)); + }, 200); + return () => clearTimeout(t); + }, [open, active, maxCount, priority]); + + return { maxCount, priority, preview, previewing, maxText, setMaxText, setPriority, countries, continents }; +} + +export function DmrUserPicker({ + pick, + maxLabel = "Max contacts (blank = no limit)", + maxPlaceholder = "e.g. 50000 — large batches take longer to write over USB", + noun = "contacts", +}: { + pick: DmrUserPick; + maxLabel?: string; + maxPlaceholder?: string; + noun?: string; +}) { + const { priority, setPriority, preview, previewing } = pick; + const [addChoice, setAddChoice] = useState(""); + + const availableToAdd = useMemo(() => { + const chosen = new Set(priority.map((p) => p.toLowerCase())); + return { + continents: pick.continents.filter((c) => !chosen.has(c.toLowerCase())), + countries: pick.countries.filter((c) => !chosen.has(c.toLowerCase())), + }; + }, [pick.continents, pick.countries, priority]); + + const add = () => { + if (!addChoice) return; + setPriority((p) => [...p, addChoice]); + setAddChoice(""); + }; + const remove = (i: number) => setPriority((p) => p.filter((_, idx) => idx !== i)); + const move = (i: number, dir: -1 | 1) => + setPriority((p) => { + const j = i + dir; + if (j < 0 || j >= p.length) return p; + const next = [...p]; + [next[i], next[j]] = [next[j], next[i]]; + return next; + }); + + return ( + <> + + +
+ + Priority order (matched first; everyone else fills any remaining space) + +
+ + +
+ {priority.length > 0 && ( +
    + {priority.map((p, i) => ( +
  • + {i + 1}. + {p} +
    + + + +
    +
  • + ))} +
+ )} +
+ +
+ {previewing || !preview ? ( +
+ Calculating… +
+ ) : ( + <> +
+ {preview.will_export.toLocaleString()} of {preview.total_available.toLocaleString()} {noun} will + be written +
+ {preview.by_country.length > 0 && ( +
+ {preview.by_country.slice(0, 8).map((b) => ( + + {b.country}: {b.count.toLocaleString()} + + ))} + {preview.by_country.length > 8 && …} +
+ )} + + )} +
+ + ); +} diff --git a/src/components/codeplugs/ProgramRadioDialog.tsx b/src/components/codeplugs/ProgramRadioDialog.tsx index 3682c3c..5a908ac 100644 --- a/src/components/codeplugs/ProgramRadioDialog.tsx +++ b/src/components/codeplugs/ProgramRadioDialog.tsx @@ -14,6 +14,7 @@ import { Ear, ListChecks, Settings2, + Users, } from "lucide-react"; import { api } from "../../lib/api"; import type { @@ -23,11 +24,13 @@ import type { CodeplugProgramReport, RadioIdent, SettingsWriteReport, + CallsignDbReport, } from "../../lib/types"; import { FooterClose, Modal } from "../overlays"; import { PortSelect, usePortChoice } from "../radioPort"; import { WarningList } from "./ReportWarnings"; import { Button, Spinner } from "../ui"; +import { DmrUserPicker, useDmrUserPick } from "./DmrUserPicker"; import { driverKeyOf, isProgrammable, @@ -105,19 +108,51 @@ export function ProgramRadioDialog({ const canWriteOverCable = showCable && cableCapable; // The Profile payload: the driver writes settings on its own (SettingsWriter). const canWriteProfile = showCable && caps?.write_settings === true; + // The User DB payload: the radio keeps a caller-ID database of its own, + // written from the DMR Contacts library (the MD-380 on md380tools firmware). + const canWriteUserDb = showCable && caps?.write_callsign_db === 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" | "settings" + null | "identify" | "download" | "program" | "restore" | "media" | "settings" | "userdb" >(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 [payload, setPayload] = useState<"channels" | "userdb" | "profile">("channels"); const [settingsResult, setSettingsResult] = useState(null); + const [callsignResult, setCallsignResult] = useState(null); const profileMode = canWriteProfile && payload === "profile"; + const userDbMode = canWriteUserDb && payload === "userdb"; + const channelsMode = !profileMode && !userDbMode; + const dbPick = useDmrUserPick(open, userDbMode); + // Channel set: also fill the codeplug's spare contacts with library users — + // on a stock MD-380 its contact list is the only caller ID there is. Capped + // at the radio's 1000; the talkgroups come first and the planner drops (and + // reports) whatever does not fit after them. + const contactCapacity = showCable ? (caps?.codeplug_contact_capacity ?? null) : null; + const canFillContacts = contactCapacity != null; + const [fillContacts, setFillContacts] = useState(false); + // The contacts the codeplug's own talkgroups take, from the programmer's + // preview, so the cap and the confirm quote the users that will really fit. + const [talkgroupContacts, setTalkgroupContacts] = useState(null); + useEffect(() => { + if (!open || !canFillContacts) return; + setTalkgroupContacts(null); + api + .programAnytonePreview(codeplugId) + .then((p) => setTalkgroupContacts(p.contacts)) + .catch(() => setTalkgroupContacts(0)); + }, [open, canFillContacts, codeplugId]); + const contactRoom = + contactCapacity == null ? 0 : Math.max(0, contactCapacity - (talkgroupContacts ?? 0)); + const fillPick = useDmrUserPick( + open, + canFillContacts && channelsMode && fillContacts && talkgroupContacts != null, + contactRoom, + ); // 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. @@ -170,6 +205,8 @@ export function ProgramRadioDialog({ setProgram(null); setPayload("channels"); setSettingsResult(null); + setCallsignResult(null); + setFillContacts(false); setConfirming(false); setMediaWritten(null); setError(null); @@ -189,7 +226,7 @@ export function ProgramRadioDialog({ }, [open]); const run = async ( - kind: "identify" | "download" | "program" | "restore" | "media" | "settings", + kind: "identify" | "download" | "program" | "restore" | "media" | "settings" | "userdb", fn: () => Promise, ) => { setError(null); @@ -197,6 +234,7 @@ export function ProgramRadioDialog({ // 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); + if (kind !== "userdb") setCallsignResult(null); setBusy(kind); try { await fn(); @@ -232,12 +270,30 @@ export function ProgramRadioDialog({ setSettingsResult(await api.writeRadioSettings(port, profileId)); }); + const doWriteUserDb = () => + run("userdb", async () => { + setConfirming(false); + setDownload(null); + setIdent(null); + setProgram(null); + setCallsignResult( + await api.writeCallsignDb(codeplugId, port, dbPick.maxCount, dbPick.priority), + ); + }); + + const filling = canFillContacts && fillContacts; const doProgram = () => run("program", async () => { setConfirming(false); setDownload(null); setIdent(null); - setProgram(await api.programRadio(codeplugId, port)); + setProgram( + await api.programRadio( + codeplugId, + port, + filling ? { max_count: fillPick.maxCount, priority_countries: fillPick.priority } : null, + ), + ); }); /// Patch the codeplug into the file the radio reads off its own memory card. @@ -342,6 +398,32 @@ 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 user DB write (the User DB payload) */} + {confirming && userDbMode && ( +
+
+ Confirm user database write to {modelName} +
+

+ This replaces the radio’s caller-ID database with{" "} + {(dbPick.preview?.will_export ?? 0).toLocaleString()}{" "} + users from your DMR Contacts library, then reads it back to check it. + Channels, contacts and settings are not touched. No backup is taken: + the database is rebuilt from the library, so there is nothing in it + to lose. A large database takes several minutes — keep the radio + connected until it finishes. +

+
+ + +
+
+ )} + {/* Confirm a settings write (the Profile payload) */} {confirming && profileMode && (
@@ -366,7 +448,7 @@ export function ProgramRadioDialog({ )} {/* Confirm write */} - {confirming && !profileMode && ( + {confirming && channelsMode && (
Confirm write to {modelName} @@ -387,6 +469,15 @@ export function ProgramRadioDialog({ {codeplugName}”. A full backup is saved first.

+ {filling && ( +

+ The spare contacts are filled with up to{" "} + {(fillPick.preview?.will_export ?? 0).toLocaleString()}{" "} + DMR users after this codeplug’s talkgroups, replacing the + contacts the radio has now. +

+ )} + {(skipped.length > 0 || receiveOnly.length > 0) && (

{[ @@ -513,11 +604,33 @@ export function ProgramRadioDialog({ Write settings )} - {canWriteOverCable && !profileMode && ( + {userDbMode && ( + )} + {canWriteOverCable && channelsMode && ( + - ); - })} + ) + .filter((p) => p.show) + .map((p) => { + const Icon = p.icon; + const selected = payload === p.id; + return ( + + ); + })} +

+ )} + + {userDbMode && ( +
+
+ Writes a caller-ID database from your local{" "} + DMR Contacts library to the {modelName}, so an + incoming call shows the operator’s callsign, name and location. + Choose a size cap and a country/continent priority — the + highest-priority users fill the space first. Refresh the library from + the DMR Contacts page if it looks empty. + {caps?.callsign_db_requirement && ( + + {caps.callsign_db_requirement} + + )} +
+ +
+ )} + + {canFillContacts && channelsMode && ( +
+ + {fillContacts && ( + + )}
)} @@ -694,7 +879,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 && !profileMode && ( + {preview && channelsMode && (
{/* The breakdown below is worth showing even for a radio @@ -884,6 +1069,18 @@ export function ProgramRadioDialog({ /> )} + {callsignResult && ( +
+
+ + Wrote {callsignResult.entries_written.toLocaleString()} user + {callsignResult.entries_written === 1 ? "" : "s"} + {callsignResult.verified && " · verified ✓"} +
+
{callsignResult.note}
+
+ )} + {program && ( 0 ? ` · ${program.banks_written} bank${program.banks_written === 1 ? "" : "s"}` + : "") + + (program.contacts_written > 0 + ? ` · ${program.contacts_written} contact${program.contacts_written === 1 ? "" : "s"}` : "") } note={program.note ?? undefined} diff --git a/src/lib/api.ts b/src/lib/api.ts index e6a44ae..7b9fc30 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -376,16 +376,20 @@ export const api = { invoke("write_radio_settings", { port, profileId }), // One program command for every radio (3.6e). Dispatches on capability: // DB-driven full replace (AnyTone) or clone-image patch (UV-5R, TD-H3). - programRadio: (codeplugId: number, port: string) => - invoke("program_radio", { codeplugId, port }), + programRadio: ( + codeplugId: number, + port: string, + // Fill the codeplug's spare contacts with library users (the MD-380). + userContacts: { max_count: number | null; priority_countries: string[] } | null = null, + ) => invoke("program_radio", { codeplugId, port, userContacts }), writeCallsignDb: ( - profileId: number, + codeplugId: number, port: string, maxCount: number | null, priorityCountries: string[], ) => invoke("write_callsign_db", { - profileId, + codeplugId, port, maxCount, priorityCountries, diff --git a/src/lib/types.ts b/src/lib/types.ts index 6ab7a6d..935d058 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -194,6 +194,13 @@ export interface DriverCapabilities { /// explicitly-acknowledged write and leaves the radio's own alone. programs_settings: boolean; write_callsign_db: boolean; + /// What the radio needs before a call-sign DB write can work (the MD-380's + /// patched firmware), said up front in the Program dialog. + callsign_db_requirement: string | null; + /// How many contacts the codeplug holds, when a program can fill its spare + /// ones with DMR users from the library (the caller-ID list a stock MD-380 + /// has); null for every other radio. + codeplug_contact_capacity: number | null; export: boolean; diagnostics: boolean; /// What the operator must do at the radio after an upload (the ID-5100 asks @@ -748,6 +755,9 @@ export interface CallsignDbReport { entries_written: number; windows_written: string[]; note: string; + /// The driver read the database back (the MD-380); the AnyTone's is + /// checked on the radio's own screen. + verified: boolean; } // Newest program image pair on disk, so Verify/Restore stay reachable even if