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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ including programming several different radios from the same codeplug.
| **Kenwood TH-D75** | D-STAR + APRS + Analog | VHF / 1.25 m / UHF TX, 0.1–524 MHz RX | microSD — patches the radio's own `.d75` file | 1000 memories in 30 groups; memories and menu settings, including the APRS setup |
| **Kenwood TM-D710** | APRS + Analog | 2 m / 70 cm TX, 118–524 MHz RX | Serial cable — read, write, settings | 1000 memories; 95 settings across **two transports** — 35 over the radio's `MU` command and 60 more, including the APRS position, status texts and station icon, out of the settings image `MU` cannot reach. Programmed live, one memory at a time over the COM port on the rear of the **operation panel** (not the main unit), so there is no image file and a backup is a transcript of the radio's own lines |
| **TYT MD-380** | DMR + Analog | UHF (400–480 MHz) | Direct USB — read, write | 1000 channels in zones of 16, talkgroup contacts and scan lists. No serial port and, on macOS, no driver: the radio itself is the USB device. On Windows it uses the driver TYT's CPS installs |
| **Yaesu FT-857D / FT-897** | Analog FM/AM/SSB/CW | HF / 6 m / 2 m / 70 cm TX, 0.1–56 / 76–108 / 118–164 / 420–470 MHz RX | Data cable to the CAT/LINEAR jack — read, write, settings | 200 memories, including USB, LSB, CW and AM channels; 46 menu settings, from mic gains and per-band RF power to the CW keyer, DSP, programmable keys and beacon texts. The clone is started from the radio's own front panel, and the app tells you when to press SEND and RCV. Measured on an FT-897; the FT-857D shares its protocol and memory map |
| **Binteradio BT-9000** | Analog FM/NFM | 18–64 / 136–174 / 200–260 / 400–520 MHz TX, 18–520 MHz RX | Direct USB — read, write, settings | 960 channels in 15 fixed zones; 42 menu settings. Also sold as the Radtel RT-950 Pro, Bajeton BJ-9000 and Tenway TP-900 Pro — the radio reports itself as `RT-950` |

Direct USB and cable programming reads the radio's current image, applies your changes, backs up
Expand Down
103 changes: 101 additions & 2 deletions src-tauri/src/commands/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ pub(crate) struct CodeplugGroup {
pub channels: Vec<Channel>,
}

/// A channel with no mode is FM — what [`channel_fit`] assumes when it lets the
/// channel through. Filled in here, where every path to a radio loads its
/// channels, so no driver has to agree on a default: the TD-H3 encoder, for
/// one, writes anything that is not literally "FM" as *narrow*.
fn with_default_mode(mut c: Channel) -> Channel {
if c.mode.as_deref().is_none_or(|m| m.trim().is_empty()) {
c.mode = Some("FM".to_string());
}
c
}

/// Resolve a codeplug's assigned channel lists into ordered groups, preserving
/// list membership. Lists come back in codeplug assignment order; channels
/// within each in the list's position order. The building block for zone/bank
Expand Down Expand Up @@ -91,7 +102,11 @@ pub(crate) async fn resolve_codeplug_groups(
.estr()?;

let mut seen = HashSet::new();
let channels = rows.into_iter().filter(|c| seen.insert(c.id)).collect();
let channels = rows
.into_iter()
.filter(|c| seen.insert(c.id))
.map(with_default_mode)
.collect();
groups.push(CodeplugGroup {
list_id,
list_name,
Expand Down Expand Up @@ -510,7 +525,15 @@ pub(crate) fn channel_fit(channel: &Channel, model: &RadioModel) -> ChannelFit {
// so this is a hard exclusion — receive-only does not apply.
let mode = channel.mode.as_deref().unwrap_or("FM").to_uppercase();
let mode_ok = match mode.as_str() {
"FM" | "NFM" | "AM" | "USB" | "LSB" | "CW" => model.analog_capable,
// An analog mode passes only if this radio's encoder writes it as
// itself — every encoder falls back to FM otherwise, which made a USB or
// AM channel a silent dead FM memory. A model with no driver is a file
// export, whose formats carry the mode through as text.
"FM" | "NFM" | "AM" | "USB" | "LSB" | "CW" => {
model.analog_capable
&& crate::radios::registry::driver_for_model(model)
.is_none_or(|d| d.analog_modes().contains(&mode.as_str()))
}
"DMR" => model.dmr_capable,
"DSTAR" => model.dstar_capable,
// YSF is Fusion digital-narrow (DN); YSF-VW is its full-rate voice mode.
Expand Down Expand Up @@ -1450,6 +1473,9 @@ fn render_chirp_csv(channels: &[&ExpandedChannel], model: &RadioModel) -> Result
.unwrap_or_else(|| "023".to_string());
let chirp_mode = match c.mode.as_deref().unwrap_or("FM").to_uppercase().as_str() {
"AM" => "AM",
"USB" => "USB",
"LSB" => "LSB",
"CW" => "CW",
"DSTAR" => "DV",
"NFM" => "NFM",
_ => "FM",
Expand Down Expand Up @@ -2729,6 +2755,79 @@ mod tests {
}
}

fn mode_at(rx_freq: f64, mode: &str) -> Channel {
Channel { mode: Some(mode.into()), ..fm_at(rx_freq) }
}

/// Every encoder here falls back to FM for a mode it does not handle, so a
/// USB or AM channel used to reach a UV-5R and program as a dead FM memory.
/// `channel_fit` now asks the radio's driver what it actually encodes.
#[test]
fn an_analog_mode_the_driver_does_not_encode_is_excluded_with_a_reason() {
let uv5r = RadioModel {
display_name: "Baofeng UV-5R".into(),
driver_key: Some("baofeng_uv5r".into()),
analog_capable: true,
tx_bands: Some("[[136.0,174.0],[400.0,520.0]]".into()),
..Default::default()
};
assert_eq!(channel_fit(&mode_at(146.52, "FM"), &uv5r), ChannelFit::Included);
assert_eq!(channel_fit(&mode_at(146.52, "NFM"), &uv5r), ChannelFit::Included);
for mode in ["AM", "USB", "LSB", "CW", "usb"] {
match channel_fit(&mode_at(146.52, mode), &uv5r) {
ChannelFit::Excluded(why) => assert!(
why.contains(&format!("Mode {} not supported by Baofeng UV-5R", mode.to_uppercase())),
"{why}"
),
other => panic!("{mode} on a UV-5R should be excluded, got {other:?}"),
}
}

// A driver that does encode AM keeps it; SSB is still out.
let ft5d = RadioModel { driver_key: Some("yaesu_ft5d".into()), ..wideband_rx_model() };
assert_eq!(channel_fit(&mode_at(146.52, "AM"), &ft5d), ChannelFit::Included);
assert!(matches!(channel_fit(&mode_at(146.52, "USB"), &ft5d), ChannelFit::Excluded(_)));
// The FT5D's encoder has no narrow bit, so NFM is refused, not sent wide.
assert!(matches!(channel_fit(&mode_at(146.52, "NFM"), &ft5d), ChannelFit::Excluded(_)));
}

/// A channel with no mode reaches every radio as FM — the TD-H3's encoder
/// would otherwise write it narrow, while `channel_fit` had passed it as FM.
#[test]
fn a_channel_with_no_mode_is_loaded_as_fm() {
for blank in [None, Some(""), Some(" ")] {
let c = Channel { mode: blank.map(str::to_string), ..fm_at(146.52) };
assert_eq!(with_default_mode(c).mode.as_deref(), Some("FM"), "{blank:?}");
}
for kept in ["NFM", "AM", "DMR"] {
assert_eq!(with_default_mode(mode_at(146.52, kept)).mode.as_deref(), Some(kept));
}
}

/// With no driver the model is a file export, and the CHIRP CSV carries
/// USB/LSB/CW through as themselves — so nothing is excluded for its mode.
#[test]
fn a_model_with_no_driver_keeps_every_analog_mode() {
let model = wideband_rx_model();
assert!(model.driver_key.is_none());
for mode in ["AM", "USB", "LSB", "CW"] {
assert_eq!(channel_fit(&mode_at(146.52, mode), &model), ChannelFit::Included, "{mode}");
}
}

/// The list is the channel library's own vocabulary, upper case, and FM is
/// always on it — a typo here would exclude every channel of that mode.
#[test]
fn every_drivers_analog_modes_are_library_modes_and_include_fm() {
for d in crate::radios::registry::all_drivers() {
let modes = d.analog_modes();
assert!(modes.contains(&"FM"), "{}", d.key());
for m in modes {
assert!(["FM", "NFM", "AM", "USB", "LSB", "CW"].contains(m), "{}: {m}", d.key());
}
}
}

/// Issue #32: GMRS was dropped from an FT5D codeplug because 462 MHz is
/// outside the radio's transmit range — but the radio receives it perfectly
/// well, so the channel belongs in the codeplug, labelled receive-only.
Expand Down
78 changes: 69 additions & 9 deletions src-tauri/src/commands/program.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
//! model row already carries the key.

use serde::Serialize;
use tauri::{AppHandle, Manager, State};
use tauri::{AppHandle, Emitter, Manager, State};

use crate::commands::dmr_users::select_prioritized_dmr_users;
use crate::commands::export;
Expand Down Expand Up @@ -195,6 +195,23 @@ pub struct DownloadResult {
pub channels: Vec<DecodedChannelSample>,
}

/// The dialog's view of a radio session: every [`crate::radios::operator`]
/// step becomes a `radio-operator-step` event. Built inside the blocking
/// closure because the sink is thread-local to the session's thread.
fn operator_sink(app: &AppHandle) -> crate::radios::operator::Sink {
let app = app.clone();
Box::new(move |step| {
let _ = app.emit("radio-operator-step", step);
})
}

/// Continue (`true`) or Cancel (`false`) on the step a radio session is waiting
/// on — the FT-857D/897's "press RCV on the radio, then Continue".
#[tauri::command]
pub fn operator_answer(proceed: bool) {
crate::radios::operator::answer(proceed);
}

/// Read a clone-mode radio's full memory image and save it as a timestamped
/// backup. This is the non-destructive proof that the protocol port is correct,
/// and it produces the safety backup the write path always takes first.
Expand Down Expand Up @@ -227,11 +244,14 @@ pub async fn download_image(
// self-identifying (previously hardcoded "uv5r-"/"tdh3-").
let backup_path = backup_dir.join(format!("{driver_key}-{stamp}.img"));

let op_app = app.clone();
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)
let _port_guard = crate::radios::port_lock::claim(&port)?;
let (ident, image) = imager.download_image(&port)?;
let (ident, image) = crate::radios::operator::with_sink(operator_sink(&op_app), || {
imager.download_image(&port)
})?;

std::fs::write(&backup_path, &image)
.map_err(|e| format!("could not write backup {}: {e}", backup_path.display()))?;
Expand Down Expand Up @@ -351,11 +371,14 @@ pub async fn read_radio_settings(
let key = target.driver.key();
let slug = slug(&target.profile_name);

let op_app = app.clone();
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)
let _port_guard = crate::radios::port_lock::claim(&port)?;
let capture = reader.read_settings(&port, &target.schema)?;
let capture = crate::radios::operator::with_sink(operator_sink(&op_app), || {
reader.read_settings(&port, &target.schema)
})?;

// Named after the driver key, matching `download_image`'s convention, so
// a folder of backups stays self-identifying.
Expand Down Expand Up @@ -863,11 +886,14 @@ pub async fn write_radio_settings(
let backup_dir = app.path().app_data_dir().estr()?.join("radio-backups");
std::fs::create_dir_all(&backup_dir).estr()?;

let op_app = app.clone();
let mut 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)
let _port_guard = crate::radios::port_lock::claim(&port)?;
writer.write_settings(&port, &settings, &target.schema, &backup_dir)
crate::radios::operator::with_sink(operator_sink(&op_app), || {
writer.write_settings(&port, &settings, &target.schema, &backup_dir)
})
})
.await
.estr()??;
Expand Down Expand Up @@ -1227,6 +1253,7 @@ pub async fn program_radio(
_ => None,
};

let op_app = app.clone();
let mut 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 All @@ -1239,7 +1266,9 @@ pub async fn program_radio(
backup_dir: &backup_dir,
label: &label,
};
imager.program_codeplug(&port, &req)
crate::radios::operator::with_sink(operator_sink(&op_app), || {
imager.program_codeplug(&port, &req)
})
})
.await
.estr()??;
Expand Down Expand Up @@ -1324,10 +1353,31 @@ pub struct RestoreResult {
/// function spoke UV-5R and nothing else.
#[tauri::command]
pub async fn restore_image(
app: AppHandle,
driver_key: String,
port: String,
path: String,
) -> Result<RestoreResult, String> {
restore_image_from(Some(move || operator_sink(&app)), driver_key, port, path).await
}

/// [`restore_image`]'s body. `sink` builds the operator-step sink on the
/// session's thread; it is `None` only in tests, which have no window.
///
/// ⚠ A sink FACTORY, not an `AppHandle`: a test that so much as names
/// `AppHandle` links Tauri's window runtime into the test binary, and on
/// Windows that binary then dies at load with STATUS_ENTRYPOINT_NOT_FOUND —
/// the runtime imports functions that resolve only under the app's manifest,
/// which a test executable does not carry (PR #153's first CI run).
async fn restore_image_from<F>(
sink: Option<F>,
driver_key: String,
port: String,
path: String,
) -> Result<RestoreResult, String>
where
F: FnOnce() -> crate::radios::operator::Sink + Send + 'static,
{
let driver = driver(&driver_key)?;
let restorer = driver.as_image_restorer().ok_or_else(|| {
format!(
Expand All @@ -1348,7 +1398,12 @@ pub async fn restore_image(
restorer
.check_restore_image(&image)
.map_err(|e| format!("{path}: {e}"))?;
restorer.restore_image(&port, &image)?;
match sink {
Some(make) => crate::radios::operator::with_sink(make(), || {
restorer.restore_image(&port, &image)
})?,
None => restorer.restore_image(&port, &image)?,
}
Ok(RestoreResult {
bytes: image.len(),
source: path,
Expand All @@ -1362,6 +1417,9 @@ pub async fn restore_image(
mod tests {
use super::*;

/// No window in a test: a typed `None` that never names Tauri's runtime.
const NO_SINK: Option<fn() -> crate::radios::operator::Sink> = None;

/// A restore is now dispatched by driver key, and a driver that cannot put
/// a backup back says so instead of speaking somebody else's protocol at
/// the radio.
Expand All @@ -1371,7 +1429,8 @@ mod tests {
/// cable, while the dialog offered the button to every clone radio.
#[tokio::test]
async fn a_driver_that_cannot_restore_says_so_before_touching_the_port() {
let err = restore_image(
let err = restore_image_from(
NO_SINK,
"kenwood_thd75".into(),
"/dev/cu.nonesuch".into(),
"/nonexistent.img".into(),
Expand All @@ -1385,7 +1444,7 @@ mod tests {

#[tokio::test]
async fn an_unknown_driver_key_is_refused() {
assert!(restore_image("no_such_radio".into(), "p".into(), "f".into())
assert!(restore_image_from(NO_SINK, "no_such_radio".into(), "p".into(), "f".into())
.await
.is_err());
}
Expand All @@ -1398,7 +1457,8 @@ mod tests {
let path = std::env::temp_dir().join("cpm-restore-dispatch-test.img");
// A TD-H3-shaped image: right length for that radio, wrong for a UV-5R.
std::fs::write(&path, vec![0u8; 0x2008]).unwrap();
let err = restore_image(
let err = restore_image_from(
NO_SINK,
"baofeng_uv5r".into(),
"/dev/cu.nonesuch".into(),
path.to_string_lossy().into_owned(),
Expand Down
10 changes: 5 additions & 5 deletions src-tauri/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ mod tests {
// original set): currently the Baofeng UV-5R, TIDRADIO TD-H3, AnyTone
// AT-D890UV and AT-D578UV, Yaesu FT5D, Icom ID-52, Kenwood TH-D75, Kenwood TH-D72
// the Binteradio BT-9000, Kenwood TM-D710, Icom ID-5100, TYT MD-380 and Yaesu
// FTM-300DR and FTM-510DR. (0015
// FTM-300DR and FTM-510DR, and the FT-857D and FT-897D. (0015
// removed the Vero VR-N76 placeholder.) None of the last five has a
// migration of its own — seeding INSERTs new (manufacturer, model)
// rows, so a new model reaches existing databases on the next startup
Expand All @@ -71,9 +71,9 @@ mod tests {
.await
.unwrap();
assert_eq!(
count.0, 14,
count.0, 16,
"expected the UV-5R, TD-H3, AT-D890UV, AT-D578UV, FT5D, ID-52, TH-D75, TH-D72, BT-9000, \
TM-D710, ID-5100, MD-380, FTM-300DR and FTM-510DR seeded models"
TM-D710, ID-5100, MD-380, FTM-300DR, FTM-510DR, FT-857D and FT-897D seeded models"
);

let models: Vec<(String,)> =
Expand All @@ -85,7 +85,7 @@ mod tests {
assert_eq!(
names,
vec![
"AT-D578UV", "AT-D890UV", "BT-9000", "FT5D", "FTM-300DR", "FTM-510DR", "ID-5100", "ID-52",
"AT-D578UV", "AT-D890UV", "BT-9000", "FT-857D", "FT-897D", "FT5D", "FTM-300DR", "FTM-510DR", "ID-5100", "ID-52",
"MD-380", "TD-H3", "TH-D72", "TH-D75", "TM-D710", "UV-5R"
]
);
Expand All @@ -96,7 +96,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count2.0, 14, "seeding should be idempotent");
assert_eq!(count2.0, 16, "seeding should be idempotent");

// A new database starts with NO talkgroups. The BrandMeister list used
// to be compiled in and seeded here; it is downloaded on request now,
Expand Down
Loading
Loading