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
5 changes: 5 additions & 0 deletions src-tauri/src/commands/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,9 @@ pub(crate) struct ResolvedCodeplug {
pub channels: Vec<ExpandedChannel>,
pub scan_lists: Vec<CodeplugScanList>,
pub scan_list_overrides: Vec<ChannelScanListOverride>,
/// See [`CodeplugPayload::contact_users`](crate::radios::driver::CodeplugPayload);
/// empty here, filled by `program_radio` when the operator asks.
pub contact_users: Vec<crate::radios::driver::CallsignRecord>,
}

impl ResolvedCodeplug {
Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -367,6 +371,7 @@ pub(crate) async fn resolve_codeplug_payload(
channels,
scan_lists,
scan_list_overrides,
contact_users: Vec::new(),
})
}

Expand Down
98 changes: 66 additions & 32 deletions src-tauri/src/commands/program.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64>,
priority_countries: Vec<String>,
) -> Result<CallsignDbReport, String> {
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<CallsignRecord> = 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
Expand Down Expand Up @@ -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 || {
Expand All @@ -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<i64>,
priority_countries: &[String],
) -> Result<Vec<CallsignRecord>, 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<i64>,
pub priority_countries: Vec<String>,
}

// ============================================================
// Write / upload
// ============================================================
Expand Down Expand Up @@ -976,6 +988,7 @@ pub async fn program_radio(
state: State<'_, AppState>,
codeplug_id: i64,
port: String,
user_contacts: Option<UserContactFill>,
) -> Result<CodeplugProgramReport, String> {
let model = export::codeplug_model(&state.pool, codeplug_id).await?;
let driver = registry::driver_for_model(&model).ok_or_else(|| {
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/radios/anytone_atd890uv/callsign_db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ impl CallsignDbWriter for AnytoneAtd890uv {
entries_written,
windows_written: result.windows_written,
note: result.note,
verified: false,
})
}
}
Expand Down
36 changes: 36 additions & 0 deletions src-tauri/src/radios/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -521,6 +526,13 @@ pub(crate) trait CodeplugProgrammer: Send + Sync {
payload: &CodeplugPayload,
backup_dir: &Path,
) -> Result<ProgramReport, String>;

/// 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<usize> {
None
}
}

/// Radios programmed by targeted per-channel/bank writes rather than a full
Expand All @@ -536,6 +548,10 @@ pub(crate) trait ChannelWriter {
) -> Result<usize, String>;
}

/// 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 {
Expand All @@ -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.
Expand Down Expand Up @@ -585,6 +604,12 @@ pub(crate) trait CallsignDbWriter: Send + Sync {
port: &str,
records: &[CallsignRecord],
) -> Result<CallsignDbReport, String>;

/// 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
Expand Down Expand Up @@ -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<usize>,
pub export: bool,
pub diagnostics: bool,
/// [`ImageProgrammer::after_write_instruction`], for the Program dialog.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/radios/icom_id52/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/radios/kenwood_thd75/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/radios/kenwood_tmd710/program.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ mod tests {
channels: chans,
scan_lists: &[],
scan_list_overrides: &[],
contact_users: &[],
}
}

Expand Down
23 changes: 19 additions & 4 deletions src-tauri/src/radios/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading