diff --git a/README.md b/README.md index 3db5e10..893693f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index 2c327c0..c4c94cf 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -53,6 +53,17 @@ pub(crate) struct CodeplugGroup { pub channels: Vec, } +/// 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 @@ -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, @@ -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. @@ -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", @@ -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. diff --git a/src-tauri/src/commands/program.rs b/src-tauri/src/commands/program.rs index f83d46b..35dff7a 100644 --- a/src-tauri/src/commands/program.rs +++ b/src-tauri/src/commands/program.rs @@ -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; @@ -195,6 +195,23 @@ pub struct DownloadResult { pub channels: Vec, } +/// 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. @@ -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()))?; @@ -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. @@ -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()??; @@ -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) @@ -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()??; @@ -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 { + 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( + sink: Option, + driver_key: String, + port: String, + path: String, +) -> Result +where + F: FnOnce() -> crate::radios::operator::Sink + Send + 'static, +{ let driver = driver(&driver_key)?; let restorer = driver.as_image_restorer().ok_or_else(|| { format!( @@ -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, @@ -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 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. @@ -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(), @@ -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()); } @@ -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(), diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index fa0d571..60d5f36 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -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 @@ -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,)> = @@ -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" ] ); @@ -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, diff --git a/src-tauri/src/ft857_settings_schema.json b/src-tauri/src/ft857_settings_schema.json new file mode 100644 index 0000000..0df24c0 --- /dev/null +++ b/src-tauri/src/ft857_settings_schema.json @@ -0,0 +1,1365 @@ +[ + { + "key": "section-menu", + "label": "Menu", + "type": "section", + "group": "General" + }, + { + "key": "ext-menu", + "label": "Extended Menu", + "type": "boolean" + }, + { + "key": "section-sounds", + "label": "Sounds", + "type": "section", + "group": "Sounds" + }, + { + "key": "beep-vol", + "label": "Beep Volume", + "type": "integer", + "min": 0, + "max": 100 + }, + { + "key": "section-transmit", + "label": "Transmit", + "type": "section", + "group": "Transmit & Receive" + }, + { + "key": "am-mic-gain", + "label": "AM Mic Gain", + "type": "integer", + "min": 0, + "max": 100 + }, + { + "key": "ssb-mic-gain", + "label": "SSB Mic Gain", + "type": "integer", + "min": 0, + "max": 100 + }, + { + "key": "fm-mic-gain", + "label": "FM Mic Gain", + "type": "integer", + "min": 0, + "max": 100 + }, + { + "key": "proc-level", + "label": "Speech Processor Level", + "type": "integer", + "min": 0, + "max": 100 + }, + { + "key": "vox-gain", + "label": "VOX Gain", + "type": "integer", + "min": 1, + "max": 100 + }, + { + "key": "vox-delay", + "label": "VOX Delay (× 100 ms)", + "type": "integer", + "min": 1, + "max": 30 + }, + { + "key": "rf-power-hf", + "label": "RF Power, HF (W)", + "type": "integer", + "min": 5, + "max": 100 + }, + { + "key": "rf-power-6m", + "label": "RF Power, 50 MHz (W)", + "type": "integer", + "min": 5, + "max": 100 + }, + { + "key": "rf-power-vhf", + "label": "RF Power, 144 MHz (W)", + "type": "integer", + "min": 5, + "max": 50 + }, + { + "key": "rf-power-uhf", + "label": "RF Power, 430 MHz (W)", + "type": "integer", + "min": 2, + "max": 20 + }, + { + "key": "section-receive", + "label": "Receive", + "type": "section", + "group": "Transmit & Receive" + }, + { + "key": "nb-level", + "label": "Noise Blanker Level", + "type": "integer", + "min": 0, + "max": 100 + }, + { + "key": "dsp-bpf-width", + "label": "DSP CW Filter Width", + "type": "select", + "options": [ + "60 Hz", + "120 Hz", + "240 Hz" + ] + }, + { + "key": "dsp-lpf-cutoff", + "label": "DSP Low-Pass Cutoff", + "type": "select", + "options": [ + "1000 Hz", + "1160 Hz", + "1320 Hz", + "1480 Hz", + "1650 Hz", + "1800 Hz", + "1970 Hz", + "2130 Hz", + "2290 Hz", + "2450 Hz", + "2610 Hz", + "2770 Hz", + "2940 Hz", + "3100 Hz", + "3260 Hz", + "3420 Hz", + "3580 Hz", + "3740 Hz", + "3900 Hz", + "4060 Hz", + "4230 Hz", + "4390 Hz", + "4550 Hz", + "4710 Hz", + "4870 Hz", + "5030 Hz", + "5190 Hz", + "5390 Hz", + "5520 Hz", + "5680 Hz", + "5840 Hz", + "6000 Hz" + ] + }, + { + "key": "dsp-nr-level", + "label": "DSP Noise Reduction Level", + "type": "integer", + "min": 1, + "max": 16 + }, + { + "key": "section-cw", + "label": "CW", + "type": "section", + "group": "Transmit & Receive" + }, + { + "key": "cw-auto-mode", + "label": "CW Key in SSB/FM (CW Auto Mode)", + "type": "boolean" + }, + { + "key": "cw-pitch", + "label": "CW Pitch", + "type": "select", + "options": [ + "400 Hz", + "500 Hz", + "600 Hz", + "700 Hz", + "800 Hz" + ] + }, + { + "key": "cw-qsk", + "label": "CW QSK Delay", + "type": "select", + "options": [ + "10 ms", + "15 ms", + "20 ms", + "25 ms", + "30 ms" + ] + }, + { + "key": "cw-sidetone", + "label": "CW Sidetone Volume", + "type": "integer", + "min": 0, + "max": 100 + }, + { + "key": "cw-speed", + "label": "CW Keyer Speed", + "type": "select", + "options": [ + "4 wpm", + "5 wpm", + "6 wpm", + "7 wpm", + "8 wpm", + "9 wpm", + "10 wpm", + "11 wpm", + "12 wpm", + "13 wpm", + "14 wpm", + "15 wpm", + "16 wpm", + "17 wpm", + "18 wpm", + "19 wpm", + "20 wpm", + "21 wpm", + "22 wpm", + "23 wpm", + "24 wpm", + "25 wpm", + "26 wpm", + "27 wpm", + "28 wpm", + "29 wpm", + "30 wpm", + "31 wpm", + "32 wpm", + "33 wpm", + "34 wpm", + "35 wpm", + "36 wpm", + "37 wpm", + "38 wpm", + "39 wpm", + "40 wpm", + "41 wpm", + "42 wpm", + "43 wpm", + "44 wpm", + "45 wpm", + "46 wpm", + "47 wpm", + "48 wpm", + "49 wpm", + "50 wpm", + "51 wpm", + "52 wpm", + "53 wpm", + "54 wpm", + "55 wpm", + "56 wpm", + "57 wpm", + "58 wpm", + "59 wpm", + "60 wpm" + ] + }, + { + "key": "cw-weight", + "label": "CW Keyer Weight", + "type": "select", + "options": [ + "1:2.5", + "1:2.6", + "1:2.7", + "1:2.8", + "1:2.9", + "1:3.0", + "1:3.1", + "1:3.2", + "1:3.3", + "1:3.4", + "1:3.5", + "1:3.6", + "1:3.7", + "1:3.8", + "1:3.9", + "1:4.0", + "1:4.1", + "1:4.2", + "1:4.3", + "1:4.4", + "1:4.5" + ] + }, + { + "key": "section-display", + "label": "Display", + "type": "section", + "group": "Display" + }, + { + "key": "disp-color", + "label": "Display Color Mode", + "type": "select", + "options": [ + "ARTS", + "BAND", + "FIX", + "MEMGRP", + "MODE", + "MTR", + "VFO" + ] + }, + { + "key": "disp-color-fix", + "label": "Display Color (FIX number)", + "type": "integer", + "min": 1, + "max": 32 + }, + { + "key": "disp-contrast", + "label": "Display Contrast", + "type": "integer", + "min": 1, + "max": 13 + }, + { + "key": "disp-mode", + "label": "Display Lamp Mode", + "type": "select", + "options": [ + "OFF", + "AUTO1", + "AUTO2", + "ON" + ] + }, + { + "key": "disp-intensity", + "label": "Display Brightness", + "type": "integer", + "min": 1, + "max": 3 + }, + { + "key": "section-keys-dials", + "label": "Keys & Dials", + "type": "section", + "group": "Keys" + }, + { + "key": "clar-dial-sel", + "label": "Clarifier Dial", + "type": "select", + "options": [ + "MAIN", + "VFO/MEM", + "CLAR" + ] + }, + { + "key": "am-fm-dial", + "label": "Main Dial in AM/FM", + "type": "select", + "options": [ + "ENABLE", + "DISABLE" + ] + }, + { + "key": "mem-vfo-dial-mode", + "label": "MEM/VFO Knob Push Function", + "type": "select", + "options": [ + "CW SIDETONE", + "CW SPEED", + "MHz/MEM GRP", + "MIC GAIN", + "NB LEVEL", + "RF POWER", + "STEP" + ] + }, + { + "key": "pg-a", + "label": "Programmable Key [A]", + "type": "select", + "options": [ + "MFa:A/B", + "MFa:A=B", + "MFa:SPL", + "MFb:MW", + "MFb:SKIP/MCLR", + "MFb:TAG", + "MFc:STO", + "MFc:RCL", + "MFc:PROC", + "MFd:RPT", + "MFd:REV", + "MFd:VOX", + "MFe:TON/ENC", + "MFe:TON/DEC", + "MFe:TDCH", + "MFf:ARTS", + "MFf:SRCH", + "MFf:PMS", + "MFg:SCN", + "MFg:PRI", + "MFg:DW", + "MFh:SCOP", + "MFh:WID", + "MFh:STEP", + "MFi:MTR", + "MFi:SWR", + "MFi:DISP", + "MFj:SPOT", + "MFj:BK", + "MFj:KYR", + "MFk:TUNE", + "MFk:DOWN", + "MFk:UP", + "MFl:NB", + "MFl:AGC", + "MFl:AGC SEL", + "MFm:IPO", + "MFm:ATT", + "MFm:NAR", + "MFn:CFIL", + "MFn:FIL1", + "MFn:FIL2", + "MFo:PLY1", + "MFo:PLY2", + "MFo:PLY3", + "MFp:DNR", + "MFp:DNF", + "MFp:DBF", + "01:EXT MENU", + "02:144MHz ARS", + "03:430MHz ARS", + "04:AM&FM DIAL", + "05:AM MIC GAIN", + "06:AM STEP", + "07:APO TIME", + "08:ARTS BEEP", + "09:ARTS ID", + "10:ARTS IDW", + "11:BEACON TEXT", + "12:BEACON TIME", + "13:BEEP TONE", + "14:BEEP VOL", + "15:CAR LSB R", + "16:CAR LSB T", + "17:CAR USB R", + "18:CAR USB T", + "19:CAT RATE", + "20:CAT/LIN/TUN", + "21:CLAR DIAL SEL", + "22:CW AUTO MODE", + "23:CW BFO", + "24:CW DELAY", + "25:CW KEY REV", + "26:CW PADDLE", + "27:CW PITCH", + "28:CW QSK", + "29:CW SIDE TONE", + "30:CW SPEED", + "31:CW TRAINING", + "32:CW WEIGHT", + "33:DCS CODE", + "34:DCS INV", + "35:DIAL STEP", + "36:DIG DISP", + "37:DIG GAIN", + "38:DIG MODE", + "39:DIG SHIFT", + "40:DIG VOX", + "41:DISP COLOR", + "42:DISP CONTRAST", + "43:DISP INTENSITY", + "44:DISP MODE", + "45:DSP BPF WIDTH", + "46:DSP HPF CUTOFF", + "47:DSP LPF CUTOFF", + "48:DSP MIC EQ", + "49:DSP NR LEVEL", + "50:EMERGENCY", + "51:FM MIC GAIN", + "52:FM STEP", + "53:HOME->VFO", + "54:LOCK MODE", + "55:MEM GROUP", + "56:MEM TAG", + "57:MEM/VFO DIAL MODE", + "58:MIC SCAN", + "59:MIC SEL", + "60:MTR ARX", + "61:MTR ATX", + "62:MTR PEAK HOLD", + "63:NB LEVEL", + "64:OP FILTER", + "71:PKT 1200", + "72:PKT 9600", + "73:PKT RATE", + "74:PROC LEVEL", + "75:RF POWER SET", + "76:RPT SHIFT", + "77:SCAN MODE", + "78:SCAN RESUME", + "79:SPLIT TONE", + "80:SQL/RF GAIN", + "81:SSB MIC GAIN", + "82:SSB STEP", + "83:TONE FREQ", + "84:TX TIME", + "85:TUNER/ATAS", + "86:TX IF FILTER", + "87:VOX DELAY", + "88:VOX GAIN", + "89:XVTR A FREQ", + "90:XVTR B FREQ", + "91:XVTR SEL", + "MONI", + "Q.SPL", + "TCALL", + "ATC", + "USER" + ] + }, + { + "key": "pg-b", + "label": "Programmable Key [B]", + "type": "select", + "options": [ + "MFa:A/B", + "MFa:A=B", + "MFa:SPL", + "MFb:MW", + "MFb:SKIP/MCLR", + "MFb:TAG", + "MFc:STO", + "MFc:RCL", + "MFc:PROC", + "MFd:RPT", + "MFd:REV", + "MFd:VOX", + "MFe:TON/ENC", + "MFe:TON/DEC", + "MFe:TDCH", + "MFf:ARTS", + "MFf:SRCH", + "MFf:PMS", + "MFg:SCN", + "MFg:PRI", + "MFg:DW", + "MFh:SCOP", + "MFh:WID", + "MFh:STEP", + "MFi:MTR", + "MFi:SWR", + "MFi:DISP", + "MFj:SPOT", + "MFj:BK", + "MFj:KYR", + "MFk:TUNE", + "MFk:DOWN", + "MFk:UP", + "MFl:NB", + "MFl:AGC", + "MFl:AGC SEL", + "MFm:IPO", + "MFm:ATT", + "MFm:NAR", + "MFn:CFIL", + "MFn:FIL1", + "MFn:FIL2", + "MFo:PLY1", + "MFo:PLY2", + "MFo:PLY3", + "MFp:DNR", + "MFp:DNF", + "MFp:DBF", + "01:EXT MENU", + "02:144MHz ARS", + "03:430MHz ARS", + "04:AM&FM DIAL", + "05:AM MIC GAIN", + "06:AM STEP", + "07:APO TIME", + "08:ARTS BEEP", + "09:ARTS ID", + "10:ARTS IDW", + "11:BEACON TEXT", + "12:BEACON TIME", + "13:BEEP TONE", + "14:BEEP VOL", + "15:CAR LSB R", + "16:CAR LSB T", + "17:CAR USB R", + "18:CAR USB T", + "19:CAT RATE", + "20:CAT/LIN/TUN", + "21:CLAR DIAL SEL", + "22:CW AUTO MODE", + "23:CW BFO", + "24:CW DELAY", + "25:CW KEY REV", + "26:CW PADDLE", + "27:CW PITCH", + "28:CW QSK", + "29:CW SIDE TONE", + "30:CW SPEED", + "31:CW TRAINING", + "32:CW WEIGHT", + "33:DCS CODE", + "34:DCS INV", + "35:DIAL STEP", + "36:DIG DISP", + "37:DIG GAIN", + "38:DIG MODE", + "39:DIG SHIFT", + "40:DIG VOX", + "41:DISP COLOR", + "42:DISP CONTRAST", + "43:DISP INTENSITY", + "44:DISP MODE", + "45:DSP BPF WIDTH", + "46:DSP HPF CUTOFF", + "47:DSP LPF CUTOFF", + "48:DSP MIC EQ", + "49:DSP NR LEVEL", + "50:EMERGENCY", + "51:FM MIC GAIN", + "52:FM STEP", + "53:HOME->VFO", + "54:LOCK MODE", + "55:MEM GROUP", + "56:MEM TAG", + "57:MEM/VFO DIAL MODE", + "58:MIC SCAN", + "59:MIC SEL", + "60:MTR ARX", + "61:MTR ATX", + "62:MTR PEAK HOLD", + "63:NB LEVEL", + "64:OP FILTER", + "71:PKT 1200", + "72:PKT 9600", + "73:PKT RATE", + "74:PROC LEVEL", + "75:RF POWER SET", + "76:RPT SHIFT", + "77:SCAN MODE", + "78:SCAN RESUME", + "79:SPLIT TONE", + "80:SQL/RF GAIN", + "81:SSB MIC GAIN", + "82:SSB STEP", + "83:TONE FREQ", + "84:TX TIME", + "85:TUNER/ATAS", + "86:TX IF FILTER", + "87:VOX DELAY", + "88:VOX GAIN", + "89:XVTR A FREQ", + "90:XVTR B FREQ", + "91:XVTR SEL", + "MONI", + "Q.SPL", + "TCALL", + "ATC", + "USER" + ] + }, + { + "key": "pg-c", + "label": "Programmable Key [C]", + "type": "select", + "options": [ + "MFa:A/B", + "MFa:A=B", + "MFa:SPL", + "MFb:MW", + "MFb:SKIP/MCLR", + "MFb:TAG", + "MFc:STO", + "MFc:RCL", + "MFc:PROC", + "MFd:RPT", + "MFd:REV", + "MFd:VOX", + "MFe:TON/ENC", + "MFe:TON/DEC", + "MFe:TDCH", + "MFf:ARTS", + "MFf:SRCH", + "MFf:PMS", + "MFg:SCN", + "MFg:PRI", + "MFg:DW", + "MFh:SCOP", + "MFh:WID", + "MFh:STEP", + "MFi:MTR", + "MFi:SWR", + "MFi:DISP", + "MFj:SPOT", + "MFj:BK", + "MFj:KYR", + "MFk:TUNE", + "MFk:DOWN", + "MFk:UP", + "MFl:NB", + "MFl:AGC", + "MFl:AGC SEL", + "MFm:IPO", + "MFm:ATT", + "MFm:NAR", + "MFn:CFIL", + "MFn:FIL1", + "MFn:FIL2", + "MFo:PLY1", + "MFo:PLY2", + "MFo:PLY3", + "MFp:DNR", + "MFp:DNF", + "MFp:DBF", + "01:EXT MENU", + "02:144MHz ARS", + "03:430MHz ARS", + "04:AM&FM DIAL", + "05:AM MIC GAIN", + "06:AM STEP", + "07:APO TIME", + "08:ARTS BEEP", + "09:ARTS ID", + "10:ARTS IDW", + "11:BEACON TEXT", + "12:BEACON TIME", + "13:BEEP TONE", + "14:BEEP VOL", + "15:CAR LSB R", + "16:CAR LSB T", + "17:CAR USB R", + "18:CAR USB T", + "19:CAT RATE", + "20:CAT/LIN/TUN", + "21:CLAR DIAL SEL", + "22:CW AUTO MODE", + "23:CW BFO", + "24:CW DELAY", + "25:CW KEY REV", + "26:CW PADDLE", + "27:CW PITCH", + "28:CW QSK", + "29:CW SIDE TONE", + "30:CW SPEED", + "31:CW TRAINING", + "32:CW WEIGHT", + "33:DCS CODE", + "34:DCS INV", + "35:DIAL STEP", + "36:DIG DISP", + "37:DIG GAIN", + "38:DIG MODE", + "39:DIG SHIFT", + "40:DIG VOX", + "41:DISP COLOR", + "42:DISP CONTRAST", + "43:DISP INTENSITY", + "44:DISP MODE", + "45:DSP BPF WIDTH", + "46:DSP HPF CUTOFF", + "47:DSP LPF CUTOFF", + "48:DSP MIC EQ", + "49:DSP NR LEVEL", + "50:EMERGENCY", + "51:FM MIC GAIN", + "52:FM STEP", + "53:HOME->VFO", + "54:LOCK MODE", + "55:MEM GROUP", + "56:MEM TAG", + "57:MEM/VFO DIAL MODE", + "58:MIC SCAN", + "59:MIC SEL", + "60:MTR ARX", + "61:MTR ATX", + "62:MTR PEAK HOLD", + "63:NB LEVEL", + "64:OP FILTER", + "71:PKT 1200", + "72:PKT 9600", + "73:PKT RATE", + "74:PROC LEVEL", + "75:RF POWER SET", + "76:RPT SHIFT", + "77:SCAN MODE", + "78:SCAN RESUME", + "79:SPLIT TONE", + "80:SQL/RF GAIN", + "81:SSB MIC GAIN", + "82:SSB STEP", + "83:TONE FREQ", + "84:TX TIME", + "85:TUNER/ATAS", + "86:TX IF FILTER", + "87:VOX DELAY", + "88:VOX GAIN", + "89:XVTR A FREQ", + "90:XVTR B FREQ", + "91:XVTR SEL", + "MONI", + "Q.SPL", + "TCALL", + "ATC", + "USER" + ] + }, + { + "key": "pg-acc", + "label": "Mic [ACC] Button", + "type": "select", + "options": [ + "MFa:A/B", + "MFa:A=B", + "MFa:SPL", + "MFb:MW", + "MFb:SKIP/MCLR", + "MFb:TAG", + "MFc:STO", + "MFc:RCL", + "MFc:PROC", + "MFd:RPT", + "MFd:REV", + "MFd:VOX", + "MFe:TON/ENC", + "MFe:TON/DEC", + "MFe:TDCH", + "MFf:ARTS", + "MFf:SRCH", + "MFf:PMS", + "MFg:SCN", + "MFg:PRI", + "MFg:DW", + "MFh:SCOP", + "MFh:WID", + "MFh:STEP", + "MFi:MTR", + "MFi:SWR", + "MFi:DISP", + "MFj:SPOT", + "MFj:BK", + "MFj:KYR", + "MFk:TUNE", + "MFk:DOWN", + "MFk:UP", + "MFl:NB", + "MFl:AGC", + "MFl:AGC SEL", + "MFm:IPO", + "MFm:ATT", + "MFm:NAR", + "MFn:CFIL", + "MFn:FIL1", + "MFn:FIL2", + "MFo:PLY1", + "MFo:PLY2", + "MFo:PLY3", + "MFp:DNR", + "MFp:DNF", + "MFp:DBF", + "01:EXT MENU", + "02:144MHz ARS", + "03:430MHz ARS", + "04:AM&FM DIAL", + "05:AM MIC GAIN", + "06:AM STEP", + "07:APO TIME", + "08:ARTS BEEP", + "09:ARTS ID", + "10:ARTS IDW", + "11:BEACON TEXT", + "12:BEACON TIME", + "13:BEEP TONE", + "14:BEEP VOL", + "15:CAR LSB R", + "16:CAR LSB T", + "17:CAR USB R", + "18:CAR USB T", + "19:CAT RATE", + "20:CAT/LIN/TUN", + "21:CLAR DIAL SEL", + "22:CW AUTO MODE", + "23:CW BFO", + "24:CW DELAY", + "25:CW KEY REV", + "26:CW PADDLE", + "27:CW PITCH", + "28:CW QSK", + "29:CW SIDE TONE", + "30:CW SPEED", + "31:CW TRAINING", + "32:CW WEIGHT", + "33:DCS CODE", + "34:DCS INV", + "35:DIAL STEP", + "36:DIG DISP", + "37:DIG GAIN", + "38:DIG MODE", + "39:DIG SHIFT", + "40:DIG VOX", + "41:DISP COLOR", + "42:DISP CONTRAST", + "43:DISP INTENSITY", + "44:DISP MODE", + "45:DSP BPF WIDTH", + "46:DSP HPF CUTOFF", + "47:DSP LPF CUTOFF", + "48:DSP MIC EQ", + "49:DSP NR LEVEL", + "50:EMERGENCY", + "51:FM MIC GAIN", + "52:FM STEP", + "53:HOME->VFO", + "54:LOCK MODE", + "55:MEM GROUP", + "56:MEM TAG", + "57:MEM/VFO DIAL MODE", + "58:MIC SCAN", + "59:MIC SEL", + "60:MTR ARX", + "61:MTR ATX", + "62:MTR PEAK HOLD", + "63:NB LEVEL", + "64:OP FILTER", + "71:PKT 1200", + "72:PKT 9600", + "73:PKT RATE", + "74:PROC LEVEL", + "75:RF POWER SET", + "76:RPT SHIFT", + "77:SCAN MODE", + "78:SCAN RESUME", + "79:SPLIT TONE", + "80:SQL/RF GAIN", + "81:SSB MIC GAIN", + "82:SSB STEP", + "83:TONE FREQ", + "84:TX TIME", + "85:TUNER/ATAS", + "86:TX IF FILTER", + "87:VOX DELAY", + "88:VOX GAIN", + "89:XVTR A FREQ", + "90:XVTR B FREQ", + "91:XVTR SEL", + "MONI", + "Q.SPL", + "TCALL", + "ATC", + "USER" + ] + }, + { + "key": "pg-p1", + "label": "Mic [P1] Button", + "type": "select", + "options": [ + "MFa:A/B", + "MFa:A=B", + "MFa:SPL", + "MFb:MW", + "MFb:SKIP/MCLR", + "MFb:TAG", + "MFc:STO", + "MFc:RCL", + "MFc:PROC", + "MFd:RPT", + "MFd:REV", + "MFd:VOX", + "MFe:TON/ENC", + "MFe:TON/DEC", + "MFe:TDCH", + "MFf:ARTS", + "MFf:SRCH", + "MFf:PMS", + "MFg:SCN", + "MFg:PRI", + "MFg:DW", + "MFh:SCOP", + "MFh:WID", + "MFh:STEP", + "MFi:MTR", + "MFi:SWR", + "MFi:DISP", + "MFj:SPOT", + "MFj:BK", + "MFj:KYR", + "MFk:TUNE", + "MFk:DOWN", + "MFk:UP", + "MFl:NB", + "MFl:AGC", + "MFl:AGC SEL", + "MFm:IPO", + "MFm:ATT", + "MFm:NAR", + "MFn:CFIL", + "MFn:FIL1", + "MFn:FIL2", + "MFo:PLY1", + "MFo:PLY2", + "MFo:PLY3", + "MFp:DNR", + "MFp:DNF", + "MFp:DBF", + "01:EXT MENU", + "02:144MHz ARS", + "03:430MHz ARS", + "04:AM&FM DIAL", + "05:AM MIC GAIN", + "06:AM STEP", + "07:APO TIME", + "08:ARTS BEEP", + "09:ARTS ID", + "10:ARTS IDW", + "11:BEACON TEXT", + "12:BEACON TIME", + "13:BEEP TONE", + "14:BEEP VOL", + "15:CAR LSB R", + "16:CAR LSB T", + "17:CAR USB R", + "18:CAR USB T", + "19:CAT RATE", + "20:CAT/LIN/TUN", + "21:CLAR DIAL SEL", + "22:CW AUTO MODE", + "23:CW BFO", + "24:CW DELAY", + "25:CW KEY REV", + "26:CW PADDLE", + "27:CW PITCH", + "28:CW QSK", + "29:CW SIDE TONE", + "30:CW SPEED", + "31:CW TRAINING", + "32:CW WEIGHT", + "33:DCS CODE", + "34:DCS INV", + "35:DIAL STEP", + "36:DIG DISP", + "37:DIG GAIN", + "38:DIG MODE", + "39:DIG SHIFT", + "40:DIG VOX", + "41:DISP COLOR", + "42:DISP CONTRAST", + "43:DISP INTENSITY", + "44:DISP MODE", + "45:DSP BPF WIDTH", + "46:DSP HPF CUTOFF", + "47:DSP LPF CUTOFF", + "48:DSP MIC EQ", + "49:DSP NR LEVEL", + "50:EMERGENCY", + "51:FM MIC GAIN", + "52:FM STEP", + "53:HOME->VFO", + "54:LOCK MODE", + "55:MEM GROUP", + "56:MEM TAG", + "57:MEM/VFO DIAL MODE", + "58:MIC SCAN", + "59:MIC SEL", + "60:MTR ARX", + "61:MTR ATX", + "62:MTR PEAK HOLD", + "63:NB LEVEL", + "64:OP FILTER", + "71:PKT 1200", + "72:PKT 9600", + "73:PKT RATE", + "74:PROC LEVEL", + "75:RF POWER SET", + "76:RPT SHIFT", + "77:SCAN MODE", + "78:SCAN RESUME", + "79:SPLIT TONE", + "80:SQL/RF GAIN", + "81:SSB MIC GAIN", + "82:SSB STEP", + "83:TONE FREQ", + "84:TX TIME", + "85:TUNER/ATAS", + "86:TX IF FILTER", + "87:VOX DELAY", + "88:VOX GAIN", + "89:XVTR A FREQ", + "90:XVTR B FREQ", + "91:XVTR SEL", + "MONI", + "Q.SPL", + "TCALL", + "ATC", + "USER" + ] + }, + { + "key": "pg-p2", + "label": "Mic [P2] Button", + "type": "select", + "options": [ + "MFa:A/B", + "MFa:A=B", + "MFa:SPL", + "MFb:MW", + "MFb:SKIP/MCLR", + "MFb:TAG", + "MFc:STO", + "MFc:RCL", + "MFc:PROC", + "MFd:RPT", + "MFd:REV", + "MFd:VOX", + "MFe:TON/ENC", + "MFe:TON/DEC", + "MFe:TDCH", + "MFf:ARTS", + "MFf:SRCH", + "MFf:PMS", + "MFg:SCN", + "MFg:PRI", + "MFg:DW", + "MFh:SCOP", + "MFh:WID", + "MFh:STEP", + "MFi:MTR", + "MFi:SWR", + "MFi:DISP", + "MFj:SPOT", + "MFj:BK", + "MFj:KYR", + "MFk:TUNE", + "MFk:DOWN", + "MFk:UP", + "MFl:NB", + "MFl:AGC", + "MFl:AGC SEL", + "MFm:IPO", + "MFm:ATT", + "MFm:NAR", + "MFn:CFIL", + "MFn:FIL1", + "MFn:FIL2", + "MFo:PLY1", + "MFo:PLY2", + "MFo:PLY3", + "MFp:DNR", + "MFp:DNF", + "MFp:DBF", + "01:EXT MENU", + "02:144MHz ARS", + "03:430MHz ARS", + "04:AM&FM DIAL", + "05:AM MIC GAIN", + "06:AM STEP", + "07:APO TIME", + "08:ARTS BEEP", + "09:ARTS ID", + "10:ARTS IDW", + "11:BEACON TEXT", + "12:BEACON TIME", + "13:BEEP TONE", + "14:BEEP VOL", + "15:CAR LSB R", + "16:CAR LSB T", + "17:CAR USB R", + "18:CAR USB T", + "19:CAT RATE", + "20:CAT/LIN/TUN", + "21:CLAR DIAL SEL", + "22:CW AUTO MODE", + "23:CW BFO", + "24:CW DELAY", + "25:CW KEY REV", + "26:CW PADDLE", + "27:CW PITCH", + "28:CW QSK", + "29:CW SIDE TONE", + "30:CW SPEED", + "31:CW TRAINING", + "32:CW WEIGHT", + "33:DCS CODE", + "34:DCS INV", + "35:DIAL STEP", + "36:DIG DISP", + "37:DIG GAIN", + "38:DIG MODE", + "39:DIG SHIFT", + "40:DIG VOX", + "41:DISP COLOR", + "42:DISP CONTRAST", + "43:DISP INTENSITY", + "44:DISP MODE", + "45:DSP BPF WIDTH", + "46:DSP HPF CUTOFF", + "47:DSP LPF CUTOFF", + "48:DSP MIC EQ", + "49:DSP NR LEVEL", + "50:EMERGENCY", + "51:FM MIC GAIN", + "52:FM STEP", + "53:HOME->VFO", + "54:LOCK MODE", + "55:MEM GROUP", + "56:MEM TAG", + "57:MEM/VFO DIAL MODE", + "58:MIC SCAN", + "59:MIC SEL", + "60:MTR ARX", + "61:MTR ATX", + "62:MTR PEAK HOLD", + "63:NB LEVEL", + "64:OP FILTER", + "71:PKT 1200", + "72:PKT 9600", + "73:PKT RATE", + "74:PROC LEVEL", + "75:RF POWER SET", + "76:RPT SHIFT", + "77:SCAN MODE", + "78:SCAN RESUME", + "79:SPLIT TONE", + "80:SQL/RF GAIN", + "81:SSB MIC GAIN", + "82:SSB STEP", + "83:TONE FREQ", + "84:TX TIME", + "85:TUNER/ATAS", + "86:TX IF FILTER", + "87:VOX DELAY", + "88:VOX GAIN", + "89:XVTR A FREQ", + "90:XVTR B FREQ", + "91:XVTR SEL", + "MONI", + "Q.SPL", + "TCALL", + "ATC", + "USER" + ] + }, + { + "key": "section-scan", + "label": "Scan", + "type": "section", + "group": "Scan" + }, + { + "key": "scan-resume", + "label": "Scan Resume (s)", + "type": "integer", + "min": 1, + "max": 10 + }, + { + "key": "section-data", + "label": "Data", + "type": "section", + "group": "Digital" + }, + { + "key": "dig-gain", + "label": "DIG Input Gain", + "type": "integer", + "min": 0, + "max": 100 + }, + { + "key": "dig-mode", + "label": "DIG Mode", + "type": "select", + "options": [ + "RTTY-L", + "RTTY-U", + "PSK31-L", + "PSK31-U", + "USER-L", + "USER-U" + ] + }, + { + "key": "pkt-1200-gain", + "label": "Packet 1200 Input Gain", + "type": "integer", + "min": 0, + "max": 100 + }, + { + "key": "pkt-9600-gain", + "label": "Packet 9600 Input Gain", + "type": "integer", + "min": 0, + "max": 100 + }, + { + "key": "section-cat", + "label": "CAT", + "type": "section", + "group": "Connections" + }, + { + "key": "cat-rate", + "label": "CAT Baud Rate", + "type": "select", + "options": [ + "4800", + "9600", + "38400" + ] + }, + { + "key": "section-cw-id-beacon", + "label": "CW ID & Beacon", + "type": "section", + "group": "DTMF & Signaling" + }, + { + "key": "arts-idw", + "label": "ARTS CW ID (call sign)", + "type": "text", + "max_length": 10 + }, + { + "key": "beacon-text-1", + "label": "Beacon Text 1", + "type": "text", + "max_length": 40 + }, + { + "key": "beacon-text-2", + "label": "Beacon Text 2", + "type": "text", + "max_length": 40 + }, + { + "key": "beacon-text-3", + "label": "Beacon Text 3", + "type": "text", + "max_length": 40 + } +] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e6a67a0..5699d18 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -243,6 +243,7 @@ pub fn run() { commands::program::program_radio, // direct radio programming (UV-5R) commands::program::restore_image, + commands::program::operator_answer, commands::radio_backups::backups_dir, commands::radio_backups::radio_backups_summary, commands::radio_backups::prune_radio_backups, diff --git a/src-tauri/src/radios/anytone_atd578uv/mod.rs b/src-tauri/src/radios/anytone_atd578uv/mod.rs index e8ae89a..e741416 100644 --- a/src-tauri/src/radios/anytone_atd578uv/mod.rs +++ b/src-tauri/src/radios/anytone_atd578uv/mod.rs @@ -70,6 +70,11 @@ pub(crate) const D578_IDENT: IdentExpect = IdentExpect { }; impl RadioDriver for AnytoneAtd578uv { + /// AM goes to the air-band memory, and only on the air band — `program::plan_d578`. + fn analog_modes(&self) -> &'static [&'static str] { + &["FM", "NFM", "AM"] + } + fn key(&self) -> &'static str { "anytone_atd578uv" } diff --git a/src-tauri/src/radios/driver.rs b/src-tauri/src/radios/driver.rs index 87aaa88..b444e91 100644 --- a/src-tauri/src/radios/driver.rs +++ b/src-tauri/src/radios/driver.rs @@ -124,6 +124,20 @@ pub(crate) trait RadioDriver: Send + Sync { false } + /// The analog modes this driver's encoder writes as themselves, in the + /// channel library's vocabulary (upper case). `export::channel_fit` excludes + /// any other analog mode for the radio, with a reason. + /// + /// This is what the *encoder* does, not what the radio can do. Every encoder + /// here falls back to FM for a mode it does not handle, so a USB or AM channel + /// that got past `channel_fit` used to program as a silent, useless FM memory. + /// A driver lists a mode — NFM included — only once its encoder writes that + /// mode as itself; a radio whose encoder has no narrow bit leaves NFM off and + /// NFM channels are excluded rather than sent out wide. + fn analog_modes(&self) -> &'static [&'static str] { + &["FM", "NFM"] + } + // --- Capability accessors ------------------------------------------------- // Default `None`; a driver overrides the ones it supports. The derived // `DriverCapabilities` (below) reads these, so a capability is advertised to diff --git a/src-tauri/src/radios/fake_port.rs b/src-tauri/src/radios/fake_port.rs index b1ec20c..0358a16 100644 --- a/src-tauri/src/radios/fake_port.rs +++ b/src-tauri/src/radios/fake_port.rs @@ -56,6 +56,13 @@ impl FakePort { } } + /// Bytes the radio sends before the host has said anything. Every other radio + /// here waits to be asked; the FT-857D/897 starts a clone from its own front + /// panel ([C] SEND) and pushes the first block unprompted. + pub fn radio_sends(&mut self, bytes: &[u8]) { + self.inbox.extend(bytes.iter().copied()); + } + fn pump(&mut self) { loop { let mut reply = Vec::new(); diff --git a/src-tauri/src/radios/icom_id5100/mod.rs b/src-tauri/src/radios/icom_id5100/mod.rs index 637578a..f8cfda5 100644 --- a/src-tauri/src/radios/icom_id5100/mod.rs +++ b/src-tauri/src/radios/icom_id5100/mod.rs @@ -53,6 +53,11 @@ pub(crate) struct IcomId5100; pub(crate) static DRIVER: IcomId5100 = IcomId5100; impl RadioDriver for IcomId5100 { + /// `memory.rs` maps AM to the radio's AM mode. + fn analog_modes(&self) -> &'static [&'static str] { + &["FM", "NFM", "AM"] + } + fn key(&self) -> &'static str { "icom_id5100" } diff --git a/src-tauri/src/radios/icom_id52/mod.rs b/src-tauri/src/radios/icom_id52/mod.rs index 8368ebd..0673cf6 100644 --- a/src-tauri/src/radios/icom_id52/mod.rs +++ b/src-tauri/src/radios/icom_id52/mod.rs @@ -132,6 +132,11 @@ pub(crate) struct IcomId52; pub(crate) static DRIVER: IcomId52 = IcomId52; impl RadioDriver for IcomId52 { + /// `memory.rs` `MODE_AM` / `memory_csv::mode_of`. + fn analog_modes(&self) -> &'static [&'static str] { + &["FM", "NFM", "AM"] + } + fn key(&self) -> &'static str { "icom_id52" } diff --git a/src-tauri/src/radios/kenwood_thd72/mod.rs b/src-tauri/src/radios/kenwood_thd72/mod.rs index 3936c88..5e5db07 100644 --- a/src-tauri/src/radios/kenwood_thd72/mod.rs +++ b/src-tauri/src/radios/kenwood_thd72/mod.rs @@ -69,6 +69,11 @@ pub(crate) struct KenwoodThd72; pub(crate) static DRIVER: KenwoodThd72 = KenwoodThd72; impl RadioDriver for KenwoodThd72 { + /// `memory.rs` `MODE_AM` (receive-only, band B, on the radio). + fn analog_modes(&self) -> &'static [&'static str] { + &["FM", "NFM", "AM"] + } + fn key(&self) -> &'static str { "kenwood_thd72" } diff --git a/src-tauri/src/radios/kenwood_thd75/mod.rs b/src-tauri/src/radios/kenwood_thd75/mod.rs index f0647ef..8b81526 100644 --- a/src-tauri/src/radios/kenwood_thd75/mod.rs +++ b/src-tauri/src/radios/kenwood_thd75/mod.rs @@ -70,6 +70,11 @@ pub(crate) struct KenwoodThd75; pub(crate) static DRIVER: KenwoodThd75 = KenwoodThd75; impl RadioDriver for KenwoodThd75 { + /// `memory.rs` encodes AM. The radio also has LSB/USB/CW; the encoder does not, so they stay off this list. + fn analog_modes(&self) -> &'static [&'static str] { + &["FM", "NFM", "AM"] + } + fn key(&self) -> &'static str { "kenwood_thd75" } diff --git a/src-tauri/src/radios/kenwood_tmd710/mod.rs b/src-tauri/src/radios/kenwood_tmd710/mod.rs index 058d32d..1c4329f 100644 --- a/src-tauri/src/radios/kenwood_tmd710/mod.rs +++ b/src-tauri/src/radios/kenwood_tmd710/mod.rs @@ -271,6 +271,11 @@ pub(crate) struct KenwoodTmD710; pub(crate) static DRIVER: KenwoodTmD710 = KenwoodTmD710; impl RadioDriver for KenwoodTmD710 { + /// `encode.rs` writes AM as mode "2". + fn analog_modes(&self) -> &'static [&'static str] { + &["FM", "NFM", "AM"] + } + fn key(&self) -> &'static str { "kenwood_tmd710" } diff --git a/src-tauri/src/radios/mod.rs b/src-tauri/src/radios/mod.rs index cb45a83..3252e84 100644 --- a/src-tauri/src/radios/mod.rs +++ b/src-tauri/src/radios/mod.rs @@ -49,6 +49,7 @@ pub(crate) mod kenwood_tmd710; /// the driver it is informing exists. #[cfg(test)] mod kenwood_tmd710_probe; +pub(crate) mod operator; pub(crate) mod port_lock; pub(crate) mod registry; pub(crate) mod settings_bounds; @@ -57,5 +58,6 @@ pub(crate) mod tyt_md380; #[cfg(test)] mod wiring; pub(crate) mod yaesu_ft5d; +pub(crate) mod yaesu_ft857d; pub(crate) mod yaesu_ftm300; pub(crate) mod yaesu_ftm510; diff --git a/src-tauri/src/radios/operator.rs b/src-tauri/src/radios/operator.rs new file mode 100644 index 0000000..163adae --- /dev/null +++ b/src-tauri/src/radios/operator.rs @@ -0,0 +1,194 @@ +//! Telling the operator what to do at the radio, mid-operation, and waiting for +//! them when the radio cannot say they have done it. +//! +//! Every radio command is one blocking session under one port lock. Most radios +//! never need the operator during it. The FT-857D/FT-897 does, three times in a +//! program: [C] SEND for the backup read, [A] RCV before the write, [C] SEND +//! again for the read-back. A radio that has had SEND pressed announces itself — +//! its first block arrives — so [`say`] is enough. A radio waiting in RCV is +//! silent, so before sending a byte the driver must [`wait`] for the operator to +//! click Continue, which is the step CHIRP takes with an OK dialog. +//! +//! The command layer installs a sink for the session with [`with_sink`]; the +//! sink is thread-local because a driver runs on the same blocking thread as the +//! command that called it. With no sink installed — tests, `#[ignore]`d +//! hardware harnesses — [`say`] prints and [`wait`] proceeds at once; a write to +//! a radio that was not actually put in RCV then fails at block 0 with nothing +//! written, which is the safe way for that to go wrong. + +use std::cell::RefCell; +use std::sync::{Condvar, Mutex}; +use std::time::Duration; + +use serde::Serialize; + +/// What the dialog shows. `text: None` clears it. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct OperatorStep { + pub text: Option, + /// The dialog offers Continue and Cancel, and the operation is paused until + /// one is clicked. + pub needs_continue: bool, +} + +pub(crate) type Sink = Box; + +thread_local! { + static SINK: RefCell> = const { RefCell::new(None) }; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Gate { + Idle, + Waiting, + Continue, + Cancel, +} + +static GATE: Mutex = Mutex::new(Gate::Idle); +static GATE_CHANGED: Condvar = Condvar::new(); + +/// How long [`wait`] holds the port before giving up on the operator. +pub(crate) const WAIT_LIMIT: Duration = Duration::from_secs(600); + +fn emit(step: &OperatorStep) { + SINK.with(|s| match s.borrow().as_ref() { + Some(sink) => sink(step), + None => { + if let Some(t) = &step.text { + eprintln!("[operator] {t}"); + } + } + }); +} + +/// Run `body` with `sink` receiving every step, and clear the dialog after. +pub(crate) fn with_sink(sink: Sink, body: impl FnOnce() -> T) -> T { + SINK.with(|s| *s.borrow_mut() = Some(sink)); + let out = body(); + emit(&OperatorStep { text: None, needs_continue: false }); + SINK.with(|s| *s.borrow_mut() = None); + out +} + +/// Show an instruction the radio itself will confirm (its data arriving). +pub(crate) fn say(text: &str) { + emit(&OperatorStep { text: Some(text.to_string()), needs_continue: false }); +} + +/// Show an instruction and block until the operator clicks Continue. Cancel, or +/// no answer within `limit`, is an error the caller returns before touching +/// the radio. +pub(crate) fn wait(text: &str, limit: Duration) -> Result<(), String> { + let installed = SINK.with(|s| s.borrow().is_some()); + if !installed { + say(text); + return Ok(()); + } + { + let mut g = GATE.lock().map_err(|_| "operator gate poisoned".to_string())?; + if *g == Gate::Waiting { + return Err("Another radio operation is waiting for the operator.".into()); + } + *g = Gate::Waiting; + } + emit(&OperatorStep { text: Some(text.to_string()), needs_continue: true }); + let g = GATE.lock().map_err(|_| "operator gate poisoned".to_string())?; + let (mut g, timed_out) = GATE_CHANGED + .wait_timeout_while(g, limit, |g| *g == Gate::Waiting) + .map_err(|_| "operator gate poisoned".to_string())?; + let answer = *g; + *g = Gate::Idle; + drop(g); + emit(&OperatorStep { text: None, needs_continue: false }); + match answer { + Gate::Continue => Ok(()), + _ if timed_out.timed_out() => { + Err("No answer from the operator, so nothing was sent to the radio.".into()) + } + _ => Err("Cancelled — nothing was sent to the radio.".into()), + } +} + +/// The dialog's Continue (`true`) or Cancel (`false`). Ignored when nothing is +/// waiting, so a stray click cannot pre-answer the next step. +pub(crate) fn answer(proceed: bool) { + if let Ok(mut g) = GATE.lock() { + if *g == Gate::Waiting { + *g = if proceed { Gate::Continue } else { Gate::Cancel }; + GATE_CHANGED.notify_all(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex as StdMutex}; + + // The gate is process-wide; these tests take turns. + static SERIAL: StdMutex<()> = StdMutex::new(()); + + fn recording() -> (Sink, Arc>>) { + let log = Arc::new(StdMutex::new(Vec::new())); + let l = log.clone(); + (Box::new(move |s: &OperatorStep| l.lock().unwrap().push(s.clone())), log) + } + + fn answer_soon(proceed: bool) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + // Answer once the waiter is actually waiting. + for _ in 0..200 { + if *GATE.lock().unwrap() == Gate::Waiting { + answer(proceed); + return; + } + std::thread::sleep(Duration::from_millis(5)); + } + panic!("nobody waited"); + }) + } + + #[test] + fn continue_releases_the_wait_and_the_dialog_is_cleared() { + let _s = SERIAL.lock().unwrap(); + let (sink, log) = recording(); + let t = answer_soon(true); + let r = with_sink(sink, || wait("press RCV", Duration::from_secs(5))); + t.join().unwrap(); + assert_eq!(r, Ok(())); + let log = log.lock().unwrap(); + assert_eq!(log[0], OperatorStep { text: Some("press RCV".into()), needs_continue: true }); + assert_eq!(log.last().unwrap().text, None, "the dialog is cleared at the end"); + } + + #[test] + fn cancel_and_silence_are_errors_that_say_nothing_was_sent() { + let _s = SERIAL.lock().unwrap(); + let (sink, _) = recording(); + let t = answer_soon(false); + let e = with_sink(sink, || wait("press RCV", Duration::from_secs(5))).unwrap_err(); + t.join().unwrap(); + assert!(e.contains("Cancelled") && e.contains("nothing was sent"), "{e}"); + + let (sink, _) = recording(); + let e = with_sink(sink, || wait("press RCV", Duration::from_millis(30))).unwrap_err(); + assert!(e.contains("No answer"), "{e}"); + assert_eq!(*GATE.lock().unwrap(), Gate::Idle); + } + + #[test] + fn a_stray_click_does_not_pre_answer_the_next_step() { + let _s = SERIAL.lock().unwrap(); + answer(true); + let (sink, _) = recording(); + let e = with_sink(sink, || wait("press RCV", Duration::from_millis(30))).unwrap_err(); + assert!(e.contains("No answer"), "{e}"); + } + + #[test] + fn with_no_sink_a_wait_proceeds() { + let _s = SERIAL.lock().unwrap(); + assert_eq!(wait("press RCV", Duration::from_millis(1)), Ok(())); + } +} diff --git a/src-tauri/src/radios/registry.rs b/src-tauri/src/radios/registry.rs index 7664dcd..9e7ee43 100644 --- a/src-tauri/src/radios/registry.rs +++ b/src-tauri/src/radios/registry.rs @@ -23,7 +23,7 @@ use crate::models::RadioModel; /// Every driver compiled into the app. Order is not significant — lookups are /// by `key()`, which is unique. (A static array rather than a slice literal: /// references to statics aren't const-promotable inside a returned temporary.) -static DRIVERS: [&dyn RadioDriver; 14] = [ +static DRIVERS: [&dyn RadioDriver; 15] = [ &super::baofeng_uv5r::DRIVER, &super::binteradio_bt9000::DRIVER, &super::tidradio_tdh3::DRIVER, @@ -38,6 +38,7 @@ static DRIVERS: [&dyn RadioDriver; 14] = [ &super::tyt_md380::DRIVER, &super::yaesu_ftm300::DRIVER, &super::yaesu_ftm510::DRIVER, + &super::yaesu_ft857d::DRIVER, ]; pub(crate) fn all_drivers() -> &'static [&'static dyn RadioDriver] { @@ -93,6 +94,7 @@ mod tests { "kenwood_tmd710", "yaesu_ftm300", "yaesu_ftm510", + "yaesu_ft857d", ] { let d = driver_for_key(key).unwrap_or_else(|| panic!("no driver for '{key}'")); assert_eq!(d.key(), key); @@ -165,6 +167,9 @@ mod tests { // a codeplug program carries the radio's own through untouched, // so the profile's are a separate write — the AnyTone's shape. "tyt_md380" => (true, true), + // FT-857D/897 (#145): both, out of and into the clone image — a + // read-patch-write-read-back of its own, like the BT-9000's. + "yaesu_ft857d" => (true, true), _ => (true, true), }; assert_eq!( @@ -247,6 +252,7 @@ mod tests { | "kenwood_thd72" | "binteradio_bt9000" | "icom_id5100" + | "yaesu_ft857d" ); assert_eq!( d.as_image_programmer().is_some(), diff --git a/src-tauri/src/radios/yaesu_ft5d/mod.rs b/src-tauri/src/radios/yaesu_ft5d/mod.rs index dcc41a9..2acce0d 100644 --- a/src-tauri/src/radios/yaesu_ft5d/mod.rs +++ b/src-tauri/src/radios/yaesu_ft5d/mod.rs @@ -131,6 +131,12 @@ pub(crate) struct YaesuFt5d; pub(crate) static DRIVER: YaesuFt5d = YaesuFt5d; impl RadioDriver for YaesuFt5d { + /// `sd_image.rs` writes AM as mode bits 1. It has no narrow bit — an NFM + /// channel would go out wide — so NFM is not on the list and is excluded. + fn analog_modes(&self) -> &'static [&'static str] { + &["FM", "AM"] + } + fn key(&self) -> &'static str { "yaesu_ft5d" } diff --git a/src-tauri/src/radios/yaesu_ft857d/ft857_settings_table.rs b/src-tauri/src/radios/yaesu_ft857d/ft857_settings_table.rs new file mode 100644 index 0000000..69a1670 --- /dev/null +++ b/src-tauri/src/radios/yaesu_ft857d/ft857_settings_table.rs @@ -0,0 +1,100 @@ +//! FT-857D / FT-897 settings field table — GENERATED, do not edit. +//! +//! Source: `scratchpad/yaesu_ft857d/settings/gen_settings.py`, whose positions +//! come from CHIRP's `ft857.py` struct (`fields.py`) and whose field choice +//! comes from `MEASURED.md`'s grades. The profile schema at +//! `src/ft857_settings_schema.json` is written by the same run, and +//! `settings.rs` asserts the two still describe the same fields. +//! +//! 46 field(s): only rows measured on the radio or anchored by the factory- +//! default cross-check with a settled encoding. An enum anchored at its centre +//! index, every CHIRP conflict and every value that looks like the owner's own +//! choice are withheld until the front-panel check (`MEASURED.md`, batch 1/2). + +/// Where a field lives in the clone image. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) enum At { + /// `width` bits at bit `shift` of image byte `byte`; the others are kept. + Bits { byte: usize, shift: u8, width: u8 }, + /// `len` characters at `off`, one character-set index per byte. + Text { off: usize, len: usize, set: Charset }, +} + +/// The radio's text alphabets — indices, not ASCII (CHIRP `ft857.py`). +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) enum Charset { + /// `0-9 A-Z space /` — the ARTS CW ID. + Callsign, + /// The call-sign set plus `+ .` — the three beacon texts. + Beacon, +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) enum Kind { + /// A displayed number, inclusive bounds; stored = shown + `bias` + /// (DISP CONTRAST stores shown + 2, DSP NR shown − 1). + Int { lo: u16, hi: u16, bias: i16 }, + /// One bit: 0 = OFF, 1 = ON. + Bool, + /// Zero-based index into `options`, in STORED order. + Enum, + Text, +} + +pub(crate) struct SF { + pub key: &'static str, + pub label: &'static str, + pub menu: &'static str, + pub at: At, + pub kind: Kind, + pub options: &'static [&'static str], +} + +pub(crate) const FIELDS: [SF; 46] = [ + SF { key: "ext-menu", label: "Extended Menu", menu: "Menu 001", at: At::Bits { byte: 43, shift: 7, width: 1 }, kind: Kind::Bool, options: &[] }, + SF { key: "beep-vol", label: "Beep Volume", menu: "Menu 014", at: At::Bits { byte: 41, shift: 0, width: 7 }, kind: Kind::Int { lo: 0, hi: 100, bias: 0 }, options: &[] }, + SF { key: "am-mic-gain", label: "AM Mic Gain", menu: "Menu 005", at: At::Bits { byte: 21, shift: 0, width: 7 }, kind: Kind::Int { lo: 0, hi: 100, bias: 0 }, options: &[] }, + SF { key: "ssb-mic-gain", label: "SSB Mic Gain", menu: "Menu 081", at: At::Bits { byte: 20, shift: 0, width: 7 }, kind: Kind::Int { lo: 0, hi: 100, bias: 0 }, options: &[] }, + SF { key: "fm-mic-gain", label: "FM Mic Gain", menu: "Menu 051", at: At::Bits { byte: 22, shift: 0, width: 7 }, kind: Kind::Int { lo: 0, hi: 100, bias: 0 }, options: &[] }, + SF { key: "proc-level", label: "Speech Processor Level", menu: "Menu 074", at: At::Bits { byte: 52, shift: 0, width: 7 }, kind: Kind::Int { lo: 0, hi: 100, bias: 0 }, options: &[] }, + SF { key: "vox-gain", label: "VOX Gain", menu: "Menu 088", at: At::Bits { byte: 16, shift: 0, width: 7 }, kind: Kind::Int { lo: 1, hi: 100, bias: 0 }, options: &[] }, + SF { key: "vox-delay", label: "VOX Delay (× 100 ms)", menu: "Menu 087", at: At::Bits { byte: 17, shift: 0, width: 5 }, kind: Kind::Int { lo: 1, hi: 30, bias: 0 }, options: &[] }, + SF { key: "rf-power-hf", label: "RF Power, HF (W)", menu: "Menu 075", at: At::Bits { byte: 53, shift: 0, width: 7 }, kind: Kind::Int { lo: 5, hi: 100, bias: 0 }, options: &[] }, + SF { key: "rf-power-6m", label: "RF Power, 50 MHz (W)", menu: "Menu 075", at: At::Bits { byte: 68, shift: 0, width: 7 }, kind: Kind::Int { lo: 5, hi: 100, bias: 0 }, options: &[] }, + SF { key: "rf-power-vhf", label: "RF Power, 144 MHz (W)", menu: "Menu 075", at: At::Bits { byte: 69, shift: 0, width: 7 }, kind: Kind::Int { lo: 5, hi: 50, bias: 0 }, options: &[] }, + SF { key: "rf-power-uhf", label: "RF Power, 430 MHz (W)", menu: "Menu 075", at: At::Bits { byte: 70, shift: 0, width: 7 }, kind: Kind::Int { lo: 2, hi: 20, bias: 0 }, options: &[] }, + SF { key: "nb-level", label: "Noise Blanker Level", menu: "Menu 063", at: At::Bits { byte: 51, shift: 0, width: 7 }, kind: Kind::Int { lo: 0, hi: 100, bias: 0 }, options: &[] }, + SF { key: "dsp-bpf-width", label: "DSP CW Filter Width", menu: "Menu 045", at: At::Bits { byte: 45, shift: 2, width: 2 }, kind: Kind::Enum, options: &["60 Hz", "120 Hz", "240 Hz"] }, + SF { key: "dsp-lpf-cutoff", label: "DSP Low-Pass Cutoff", menu: "Menu 047", at: At::Bits { byte: 46, shift: 0, width: 5 }, kind: Kind::Enum, options: &["1000 Hz", "1160 Hz", "1320 Hz", "1480 Hz", "1650 Hz", "1800 Hz", "1970 Hz", "2130 Hz", "2290 Hz", "2450 Hz", "2610 Hz", "2770 Hz", "2940 Hz", "3100 Hz", "3260 Hz", "3420 Hz", "3580 Hz", "3740 Hz", "3900 Hz", "4060 Hz", "4230 Hz", "4390 Hz", "4550 Hz", "4710 Hz", "4870 Hz", "5030 Hz", "5190 Hz", "5390 Hz", "5520 Hz", "5680 Hz", "5840 Hz", "6000 Hz"] }, + SF { key: "dsp-nr-level", label: "DSP Noise Reduction Level", menu: "Menu 049", at: At::Bits { byte: 45, shift: 4, width: 4 }, kind: Kind::Int { lo: 1, hi: 16, bias: -1 }, options: &[] }, + SF { key: "cw-auto-mode", label: "CW Key in SSB/FM (CW Auto Mode)", menu: "Menu 022", at: At::Bits { byte: 44, shift: 7, width: 1 }, kind: Kind::Bool, options: &[] }, + SF { key: "cw-pitch", label: "CW Pitch", menu: "Menu 027", at: At::Bits { byte: 11, shift: 0, width: 3 }, kind: Kind::Enum, options: &["400 Hz", "500 Hz", "600 Hz", "700 Hz", "800 Hz"] }, + SF { key: "cw-qsk", label: "CW QSK Delay", menu: "Menu 028", at: At::Bits { byte: 44, shift: 2, width: 3 }, kind: Kind::Enum, options: &["10 ms", "15 ms", "20 ms", "25 ms", "30 ms"] }, + SF { key: "cw-sidetone", label: "CW Sidetone Volume", menu: "Menu 029", at: At::Bits { byte: 14, shift: 0, width: 7 }, kind: Kind::Int { lo: 0, hi: 100, bias: 0 }, options: &[] }, + SF { key: "cw-speed", label: "CW Keyer Speed", menu: "Menu 030", at: At::Bits { byte: 15, shift: 0, width: 6 }, kind: Kind::Enum, options: &["4 wpm", "5 wpm", "6 wpm", "7 wpm", "8 wpm", "9 wpm", "10 wpm", "11 wpm", "12 wpm", "13 wpm", "14 wpm", "15 wpm", "16 wpm", "17 wpm", "18 wpm", "19 wpm", "20 wpm", "21 wpm", "22 wpm", "23 wpm", "24 wpm", "25 wpm", "26 wpm", "27 wpm", "28 wpm", "29 wpm", "30 wpm", "31 wpm", "32 wpm", "33 wpm", "34 wpm", "35 wpm", "36 wpm", "37 wpm", "38 wpm", "39 wpm", "40 wpm", "41 wpm", "42 wpm", "43 wpm", "44 wpm", "45 wpm", "46 wpm", "47 wpm", "48 wpm", "49 wpm", "50 wpm", "51 wpm", "52 wpm", "53 wpm", "54 wpm", "55 wpm", "56 wpm", "57 wpm", "58 wpm", "59 wpm", "60 wpm"] }, + SF { key: "cw-weight", label: "CW Keyer Weight", menu: "Menu 032", at: At::Bits { byte: 12, shift: 0, width: 5 }, kind: Kind::Enum, options: &["1:2.5", "1:2.6", "1:2.7", "1:2.8", "1:2.9", "1:3.0", "1:3.1", "1:3.2", "1:3.3", "1:3.4", "1:3.5", "1:3.6", "1:3.7", "1:3.8", "1:3.9", "1:4.0", "1:4.1", "1:4.2", "1:4.3", "1:4.4", "1:4.5"] }, + SF { key: "disp-color", label: "Display Color Mode", menu: "Menu 041", at: At::Bits { byte: 48, shift: 0, width: 3 }, kind: Kind::Enum, options: &["ARTS", "BAND", "FIX", "MEMGRP", "MODE", "MTR", "VFO"] }, + SF { key: "disp-color-fix", label: "Display Color (FIX number)", menu: "Menu 041", at: At::Bits { byte: 50, shift: 0, width: 5 }, kind: Kind::Int { lo: 1, hi: 32, bias: -1 }, options: &[] }, + SF { key: "disp-contrast", label: "Display Contrast", menu: "Menu 042", at: At::Bits { byte: 8, shift: 0, width: 4 }, kind: Kind::Int { lo: 1, hi: 13, bias: 2 }, options: &[] }, + SF { key: "disp-mode", label: "Display Lamp Mode", menu: "Menu 044", at: At::Bits { byte: 8, shift: 6, width: 2 }, kind: Kind::Enum, options: &["OFF", "AUTO1", "AUTO2", "ON"] }, + SF { key: "disp-intensity", label: "Display Brightness", menu: "Menu 043", at: At::Bits { byte: 48, shift: 4, width: 2 }, kind: Kind::Int { lo: 1, hi: 3, bias: 0 }, options: &[] }, + SF { key: "clar-dial-sel", label: "Clarifier Dial", menu: "Menu 021", at: At::Bits { byte: 9, shift: 2, width: 2 }, kind: Kind::Enum, options: &["MAIN", "VFO/MEM", "CLAR"] }, + SF { key: "am-fm-dial", label: "Main Dial in AM/FM", menu: "Menu 004", at: At::Bits { byte: 16, shift: 7, width: 1 }, kind: Kind::Enum, options: &["ENABLE", "DISABLE"] }, + SF { key: "mem-vfo-dial-mode", label: "MEM/VFO Knob Push Function", menu: "Menu 057", at: At::Bits { byte: 54, shift: 0, width: 3 }, kind: Kind::Enum, options: &["CW SIDETONE", "CW SPEED", "MHz/MEM GRP", "MIC GAIN", "NB LEVEL", "RF POWER", "STEP"] }, + SF { key: "pg-a", label: "Programmable Key [A]", menu: "Menu 065", at: At::Bits { byte: 55, shift: 0, width: 8 }, kind: Kind::Enum, options: &["MFa:A/B", "MFa:A=B", "MFa:SPL", "MFb:MW", "MFb:SKIP/MCLR", "MFb:TAG", "MFc:STO", "MFc:RCL", "MFc:PROC", "MFd:RPT", "MFd:REV", "MFd:VOX", "MFe:TON/ENC", "MFe:TON/DEC", "MFe:TDCH", "MFf:ARTS", "MFf:SRCH", "MFf:PMS", "MFg:SCN", "MFg:PRI", "MFg:DW", "MFh:SCOP", "MFh:WID", "MFh:STEP", "MFi:MTR", "MFi:SWR", "MFi:DISP", "MFj:SPOT", "MFj:BK", "MFj:KYR", "MFk:TUNE", "MFk:DOWN", "MFk:UP", "MFl:NB", "MFl:AGC", "MFl:AGC SEL", "MFm:IPO", "MFm:ATT", "MFm:NAR", "MFn:CFIL", "MFn:FIL1", "MFn:FIL2", "MFo:PLY1", "MFo:PLY2", "MFo:PLY3", "MFp:DNR", "MFp:DNF", "MFp:DBF", "01:EXT MENU", "02:144MHz ARS", "03:430MHz ARS", "04:AM&FM DIAL", "05:AM MIC GAIN", "06:AM STEP", "07:APO TIME", "08:ARTS BEEP", "09:ARTS ID", "10:ARTS IDW", "11:BEACON TEXT", "12:BEACON TIME", "13:BEEP TONE", "14:BEEP VOL", "15:CAR LSB R", "16:CAR LSB T", "17:CAR USB R", "18:CAR USB T", "19:CAT RATE", "20:CAT/LIN/TUN", "21:CLAR DIAL SEL", "22:CW AUTO MODE", "23:CW BFO", "24:CW DELAY", "25:CW KEY REV", "26:CW PADDLE", "27:CW PITCH", "28:CW QSK", "29:CW SIDE TONE", "30:CW SPEED", "31:CW TRAINING", "32:CW WEIGHT", "33:DCS CODE", "34:DCS INV", "35:DIAL STEP", "36:DIG DISP", "37:DIG GAIN", "38:DIG MODE", "39:DIG SHIFT", "40:DIG VOX", "41:DISP COLOR", "42:DISP CONTRAST", "43:DISP INTENSITY", "44:DISP MODE", "45:DSP BPF WIDTH", "46:DSP HPF CUTOFF", "47:DSP LPF CUTOFF", "48:DSP MIC EQ", "49:DSP NR LEVEL", "50:EMERGENCY", "51:FM MIC GAIN", "52:FM STEP", "53:HOME->VFO", "54:LOCK MODE", "55:MEM GROUP", "56:MEM TAG", "57:MEM/VFO DIAL MODE", "58:MIC SCAN", "59:MIC SEL", "60:MTR ARX", "61:MTR ATX", "62:MTR PEAK HOLD", "63:NB LEVEL", "64:OP FILTER", "71:PKT 1200", "72:PKT 9600", "73:PKT RATE", "74:PROC LEVEL", "75:RF POWER SET", "76:RPT SHIFT", "77:SCAN MODE", "78:SCAN RESUME", "79:SPLIT TONE", "80:SQL/RF GAIN", "81:SSB MIC GAIN", "82:SSB STEP", "83:TONE FREQ", "84:TX TIME", "85:TUNER/ATAS", "86:TX IF FILTER", "87:VOX DELAY", "88:VOX GAIN", "89:XVTR A FREQ", "90:XVTR B FREQ", "91:XVTR SEL", "MONI", "Q.SPL", "TCALL", "ATC", "USER"] }, + SF { key: "pg-b", label: "Programmable Key [B]", menu: "Menu 066", at: At::Bits { byte: 56, shift: 0, width: 8 }, kind: Kind::Enum, options: &["MFa:A/B", "MFa:A=B", "MFa:SPL", "MFb:MW", "MFb:SKIP/MCLR", "MFb:TAG", "MFc:STO", "MFc:RCL", "MFc:PROC", "MFd:RPT", "MFd:REV", "MFd:VOX", "MFe:TON/ENC", "MFe:TON/DEC", "MFe:TDCH", "MFf:ARTS", "MFf:SRCH", "MFf:PMS", "MFg:SCN", "MFg:PRI", "MFg:DW", "MFh:SCOP", "MFh:WID", "MFh:STEP", "MFi:MTR", "MFi:SWR", "MFi:DISP", "MFj:SPOT", "MFj:BK", "MFj:KYR", "MFk:TUNE", "MFk:DOWN", "MFk:UP", "MFl:NB", "MFl:AGC", "MFl:AGC SEL", "MFm:IPO", "MFm:ATT", "MFm:NAR", "MFn:CFIL", "MFn:FIL1", "MFn:FIL2", "MFo:PLY1", "MFo:PLY2", "MFo:PLY3", "MFp:DNR", "MFp:DNF", "MFp:DBF", "01:EXT MENU", "02:144MHz ARS", "03:430MHz ARS", "04:AM&FM DIAL", "05:AM MIC GAIN", "06:AM STEP", "07:APO TIME", "08:ARTS BEEP", "09:ARTS ID", "10:ARTS IDW", "11:BEACON TEXT", "12:BEACON TIME", "13:BEEP TONE", "14:BEEP VOL", "15:CAR LSB R", "16:CAR LSB T", "17:CAR USB R", "18:CAR USB T", "19:CAT RATE", "20:CAT/LIN/TUN", "21:CLAR DIAL SEL", "22:CW AUTO MODE", "23:CW BFO", "24:CW DELAY", "25:CW KEY REV", "26:CW PADDLE", "27:CW PITCH", "28:CW QSK", "29:CW SIDE TONE", "30:CW SPEED", "31:CW TRAINING", "32:CW WEIGHT", "33:DCS CODE", "34:DCS INV", "35:DIAL STEP", "36:DIG DISP", "37:DIG GAIN", "38:DIG MODE", "39:DIG SHIFT", "40:DIG VOX", "41:DISP COLOR", "42:DISP CONTRAST", "43:DISP INTENSITY", "44:DISP MODE", "45:DSP BPF WIDTH", "46:DSP HPF CUTOFF", "47:DSP LPF CUTOFF", "48:DSP MIC EQ", "49:DSP NR LEVEL", "50:EMERGENCY", "51:FM MIC GAIN", "52:FM STEP", "53:HOME->VFO", "54:LOCK MODE", "55:MEM GROUP", "56:MEM TAG", "57:MEM/VFO DIAL MODE", "58:MIC SCAN", "59:MIC SEL", "60:MTR ARX", "61:MTR ATX", "62:MTR PEAK HOLD", "63:NB LEVEL", "64:OP FILTER", "71:PKT 1200", "72:PKT 9600", "73:PKT RATE", "74:PROC LEVEL", "75:RF POWER SET", "76:RPT SHIFT", "77:SCAN MODE", "78:SCAN RESUME", "79:SPLIT TONE", "80:SQL/RF GAIN", "81:SSB MIC GAIN", "82:SSB STEP", "83:TONE FREQ", "84:TX TIME", "85:TUNER/ATAS", "86:TX IF FILTER", "87:VOX DELAY", "88:VOX GAIN", "89:XVTR A FREQ", "90:XVTR B FREQ", "91:XVTR SEL", "MONI", "Q.SPL", "TCALL", "ATC", "USER"] }, + SF { key: "pg-c", label: "Programmable Key [C]", menu: "Menu 067", at: At::Bits { byte: 57, shift: 0, width: 8 }, kind: Kind::Enum, options: &["MFa:A/B", "MFa:A=B", "MFa:SPL", "MFb:MW", "MFb:SKIP/MCLR", "MFb:TAG", "MFc:STO", "MFc:RCL", "MFc:PROC", "MFd:RPT", "MFd:REV", "MFd:VOX", "MFe:TON/ENC", "MFe:TON/DEC", "MFe:TDCH", "MFf:ARTS", "MFf:SRCH", "MFf:PMS", "MFg:SCN", "MFg:PRI", "MFg:DW", "MFh:SCOP", "MFh:WID", "MFh:STEP", "MFi:MTR", "MFi:SWR", "MFi:DISP", "MFj:SPOT", "MFj:BK", "MFj:KYR", "MFk:TUNE", "MFk:DOWN", "MFk:UP", "MFl:NB", "MFl:AGC", "MFl:AGC SEL", "MFm:IPO", "MFm:ATT", "MFm:NAR", "MFn:CFIL", "MFn:FIL1", "MFn:FIL2", "MFo:PLY1", "MFo:PLY2", "MFo:PLY3", "MFp:DNR", "MFp:DNF", "MFp:DBF", "01:EXT MENU", "02:144MHz ARS", "03:430MHz ARS", "04:AM&FM DIAL", "05:AM MIC GAIN", "06:AM STEP", "07:APO TIME", "08:ARTS BEEP", "09:ARTS ID", "10:ARTS IDW", "11:BEACON TEXT", "12:BEACON TIME", "13:BEEP TONE", "14:BEEP VOL", "15:CAR LSB R", "16:CAR LSB T", "17:CAR USB R", "18:CAR USB T", "19:CAT RATE", "20:CAT/LIN/TUN", "21:CLAR DIAL SEL", "22:CW AUTO MODE", "23:CW BFO", "24:CW DELAY", "25:CW KEY REV", "26:CW PADDLE", "27:CW PITCH", "28:CW QSK", "29:CW SIDE TONE", "30:CW SPEED", "31:CW TRAINING", "32:CW WEIGHT", "33:DCS CODE", "34:DCS INV", "35:DIAL STEP", "36:DIG DISP", "37:DIG GAIN", "38:DIG MODE", "39:DIG SHIFT", "40:DIG VOX", "41:DISP COLOR", "42:DISP CONTRAST", "43:DISP INTENSITY", "44:DISP MODE", "45:DSP BPF WIDTH", "46:DSP HPF CUTOFF", "47:DSP LPF CUTOFF", "48:DSP MIC EQ", "49:DSP NR LEVEL", "50:EMERGENCY", "51:FM MIC GAIN", "52:FM STEP", "53:HOME->VFO", "54:LOCK MODE", "55:MEM GROUP", "56:MEM TAG", "57:MEM/VFO DIAL MODE", "58:MIC SCAN", "59:MIC SEL", "60:MTR ARX", "61:MTR ATX", "62:MTR PEAK HOLD", "63:NB LEVEL", "64:OP FILTER", "71:PKT 1200", "72:PKT 9600", "73:PKT RATE", "74:PROC LEVEL", "75:RF POWER SET", "76:RPT SHIFT", "77:SCAN MODE", "78:SCAN RESUME", "79:SPLIT TONE", "80:SQL/RF GAIN", "81:SSB MIC GAIN", "82:SSB STEP", "83:TONE FREQ", "84:TX TIME", "85:TUNER/ATAS", "86:TX IF FILTER", "87:VOX DELAY", "88:VOX GAIN", "89:XVTR A FREQ", "90:XVTR B FREQ", "91:XVTR SEL", "MONI", "Q.SPL", "TCALL", "ATC", "USER"] }, + SF { key: "pg-acc", label: "Mic [ACC] Button", menu: "Menu 068", at: At::Bits { byte: 58, shift: 0, width: 8 }, kind: Kind::Enum, options: &["MFa:A/B", "MFa:A=B", "MFa:SPL", "MFb:MW", "MFb:SKIP/MCLR", "MFb:TAG", "MFc:STO", "MFc:RCL", "MFc:PROC", "MFd:RPT", "MFd:REV", "MFd:VOX", "MFe:TON/ENC", "MFe:TON/DEC", "MFe:TDCH", "MFf:ARTS", "MFf:SRCH", "MFf:PMS", "MFg:SCN", "MFg:PRI", "MFg:DW", "MFh:SCOP", "MFh:WID", "MFh:STEP", "MFi:MTR", "MFi:SWR", "MFi:DISP", "MFj:SPOT", "MFj:BK", "MFj:KYR", "MFk:TUNE", "MFk:DOWN", "MFk:UP", "MFl:NB", "MFl:AGC", "MFl:AGC SEL", "MFm:IPO", "MFm:ATT", "MFm:NAR", "MFn:CFIL", "MFn:FIL1", "MFn:FIL2", "MFo:PLY1", "MFo:PLY2", "MFo:PLY3", "MFp:DNR", "MFp:DNF", "MFp:DBF", "01:EXT MENU", "02:144MHz ARS", "03:430MHz ARS", "04:AM&FM DIAL", "05:AM MIC GAIN", "06:AM STEP", "07:APO TIME", "08:ARTS BEEP", "09:ARTS ID", "10:ARTS IDW", "11:BEACON TEXT", "12:BEACON TIME", "13:BEEP TONE", "14:BEEP VOL", "15:CAR LSB R", "16:CAR LSB T", "17:CAR USB R", "18:CAR USB T", "19:CAT RATE", "20:CAT/LIN/TUN", "21:CLAR DIAL SEL", "22:CW AUTO MODE", "23:CW BFO", "24:CW DELAY", "25:CW KEY REV", "26:CW PADDLE", "27:CW PITCH", "28:CW QSK", "29:CW SIDE TONE", "30:CW SPEED", "31:CW TRAINING", "32:CW WEIGHT", "33:DCS CODE", "34:DCS INV", "35:DIAL STEP", "36:DIG DISP", "37:DIG GAIN", "38:DIG MODE", "39:DIG SHIFT", "40:DIG VOX", "41:DISP COLOR", "42:DISP CONTRAST", "43:DISP INTENSITY", "44:DISP MODE", "45:DSP BPF WIDTH", "46:DSP HPF CUTOFF", "47:DSP LPF CUTOFF", "48:DSP MIC EQ", "49:DSP NR LEVEL", "50:EMERGENCY", "51:FM MIC GAIN", "52:FM STEP", "53:HOME->VFO", "54:LOCK MODE", "55:MEM GROUP", "56:MEM TAG", "57:MEM/VFO DIAL MODE", "58:MIC SCAN", "59:MIC SEL", "60:MTR ARX", "61:MTR ATX", "62:MTR PEAK HOLD", "63:NB LEVEL", "64:OP FILTER", "71:PKT 1200", "72:PKT 9600", "73:PKT RATE", "74:PROC LEVEL", "75:RF POWER SET", "76:RPT SHIFT", "77:SCAN MODE", "78:SCAN RESUME", "79:SPLIT TONE", "80:SQL/RF GAIN", "81:SSB MIC GAIN", "82:SSB STEP", "83:TONE FREQ", "84:TX TIME", "85:TUNER/ATAS", "86:TX IF FILTER", "87:VOX DELAY", "88:VOX GAIN", "89:XVTR A FREQ", "90:XVTR B FREQ", "91:XVTR SEL", "MONI", "Q.SPL", "TCALL", "ATC", "USER"] }, + SF { key: "pg-p1", label: "Mic [P1] Button", menu: "Menu 069", at: At::Bits { byte: 59, shift: 0, width: 8 }, kind: Kind::Enum, options: &["MFa:A/B", "MFa:A=B", "MFa:SPL", "MFb:MW", "MFb:SKIP/MCLR", "MFb:TAG", "MFc:STO", "MFc:RCL", "MFc:PROC", "MFd:RPT", "MFd:REV", "MFd:VOX", "MFe:TON/ENC", "MFe:TON/DEC", "MFe:TDCH", "MFf:ARTS", "MFf:SRCH", "MFf:PMS", "MFg:SCN", "MFg:PRI", "MFg:DW", "MFh:SCOP", "MFh:WID", "MFh:STEP", "MFi:MTR", "MFi:SWR", "MFi:DISP", "MFj:SPOT", "MFj:BK", "MFj:KYR", "MFk:TUNE", "MFk:DOWN", "MFk:UP", "MFl:NB", "MFl:AGC", "MFl:AGC SEL", "MFm:IPO", "MFm:ATT", "MFm:NAR", "MFn:CFIL", "MFn:FIL1", "MFn:FIL2", "MFo:PLY1", "MFo:PLY2", "MFo:PLY3", "MFp:DNR", "MFp:DNF", "MFp:DBF", "01:EXT MENU", "02:144MHz ARS", "03:430MHz ARS", "04:AM&FM DIAL", "05:AM MIC GAIN", "06:AM STEP", "07:APO TIME", "08:ARTS BEEP", "09:ARTS ID", "10:ARTS IDW", "11:BEACON TEXT", "12:BEACON TIME", "13:BEEP TONE", "14:BEEP VOL", "15:CAR LSB R", "16:CAR LSB T", "17:CAR USB R", "18:CAR USB T", "19:CAT RATE", "20:CAT/LIN/TUN", "21:CLAR DIAL SEL", "22:CW AUTO MODE", "23:CW BFO", "24:CW DELAY", "25:CW KEY REV", "26:CW PADDLE", "27:CW PITCH", "28:CW QSK", "29:CW SIDE TONE", "30:CW SPEED", "31:CW TRAINING", "32:CW WEIGHT", "33:DCS CODE", "34:DCS INV", "35:DIAL STEP", "36:DIG DISP", "37:DIG GAIN", "38:DIG MODE", "39:DIG SHIFT", "40:DIG VOX", "41:DISP COLOR", "42:DISP CONTRAST", "43:DISP INTENSITY", "44:DISP MODE", "45:DSP BPF WIDTH", "46:DSP HPF CUTOFF", "47:DSP LPF CUTOFF", "48:DSP MIC EQ", "49:DSP NR LEVEL", "50:EMERGENCY", "51:FM MIC GAIN", "52:FM STEP", "53:HOME->VFO", "54:LOCK MODE", "55:MEM GROUP", "56:MEM TAG", "57:MEM/VFO DIAL MODE", "58:MIC SCAN", "59:MIC SEL", "60:MTR ARX", "61:MTR ATX", "62:MTR PEAK HOLD", "63:NB LEVEL", "64:OP FILTER", "71:PKT 1200", "72:PKT 9600", "73:PKT RATE", "74:PROC LEVEL", "75:RF POWER SET", "76:RPT SHIFT", "77:SCAN MODE", "78:SCAN RESUME", "79:SPLIT TONE", "80:SQL/RF GAIN", "81:SSB MIC GAIN", "82:SSB STEP", "83:TONE FREQ", "84:TX TIME", "85:TUNER/ATAS", "86:TX IF FILTER", "87:VOX DELAY", "88:VOX GAIN", "89:XVTR A FREQ", "90:XVTR B FREQ", "91:XVTR SEL", "MONI", "Q.SPL", "TCALL", "ATC", "USER"] }, + SF { key: "pg-p2", label: "Mic [P2] Button", menu: "Menu 070", at: At::Bits { byte: 60, shift: 0, width: 8 }, kind: Kind::Enum, options: &["MFa:A/B", "MFa:A=B", "MFa:SPL", "MFb:MW", "MFb:SKIP/MCLR", "MFb:TAG", "MFc:STO", "MFc:RCL", "MFc:PROC", "MFd:RPT", "MFd:REV", "MFd:VOX", "MFe:TON/ENC", "MFe:TON/DEC", "MFe:TDCH", "MFf:ARTS", "MFf:SRCH", "MFf:PMS", "MFg:SCN", "MFg:PRI", "MFg:DW", "MFh:SCOP", "MFh:WID", "MFh:STEP", "MFi:MTR", "MFi:SWR", "MFi:DISP", "MFj:SPOT", "MFj:BK", "MFj:KYR", "MFk:TUNE", "MFk:DOWN", "MFk:UP", "MFl:NB", "MFl:AGC", "MFl:AGC SEL", "MFm:IPO", "MFm:ATT", "MFm:NAR", "MFn:CFIL", "MFn:FIL1", "MFn:FIL2", "MFo:PLY1", "MFo:PLY2", "MFo:PLY3", "MFp:DNR", "MFp:DNF", "MFp:DBF", "01:EXT MENU", "02:144MHz ARS", "03:430MHz ARS", "04:AM&FM DIAL", "05:AM MIC GAIN", "06:AM STEP", "07:APO TIME", "08:ARTS BEEP", "09:ARTS ID", "10:ARTS IDW", "11:BEACON TEXT", "12:BEACON TIME", "13:BEEP TONE", "14:BEEP VOL", "15:CAR LSB R", "16:CAR LSB T", "17:CAR USB R", "18:CAR USB T", "19:CAT RATE", "20:CAT/LIN/TUN", "21:CLAR DIAL SEL", "22:CW AUTO MODE", "23:CW BFO", "24:CW DELAY", "25:CW KEY REV", "26:CW PADDLE", "27:CW PITCH", "28:CW QSK", "29:CW SIDE TONE", "30:CW SPEED", "31:CW TRAINING", "32:CW WEIGHT", "33:DCS CODE", "34:DCS INV", "35:DIAL STEP", "36:DIG DISP", "37:DIG GAIN", "38:DIG MODE", "39:DIG SHIFT", "40:DIG VOX", "41:DISP COLOR", "42:DISP CONTRAST", "43:DISP INTENSITY", "44:DISP MODE", "45:DSP BPF WIDTH", "46:DSP HPF CUTOFF", "47:DSP LPF CUTOFF", "48:DSP MIC EQ", "49:DSP NR LEVEL", "50:EMERGENCY", "51:FM MIC GAIN", "52:FM STEP", "53:HOME->VFO", "54:LOCK MODE", "55:MEM GROUP", "56:MEM TAG", "57:MEM/VFO DIAL MODE", "58:MIC SCAN", "59:MIC SEL", "60:MTR ARX", "61:MTR ATX", "62:MTR PEAK HOLD", "63:NB LEVEL", "64:OP FILTER", "71:PKT 1200", "72:PKT 9600", "73:PKT RATE", "74:PROC LEVEL", "75:RF POWER SET", "76:RPT SHIFT", "77:SCAN MODE", "78:SCAN RESUME", "79:SPLIT TONE", "80:SQL/RF GAIN", "81:SSB MIC GAIN", "82:SSB STEP", "83:TONE FREQ", "84:TX TIME", "85:TUNER/ATAS", "86:TX IF FILTER", "87:VOX DELAY", "88:VOX GAIN", "89:XVTR A FREQ", "90:XVTR B FREQ", "91:XVTR SEL", "MONI", "Q.SPL", "TCALL", "ATC", "USER"] }, + SF { key: "scan-resume", label: "Scan Resume (s)", menu: "Menu 078", at: At::Bits { byte: 43, shift: 0, width: 4 }, kind: Kind::Int { lo: 1, hi: 10, bias: 0 }, options: &[] }, + SF { key: "dig-gain", label: "DIG Input Gain", menu: "Menu 037", at: At::Bits { byte: 23, shift: 0, width: 7 }, kind: Kind::Int { lo: 0, hi: 100, bias: 0 }, options: &[] }, + SF { key: "dig-mode", label: "DIG Mode", menu: "Menu 038", at: At::Bits { byte: 18, shift: 5, width: 3 }, kind: Kind::Enum, options: &["RTTY-L", "RTTY-U", "PSK31-L", "PSK31-U", "USER-L", "USER-U"] }, + SF { key: "pkt-1200-gain", label: "Packet 1200 Input Gain", menu: "Menu 071", at: At::Bits { byte: 24, shift: 0, width: 7 }, kind: Kind::Int { lo: 0, hi: 100, bias: 0 }, options: &[] }, + SF { key: "pkt-9600-gain", label: "Packet 9600 Input Gain", menu: "Menu 072", at: At::Bits { byte: 25, shift: 0, width: 7 }, kind: Kind::Int { lo: 0, hi: 100, bias: 0 }, options: &[] }, + SF { key: "cat-rate", label: "CAT Baud Rate", menu: "Menu 019", at: At::Bits { byte: 17, shift: 6, width: 2 }, kind: Kind::Enum, options: &["4800", "9600", "38400"] }, + SF { key: "arts-idw", label: "ARTS CW ID (call sign)", menu: "Menu 010", at: At::Text { off: 0x1BF3, len: 10, set: Charset::Callsign }, kind: Kind::Text, options: &[] }, + SF { key: "beacon-text-1", label: "Beacon Text 1", menu: "Menu 011", at: At::Text { off: 0x1BFD, len: 40, set: Charset::Beacon }, kind: Kind::Text, options: &[] }, + SF { key: "beacon-text-2", label: "Beacon Text 2", menu: "Menu 011", at: At::Text { off: 0x1C25, len: 40, set: Charset::Beacon }, kind: Kind::Text, options: &[] }, + SF { key: "beacon-text-3", label: "Beacon Text 3", menu: "Menu 011", at: At::Text { off: 0x1C4D, len: 40, set: Charset::Beacon }, kind: Kind::Text, options: &[] }, +]; diff --git a/src-tauri/src/radios/yaesu_ft857d/hw_ladder.rs b/src-tauri/src/radios/yaesu_ft857d/hw_ladder.rs new file mode 100644 index 0000000..a41e995 --- /dev/null +++ b/src-tauri/src/radios/yaesu_ft857d/hw_ladder.rs @@ -0,0 +1,221 @@ +//! THROWAWAY (#145): the hardware ladder, through the shipping code. Each test +//! is ONE radio operation; the operator does the front-panel step BEFORE a write +//! is started, because nothing here can wait for RCV. +//! +//! ```sh +//! export FT857_PORT=/dev/cu.usbserial-2110 FT857_DIR=../scratchpad/yaesu_ft857d/ladder +//! # read: start it, then press [C] SEND +//! FT857_TAG=before cargo test --lib yaesu_ft857d::hw_ladder::ladder_read -- --ignored --nocapture +//! # write: press [A] RCV first, then start it +//! FT857_IMG=…/x.img cargo test --lib yaesu_ft857d::hw_ladder::ladder_write -- --ignored --nocapture +//! # build a one-name change (pure, no radio) +//! FT857_IMG=…/x.img cargo test --lib yaesu_ft857d::hw_ladder::ladder_make_one_name -- --ignored --nocapture +//! # compare two images +//! FT857_A=… FT857_B=… cargo test --lib yaesu_ft857d::hw_ladder::ladder_compare -- --ignored --nocapture +//! ``` + +use std::path::PathBuf; + +use super::layout::CHANNEL_COUNT; +use super::memory::{decode_channel, is_visible, read_memory, tag_text}; +use super::program::{build_image, decode_sample}; +use super::protocol::{check_image, open_port, read_clone, write_clone, PATIENCE}; +use crate::commands::export::SlotChannel; +use crate::models::RadioModel; + +fn env(k: &str) -> String { + std::env::var(k).unwrap_or_else(|_| panic!("set {k}")) +} + +fn dir() -> PathBuf { + let d = PathBuf::from(std::env::var("FT857_DIR").unwrap_or_else(|_| ".".into())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn stamp() -> String { + chrono::Local::now().format("%Y%m%d-%H%M%S").to_string() +} + +fn show(image: &[u8]) { + for c in decode_sample(image) { + println!( + " {:>3} {:<8} {:>11.5} {:<4} {:<8} {}", + c.index, + c.name, + c.rx_mhz, + c.mode.unwrap_or_default(), + c.shift.unwrap_or_default(), + c.tone + ); + } +} + +#[test] +#[ignore] +fn ladder_read() { + let mut p = open_port(&env("FT857_PORT")).unwrap(); + println!("listening — press [C] SEND on the radio now"); + let image = read_clone(&mut *p, PATIENCE, &mut |d, t| { + if d % 10 == 0 || d == t { + println!(" block {d}/{t}"); + } + }) + .unwrap(); + let path = dir().join(format!("ft897-ladder-{}-{}.img", env("FT857_TAG"), stamp())); + std::fs::write(&path, &image).unwrap(); + println!("read {} bytes -> {}", image.len(), path.display()); + show(&image); +} + +#[test] +#[ignore] +fn ladder_write() { + let image = std::fs::read(env("FT857_IMG")).unwrap(); + check_image(&image).unwrap(); + let mut p = open_port(&env("FT857_PORT")).unwrap(); + println!("sending {} bytes — the radio must already be in RCV", image.len()); + write_clone(&mut *p, &image, PATIENCE, &mut |d, t| { + if d % 10 == 0 || d == t { + println!(" block {d}/{t} acknowledged"); + } + }) + .unwrap(); + println!("every block acknowledged — an ACK is not a commit: read it back"); +} + +/// Ladder step 2's file: the radio's own memories through the channel model, +/// with memory 1 renamed. No radio involved. +#[test] +#[ignore] +fn ladder_make_one_name() { + let src = env("FT857_IMG"); + let base = std::fs::read(&src).unwrap(); + let new_name = std::env::var("FT857_NAME").unwrap_or_else(|_| "LADDER2".into()); + let slots: Vec = (0..CHANNEL_COUNT) + .filter(|&s| is_visible(&base, s)) + .map(|s| { + let m = read_memory(&base, s); + let name = if s == 0 { new_name.clone() } else { tag_text(&m) }; + SlotChannel { slot: s, name, channel: decode_channel(&m) } + }) + .collect(); + let model = RadioModel { memory_channels: Some(200), ..Default::default() }; + let out = build_image(&model, &slots, &base).unwrap(); + let diff: Vec = (0..base.len()).filter(|&i| base[i] != out[i]).collect(); + for &i in &diff { + println!(" 0x{i:04X}: {:02X} -> {:02X}", base[i], out[i]); + } + assert!(!diff.is_empty() && diff.len() <= 8, "expected only memory 1's tag to change"); + let path = PathBuf::from(&src).with_file_name(format!("ft897-ladder-onename-{}.img", stamp())); + std::fs::write(&path, &out).unwrap(); + println!("{} bytes differ; wrote {}", diff.len(), path.display()); +} + +#[test] +#[ignore] +fn ladder_compare() { + let a = std::fs::read(env("FT857_A")).unwrap(); + let b = std::fs::read(env("FT857_B")).unwrap(); + assert_eq!(a.len(), b.len(), "lengths differ"); + let diff: Vec = (0..a.len()).filter(|&i| a[i] != b[i]).collect(); + for &i in diff.iter().take(40) { + println!(" 0x{i:04X}: {:02X} -> {:02X}", a[i], b[i]); + } + println!("{} bytes differ", diff.len()); +} + +/// Ladder step 3's file: a codeplug that exercises every encoding the radio's +/// own records have not shown — shift, tone, TSQL, DCS, narrow, USB, CW, AM. +/// Built from FT857_IMG through the shipping `build_image`. No radio involved. +#[test] +#[ignore] +fn ladder_make_codeplug() { + use crate::models::Channel; + let src = env("FT857_IMG"); + let base = std::fs::read(&src).unwrap(); + let ch = |rx: f64, tx: f64, mode: &str| Channel { + rx_freq: rx, + tx_freq: Some(tx), + mode: Some(mode.into()), + dcs_polarity: "NN".into(), + cross_mode: "Tone->Tone".into(), + ..Default::default() + }; + let mut c = Vec::new(); + let mut t = ch(146.94, 146.34, "FM"); + t.tone_mode = Some("Tone".into()); + t.ctcss_uplink = Some(100.0); + c.push(("RPT T100", t)); + let mut t = ch(442.1, 447.1, "FM"); + t.tone_mode = Some("TSQL".into()); + t.ctcss_uplink = Some(131.8); + t.ctcss_downlink = Some(131.8); + c.push(("TSQL1318", t)); + let mut t = ch(147.0, 147.0, "FM"); + t.tone_mode = Some("DTCS".into()); + t.dcs_code = Some("023".into()); + c.push(("DCS 023", t)); + c.push(("NFM CALL", ch(146.52, 146.52, "NFM"))); + c.push(("FT8 20M", ch(14.074, 14.074, "USB"))); + c.push(("LSB 40M", ch(7.185, 7.185, "LSB"))); + c.push(("CW 40M", ch(7.03, 7.03, "CW"))); + c.push(("AIR 1215", ch(121.5, 121.5, "AM"))); + let slots: Vec = c + .into_iter() + .enumerate() + .map(|(i, (n, ch))| SlotChannel { slot: i, name: n.into(), channel: ch }) + .collect(); + let model = RadioModel { memory_channels: Some(200), ..Default::default() }; + let out = build_image(&model, &slots, &base).unwrap(); + show(&out); + let path = PathBuf::from(&src).with_file_name(format!("ft897-ladder-codeplug-{}.img", stamp())); + std::fs::write(&path, &out).unwrap(); + println!("wrote {}", path.display()); +} + +/// Ladder step 4's file: one memory at each edge of the receive and transmit +/// coverage the seed row claims (manual spec). Built through `build_image`. +#[test] +#[ignore] +fn ladder_make_bandprobe() { + use crate::models::Channel; + let src = env("FT857_IMG"); + let base = std::fs::read(&src).unwrap(); + let probes: [(&str, f64, &str); 12] = [ + ("RX 0.100", 0.1, "AM"), + ("TX 1.800", 1.8, "LSB"), + ("TX 29.69", 29.69, "USB"), + ("TX 50.00", 50.0, "USB"), + ("RX 55.99", 55.995, "FM"), + ("RX 76.00", 76.0, "FM"), + ("RX 107.9", 107.9, "FM"), + ("RX 118.0", 118.0, "AM"), + ("RX 163.9", 163.995, "FM"), + ("RX 420.0", 420.0, "FM"), + ("TX 449.9", 449.995, "FM"), + ("RX 469.9", 469.995, "FM"), + ]; + let slots: Vec = probes + .iter() + .enumerate() + .map(|(i, &(n, f, m))| SlotChannel { + slot: i, + name: n.into(), + channel: Channel { + rx_freq: f, + tx_freq: Some(f), + mode: Some(m.into()), + dcs_polarity: "NN".into(), + cross_mode: "Tone->Tone".into(), + ..Default::default() + }, + }) + .collect(); + let model = RadioModel { memory_channels: Some(200), ..Default::default() }; + let out = build_image(&model, &slots, &base).unwrap(); + show(&out); + let path = PathBuf::from(&src).with_file_name(format!("ft897-ladder-bandprobe-{}.img", stamp())); + std::fs::write(&path, &out).unwrap(); + println!("wrote {}", path.display()); +} diff --git a/src-tauri/src/radios/yaesu_ft857d/layout.rs b/src-tauri/src/radios/yaesu_ft857d/layout.rs new file mode 100644 index 0000000..dccb796 --- /dev/null +++ b/src-tauri/src/radios/yaesu_ft857d/layout.rs @@ -0,0 +1,98 @@ +//! FT-857D / FT-897 clone-image layout. Constants only. +//! +//! Every address here is CHIRP's (`ft857.py`, which drives both radios from one +//! class) and has been checked against three clone images read off a real +//! **FT-897** (US) on 2026-10-17 — not an FT-857D, whose CAT port is dead (see +//! `scratchpad/yaesu_ft857d/FINDINGS.md`). What that check proved, and what it +//! did not: +//! +//! - the block table and image lengths: all 53 blocks of three reads, every +//! checksum good; +//! - the memory table, its 28-byte stride and both bitmaps: 17 memories decode +//! to a coherent band-plan set and the bitmaps mark exactly those 17; +//! - the 60 m block's position (US image only): five channels at 5.332-5.405 MHz. +//! +//! The FT-857D is the same layout **by CHIRP's say-so** until an image comes off +//! one. + +/// A non-US image: everything up to the 60 m channels. +pub(crate) const IMAGE_LEN_EXPORT: usize = 7341; +/// A US image: the radio sends one more 140-byte block, the five 60 m channels. +pub(crate) const IMAGE_LEN_US: usize = 7481; + +/// The clone's block lengths in the order the radio sends them, for an export +/// radio. A US radio appends [`US_EXTRA_BLOCK`]. The repeated 140 is the memory +/// area, which CHIRP describes as "block 8 × 40" — 42 blocks really, the last +/// two belonging to the same run. +pub(crate) fn block_lengths(us: bool) -> Vec { + let mut v = vec![2, 82, 252, 196, 252, 196, 212, 55]; + v.extend(std::iter::repeat_n(140, 42)); + v.extend([38, 176]); + if us { + v.push(US_EXTRA_BLOCK); + } + v +} + +/// The 60 m block a US radio sends last (block 0x34). +pub(crate) const US_EXTRA_BLOCK: usize = 140; + +/// One memory record. Same stride for regular memories, PMS, VFOs, HOME, QMB +/// and the 60 m channels. +pub(crate) const ENTRY_LEN: usize = 28; +/// Bytes of tag (name) at the end of a record. +pub(crate) const NAME_LEN: usize = 8; +/// Offset of the name within a record. +pub(crate) const NAME_OFFSET: usize = 20; + +/// Regular memories 1-200. +pub(crate) const CHANNEL_COUNT: usize = 200; +pub(crate) const MEMORY_BASE: usize = 0x04DF; + +/// One bit per regular memory, LSB first: bit `(n-1) % 8` of byte `(n-1) / 8`. +/// "Visible" is what the radio shows; "filled" is whether the record holds +/// data. An erased memory keeps its record and its filled bit and loses only +/// visible — CHIRP's `_set_normal` relies on that, and on real images the two +/// agree for every memory in use. +pub(crate) const VISIBLE_BASE: usize = 0x04A9; +pub(crate) const FILLED_BASE: usize = 0x04C4; +pub(crate) const BITMAP_LEN: usize = 25; + +/// The ten PMS edges (P-1L/U … P-5L/U) follow the 200 memories. Their bitmaps are +/// a little-endian u16 after each regular bitmap. Not programmed by the app. +pub(crate) const PMS_COUNT: usize = 10; +pub(crate) const PMS_BASE: usize = MEMORY_BASE + CHANNEL_COUNT * ENTRY_LEN; + +/// The five fixed US 60 m channels. Present only in a [`IMAGE_LEN_US`] image, +/// and never written by the app — CHIRP makes them immutable. +pub(crate) const SIXTY_M_BASE: usize = 0x1CAD; +pub(crate) const SIXTY_M_COUNT: usize = 5; + +/// Offset of regular memory `slot` (0-based). +pub(crate) fn memory_offset(slot: usize) -> usize { + MEMORY_BASE + slot * ENTRY_LEN +} + +// The layout's own arithmetic, checked at compile time: no region overlaps +// the next, and the 60 m block is exactly the tail a US image adds. +const _: () = { + assert!(VISIBLE_BASE + BITMAP_LEN + 2 <= FILLED_BASE); + assert!(FILLED_BASE + BITMAP_LEN + 2 <= MEMORY_BASE); + assert!(PMS_BASE + PMS_COUNT * ENTRY_LEN <= SIXTY_M_BASE); + assert!(SIXTY_M_BASE == IMAGE_LEN_EXPORT); + assert!(SIXTY_M_BASE + SIXTY_M_COUNT * ENTRY_LEN == IMAGE_LEN_US); +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn block_tables_sum_to_the_two_image_lengths() { + assert_eq!(block_lengths(false).iter().sum::(), IMAGE_LEN_EXPORT); + assert_eq!(block_lengths(true).iter().sum::(), IMAGE_LEN_US); + // 53 blocks on the US radio — the count the FT-897 actually sent. + assert_eq!(block_lengths(true).len(), 53); + } + +} diff --git a/src-tauri/src/radios/yaesu_ft857d/memory.rs b/src-tauri/src/radios/yaesu_ft857d/memory.rs new file mode 100644 index 0000000..9e986a8 --- /dev/null +++ b/src-tauri/src/radios/yaesu_ft857d/memory.rs @@ -0,0 +1,669 @@ +//! FT-857D / FT-897 memories: the 28-byte record, the two bitmaps, and the +//! channel encoder. +//! +//! The record is CHIRP's `mem_struct` from `ft857.py`. Bit fields are listed +//! high bit first, as CHIRP's bitwise syntax reads them: +//! +//! ```text +//! byte 0 tag_on_off:1 tag_default:1 unknown:3 mode:3 +//! byte 1 duplex:2 is_duplex:1 is_cwdig_narrow:1 is_fm_narrow:1 freq_range:3 +//! byte 2 skip:1 unknown:1 ipo:1 att:1 unknown:4 +//! byte 3 ssb_step:2 am_step:3 fm_step:3 +//! byte 4 unknown:3 is_split_tone:1 tmode:4 +//! byte 5 unknown:2 tx_mode:3 tx_freq_range:3 +//! byte 6 unknown:1 toneflag:1 tone:6 (index into TONES_DHZ) +//! byte 7 unknown:1 rxtoneflag:1 rxtone:6 +//! byte 8 unknown:1 dcs:7 (index into DTCS_CODES) +//! byte 9 unknown:1 rxdcs:7 +//! byte 10..12 rit, u16 little-endian +//! byte 12..16 freq, u32 BIG-endian, units of 10 Hz +//! byte 16..20 offset, u32 big-endian, 10 Hz — the shift, or the TX frequency +//! when duplex is split +//! byte 20..28 tag (name), ASCII, space padded +//! ``` +//! +//! ## What the FT-897 images settled, and what they could not +//! +//! Seventeen real memories — all HF/6 m CW and SSB — decode coherently against +//! this layout: frequency, mode, `freq_range` (0 on HF, 1 on 6 m), name. **None +//! is FM**, so the tone, DCS, shift and cross-tone encodings below are CHIRP's and +//! have not been seen in a radio-written record. They are confirmed only once +//! ladder step 3 programs an FM repeater and the radio's own screen shows it. +//! +//! The same images also showed two places where the radio and CHIRP's *writer* +//! disagree, and the encoder is built around both: +//! +//! - `is_duplex` is **set on every simplex memory** the radio wrote; CHIRP's +//! writer clears it for simplex. The encoder leaves the bit alone on simplex +//! (the radio's value survives) and sets it for +/-/split, as CHIRP does. +//! - The step byte is `0x08` on CW memories and `0x88` on SSB ones. Channels +//! carry no tuning step, so the encoder never recomputes it. +//! +//! The rule that follows from both: [`encode_channel`] starts from the record +//! already in the slot and changes only what the channel says. A slot that never +//! held a record starts from zero, which is what CHIRP writes into one — the +//! behaviour that has programmed these radios for a decade. + +use super::layout::{ + memory_offset, ENTRY_LEN, FILLED_BASE, NAME_LEN, NAME_OFFSET, + VISIBLE_BASE, +}; +use crate::commands::export; +use crate::models::Channel; + +/// CTCSS tones in tenths of a hertz — CHIRP's `chirp_common.TONES`, the 50 +/// standard tones the manual's menu 083 lists. Index order unverified on the +/// radio (no toned memory in the images). +pub(crate) const TONES_DHZ: [u16; 50] = [ + 670, 693, 719, 744, 770, 797, 825, 854, 885, 915, 948, 974, 1000, 1035, 1072, 1109, 1148, + 1188, 1230, 1273, 1318, 1365, 1413, 1462, 1514, 1567, 1598, 1622, 1655, 1679, 1713, 1738, + 1773, 1799, 1835, 1862, 1899, 1928, 1966, 1995, 2035, 2065, 2107, 2181, 2257, 2291, 2336, + 2418, 2503, 2541, +]; + +/// DCS codes, written the way the radio displays them (octal digits) — CHIRP's +/// `chirp_common.DTCS_CODES`, the 104 the manual's menu 033 lists. +pub(crate) const DTCS_CODES: [u16; 104] = [ + 23, 25, 26, 31, 32, 36, 43, 47, 51, 53, 54, 65, 71, 72, 73, 74, 114, 115, 116, 122, 125, 131, + 132, 134, 143, 145, 152, 155, 156, 162, 165, 172, 174, 205, 212, 223, 225, 226, 243, 244, 245, + 246, 251, 252, 255, 261, 263, 265, 266, 271, 274, 306, 311, 315, 325, 331, 332, 343, 346, 351, + 356, 364, 365, 371, 411, 412, 413, 423, 431, 432, 445, 446, 452, 454, 455, 462, 464, 465, 466, + 503, 506, 516, 523, 526, 532, 546, 565, 606, 612, 624, 627, 631, 632, 654, 662, 664, 703, 712, + 723, 731, 732, 734, 743, 754, +]; + +// `mode` — CHIRP's MODES order. Narrow is a separate bit, not a mode. +pub(crate) const MODE_LSB: u8 = 0; +pub(crate) const MODE_USB: u8 = 1; +pub(crate) const MODE_CW: u8 = 2; +pub(crate) const MODE_CWR: u8 = 3; +pub(crate) const MODE_AM: u8 = 4; +pub(crate) const MODE_FM: u8 = 5; +pub(crate) const MODE_DIG: u8 = 6; +pub(crate) const MODE_PKT: u8 = 7; + +pub(crate) const DUPLEX_NONE: u8 = 0; +pub(crate) const DUPLEX_MINUS: u8 = 1; +pub(crate) const DUPLEX_PLUS: u8 = 2; +pub(crate) const DUPLEX_SPLIT: u8 = 3; + +// `tmode` with `is_split_tone` clear — CHIRP's FT857 TMODES. Unverified. +pub(crate) const TMODE_OFF: u8 = 0x00; +pub(crate) const TMODE_TONE: u8 = 0x04; +pub(crate) const TMODE_TSQL: u8 = 0x05; +pub(crate) const TMODE_DTCS: u8 = 0x0A; + +/// `tmode` with `is_split_tone` set — CHIRP's CROSS_MODES, in the app's +/// `cross_mode` vocabulary. Unverified. +const CROSS_MODES: [(&str, u8); 8] = [ + ("->Tone", 0x01), + ("->DTCS", 0x02), + ("Tone->", 0x04), + ("Tone->Tone", 0x05), + ("Tone->DTCS", 0x06), + ("DTCS->", 0x08), + ("DTCS->Tone", 0x09), + ("DTCS->DTCS", 0x0A), +]; + +/// The radio's `freq_range` code is an index into this list (Hz, `lo..hi`). +/// CHIRP's `VALID_BANDS`, and the order is load-bearing — the images show 0 on +/// HF and 1 on 6 m, matching it. A frequency in none of them is outside the +/// receiver and cannot be stored. +pub(crate) const FREQ_RANGES: [(u32, u32); 6] = [ + (100_000, 33_000_000), + (33_000_000, 56_000_000), + (76_000_000, 108_000_000), + (108_000_000, 137_000_000), + (137_000_000, 164_000_000), + (420_000_000, 470_000_000), +]; + +/// The radio's `freq_range` code for `hz`, or `None` outside every range. +pub(crate) fn freq_range(hz: u32) -> Option { + FREQ_RANGES.iter().position(|&(lo, hi)| hz >= lo && hz < hi).map(|i| i as u8) +} + +/// One record, fully decoded. Every bit is carried, named or not, so +/// [`encode_memory`] is byte-identical by construction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Ft857Memory { + pub tag_on_off: bool, + pub tag_default: bool, + pub unknown0: u8, + pub mode: u8, + pub duplex: u8, + pub is_duplex: bool, + pub is_cwdig_narrow: bool, + pub is_fm_narrow: bool, + pub freq_range: u8, + /// Byte 2 whole: skip, ipo, att and five unknown bits. The app has no + /// channel field for any of them, so they are only ever preserved. + pub flags2: u8, + /// Byte 3 whole: the three per-mode tuning steps. Preserved, never computed. + pub steps: u8, + pub unknown4: u8, + pub is_split_tone: bool, + pub tmode: u8, + pub unknown5: u8, + pub tx_mode: u8, + pub tx_freq_range: u8, + /// Bytes 6-9 whole, flag bits and all. + pub tone: u8, + pub rxtone: u8, + pub dcs: u8, + pub rxdcs: u8, + pub rit: u16, + pub freq_10hz: u32, + pub offset_10hz: u32, + pub name: [u8; NAME_LEN], +} + +/// The low six bits of a tone byte are the index; bit 6 is CHIRP's +/// `unknown_toneflag`, which it forces to 0 because a set flag garbles menu 083. +const TONE_INDEX: u8 = 0x3F; +const DCS_INDEX: u8 = 0x7F; + +/// Decode a 28-byte record. Total: never fails, never normalises. +pub(crate) fn decode_memory(m: &[u8; ENTRY_LEN]) -> Ft857Memory { + let mut name = [0u8; NAME_LEN]; + name.copy_from_slice(&m[NAME_OFFSET..NAME_OFFSET + NAME_LEN]); + Ft857Memory { + tag_on_off: m[0] & 0x80 != 0, + tag_default: m[0] & 0x40 != 0, + unknown0: (m[0] >> 3) & 0x07, + mode: m[0] & 0x07, + duplex: m[1] >> 6, + is_duplex: m[1] & 0x20 != 0, + is_cwdig_narrow: m[1] & 0x10 != 0, + is_fm_narrow: m[1] & 0x08 != 0, + freq_range: m[1] & 0x07, + flags2: m[2], + steps: m[3], + unknown4: m[4] >> 5, + is_split_tone: m[4] & 0x10 != 0, + tmode: m[4] & 0x0F, + unknown5: m[5] >> 6, + tx_mode: (m[5] >> 3) & 0x07, + tx_freq_range: m[5] & 0x07, + tone: m[6], + rxtone: m[7], + dcs: m[8], + rxdcs: m[9], + rit: u16::from_le_bytes([m[10], m[11]]), + freq_10hz: u32::from_be_bytes([m[12], m[13], m[14], m[15]]), + offset_10hz: u32::from_be_bytes([m[16], m[17], m[18], m[19]]), + name, + } +} + +/// Re-emit a decoded record. Byte-identical for anything [`decode_memory`] +/// produced. +pub(crate) fn encode_memory(d: &Ft857Memory) -> [u8; ENTRY_LEN] { + let mut m = [0u8; ENTRY_LEN]; + m[0] = (d.tag_on_off as u8) << 7 + | (d.tag_default as u8) << 6 + | (d.unknown0 & 0x07) << 3 + | (d.mode & 0x07); + m[1] = (d.duplex & 0x03) << 6 + | (d.is_duplex as u8) << 5 + | (d.is_cwdig_narrow as u8) << 4 + | (d.is_fm_narrow as u8) << 3 + | (d.freq_range & 0x07); + m[2] = d.flags2; + m[3] = d.steps; + m[4] = (d.unknown4 & 0x07) << 5 | (d.is_split_tone as u8) << 4 | (d.tmode & 0x0F); + m[5] = (d.unknown5 & 0x03) << 6 | (d.tx_mode & 0x07) << 3 | (d.tx_freq_range & 0x07); + m[6] = d.tone; + m[7] = d.rxtone; + m[8] = d.dcs; + m[9] = d.rxdcs; + m[10..12].copy_from_slice(&d.rit.to_le_bytes()); + m[12..16].copy_from_slice(&d.freq_10hz.to_be_bytes()); + m[16..20].copy_from_slice(&d.offset_10hz.to_be_bytes()); + m[NAME_OFFSET..NAME_OFFSET + NAME_LEN].copy_from_slice(&d.name); + m +} + +/// The record CHIRP writes into a slot that has never held one: all zero. +pub(crate) fn blank_memory() -> Ft857Memory { + decode_memory(&[0u8; ENTRY_LEN]) +} + +// ============================================================ +// Bitmaps +// ============================================================ + +fn bit(slot: usize) -> (usize, u8) { + (slot / 8, 1u8 << (slot % 8)) +} + +/// Whether regular memory `slot` (0-based) is shown by the radio. +pub(crate) fn is_visible(image: &[u8], slot: usize) -> bool { + let (byte, mask) = bit(slot); + image[VISIBLE_BASE + byte] & mask != 0 +} + +/// Whether regular memory `slot` (0-based) holds a record. +pub(crate) fn is_filled(image: &[u8], slot: usize) -> bool { + let (byte, mask) = bit(slot); + image[FILLED_BASE + byte] & mask != 0 +} + +/// Read regular memory `slot` (0-based), whatever its bitmaps say. +pub(crate) fn read_memory(image: &[u8], slot: usize) -> Ft857Memory { + let at = memory_offset(slot); + let mut raw = [0u8; ENTRY_LEN]; + raw.copy_from_slice(&image[at..at + ENTRY_LEN]); + decode_memory(&raw) +} + +/// The record already in `slot`, if the radio considers it one. A slot that was +/// never filled holds `FF` filler, which must not be used as a base. +pub(crate) fn existing_memory(image: &[u8], slot: usize) -> Option { + is_filled(image, slot).then(|| read_memory(image, slot)) +} + +/// Write `m` into `slot` and mark it visible and filled. +pub(crate) fn store_memory(image: &mut [u8], slot: usize, m: &Ft857Memory) { + let at = memory_offset(slot); + image[at..at + ENTRY_LEN].copy_from_slice(&encode_memory(m)); + let (byte, mask) = bit(slot); + image[VISIBLE_BASE + byte] |= mask; + image[FILLED_BASE + byte] |= mask; +} + +/// Hide `slot`, the way the radio erases a memory: the record and its filled +/// bit stay, only visible goes. CHIRP refuses to erase memory 1 ("if you upload +/// an empty image you can brick your radio"), and so does this. +pub(crate) fn hide_memory(image: &mut [u8], slot: usize) -> Result<(), String> { + if slot == 0 { + return Err("memory 1 cannot be left empty on this radio".to_string()); + } + let (byte, mask) = bit(slot); + image[VISIBLE_BASE + byte] &= !mask; + Ok(()) +} + +/// The slots (0-based) the radio shows. +#[cfg(test)] +pub(crate) fn visible_slots(image: &[u8]) -> Vec { + (0..super::layout::CHANNEL_COUNT).filter(|&s| is_visible(image, s)).collect() +} + +// ============================================================ +// Channel <-> record +// ============================================================ + +fn mhz_to_10hz(mhz: f64) -> u32 { + (mhz * 100_000.0).round() as u32 +} + +fn tone_index(hz: f64) -> Option { + let dhz = (hz * 10.0).round() as u16; + TONES_DHZ.iter().position(|&t| t == dhz).map(|i| i as u8) +} + +fn dtcs_index(code: &str) -> Option { + let n: u16 = code.trim().parse().ok()?; + DTCS_CODES.iter().position(|&c| c == n).map(|i| i as u8) +} + +/// Encode a tag: printable ASCII, space padded. CHIRP pads with spaces because +/// some FT-857Ds misbehave on `FF`-padded short tags (CHIRP bug #937), and every +/// tag the FT-897 wrote is space padded. `\ { | }` are not in the radio's set. +pub(crate) fn name_bytes(name: &str) -> [u8; NAME_LEN] { + let mut out = [b' '; NAME_LEN]; + let ok = |c: &char| c.is_ascii_graphic() || *c == ' '; + for (i, ch) in name + .chars() + .filter(ok) + .filter(|c| !"\\{|}".contains(*c)) + .take(NAME_LEN) + .enumerate() + { + out[i] = ch as u8; + } + out +} + +/// The record's tag as text, or empty when the tag is switched off. +pub(crate) fn tag_text(m: &Ft857Memory) -> String { + if !m.tag_on_off { + return String::new(); + } + m.name + .iter() + .take_while(|&&b| b != 0xFF) + .map(|&b| if (0x20..0x7F).contains(&b) { b as char } else { '*' }) + .collect::() + .trim_end() + .to_string() +} + +/// The app's mode string for a record. CWR, DIG and PKT have no channel-library +/// equivalent; they are named for display and refused by [`encode_channel`]. +pub(crate) fn mode_text(m: &Ft857Memory) -> &'static str { + match m.mode { + MODE_LSB => "LSB", + MODE_USB => "USB", + MODE_CW => "CW", + MODE_CWR => "CWR", + MODE_AM => "AM", + MODE_FM if m.is_fm_narrow => "NFM", + MODE_FM => "FM", + MODE_DIG => "DIG", + MODE_PKT => "PKT", + _ => "?", + } +} + +/// Build the record for `c` in a slot that holds `base` (or nothing). +/// +/// Starts from `base` and changes only what the channel determines, so a +/// channel written over the memory it came from leaves every byte alone — the +/// property [`super::real_images`] measures. Refuses rather than guesses: a +/// frequency outside the receiver, a mode the radio has no slot for, a tone not +/// in its table. +/// +/// Per-channel DCS polarity does not exist on this radio — it is global, menu +/// 034 DCS INV — so `dcs_polarity` is not encoded. +pub(crate) fn encode_channel( + c: &Channel, + name: &str, + base: Option<&Ft857Memory>, +) -> Result { + let mut m = base.cloned().unwrap_or_else(blank_memory); + + let rx = mhz_to_10hz(c.rx_freq); + m.freq_10hz = rx; + m.freq_range = freq_range(rx * 10).ok_or_else(|| { + format!("{:.5} MHz is outside the radio's receive coverage", c.rx_freq) + })?; + + let (mode, narrow) = match c.mode.as_deref().unwrap_or("FM").to_ascii_uppercase().as_str() { + "FM" => (MODE_FM, false), + "NFM" => (MODE_FM, true), + "AM" => (MODE_AM, false), + "USB" => (MODE_USB, false), + "LSB" => (MODE_LSB, false), + "CW" => (MODE_CW, false), + other => return Err(format!("mode {other} has no memory mode on this radio")), + }; + m.mode = mode; + // CHIRP sets both narrow bits together; for a non-narrow mode both clear. + m.is_fm_narrow = narrow; + m.is_cwdig_narrow = narrow; + + let tx = mhz_to_10hz(export::tx_frequency(c)); + if c.duplex.as_deref() == Some("split") { + m.duplex = DUPLEX_SPLIT; + m.is_duplex = true; + m.offset_10hz = tx; + m.tx_mode = mode; + m.tx_freq_range = freq_range(tx * 10).ok_or_else(|| { + format!("split TX {:.5} MHz is outside the radio's coverage", tx as f64 / 100_000.0) + })?; + } else if tx != rx { + m.duplex = if tx > rx { DUPLEX_PLUS } else { DUPLEX_MINUS }; + m.is_duplex = true; + m.offset_10hz = tx.abs_diff(rx); + } else { + // Simplex. `is_duplex` and the offset field keep whatever the slot held: + // the radio itself leaves both set on simplex memories (the offset is the + // band's default shift), and CHIRP's writer clearing them is not evidence + // the radio needs that. + m.duplex = DUPLEX_NONE; + } + + encode_tone(c, &mut m)?; + + m.tag_on_off = !name.trim().is_empty(); + // Never the radio's default "CH-nnn" label — CHIRP's rule. + m.tag_default = false; + m.name = name_bytes(name); + // The clarifier offset is operating state, not codeplug; CHIRP zeroes it. + m.rit = 0; + Ok(m) +} + +fn encode_tone(c: &Channel, m: &mut Ft857Memory) -> Result<(), String> { + let want = c.tone_mode.as_deref().unwrap_or("off"); + let tone = |hz: Option| -> Result { + let hz = hz.ok_or("a CTCSS tone mode with no tone")?; + tone_index(hz).ok_or_else(|| format!("{hz:.1} Hz is not one of the radio's 50 CTCSS tones")) + }; + let dcs = |code: Option<&str>| -> Result { + let code = code.ok_or("a DCS tone mode with no code")?; + dtcs_index(code).ok_or_else(|| format!("DCS {code} is not one of the radio's 104 codes")) + }; + // Keep the flag bits (CHIRP forces them to 0 only when it writes a value). + let set_tone = |byte: &mut u8, idx: u8| *byte = idx & TONE_INDEX; + let set_dcs = |byte: &mut u8, idx: u8| *byte = (*byte & !DCS_INDEX) | (idx & DCS_INDEX); + + if want.eq_ignore_ascii_case("Tone") { + let t = tone(c.ctcss_uplink.or(c.ctcss_downlink))?; + m.is_split_tone = false; + m.tmode = TMODE_TONE; + set_tone(&mut m.tone, t); + set_tone(&mut m.rxtone, t); + } else if want.eq_ignore_ascii_case("TSQL") { + let t = tone(c.ctcss_downlink.or(c.ctcss_uplink))?; + m.is_split_tone = false; + m.tmode = TMODE_TSQL; + set_tone(&mut m.tone, t); + set_tone(&mut m.rxtone, t); + } else if want.eq_ignore_ascii_case("DTCS") { + let d = dcs(c.dcs_code.as_deref())?; + m.is_split_tone = false; + m.tmode = TMODE_DTCS; + set_dcs(&mut m.dcs, d); + set_dcs(&mut m.rxdcs, d); + } else if want.eq_ignore_ascii_case("Cross") { + let code = CROSS_MODES + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case(&c.cross_mode)) + .map(|&(_, v)| v) + .ok_or_else(|| format!("cross mode {} is not one this radio has", c.cross_mode))?; + let (tx_kind, rx_kind) = c.cross_mode.split_once("->").unwrap_or(("", "")); + if tx_kind.eq_ignore_ascii_case("Tone") { + set_tone(&mut m.tone, tone(c.ctcss_uplink)?); + } else if tx_kind.eq_ignore_ascii_case("DTCS") { + set_dcs(&mut m.dcs, dcs(c.dcs_code.as_deref())?); + } + if rx_kind.eq_ignore_ascii_case("Tone") { + set_tone(&mut m.rxtone, tone(c.ctcss_downlink)?); + } else if rx_kind.eq_ignore_ascii_case("DTCS") { + set_dcs(&mut m.rxdcs, dcs(c.dcs_rx_code.as_deref().or(c.dcs_code.as_deref()))?); + } + m.is_split_tone = true; + m.tmode = code; + } else { + // Off. The tone values stay as they were — the radio keeps them too. + m.is_split_tone = false; + m.tmode = TMODE_OFF; + } + Ok(()) +} + +/// The channel a record describes, in the app's vocabulary — the inverse of +/// [`encode_channel`] for everything the channel model can say. +pub(crate) fn decode_channel(m: &Ft857Memory) -> Channel { + let rx = m.freq_10hz as f64 / 100_000.0; + let mut c = Channel { + rx_freq: rx, + mode: Some(mode_text(m).to_string()), + dcs_polarity: "NN".to_string(), + cross_mode: "Tone->Tone".to_string(), + ..Default::default() + }; + let off = m.offset_10hz as f64 / 100_000.0; + match m.duplex { + DUPLEX_PLUS => { + c.duplex = Some("+".into()); + c.offset = Some(off); + c.tx_freq = Some(rx + off); + } + DUPLEX_MINUS => { + c.duplex = Some("-".into()); + c.offset = Some(off); + c.tx_freq = Some(rx - off); + } + DUPLEX_SPLIT => { + c.duplex = Some("split".into()); + c.tx_freq = Some(off); + } + _ => {} + } + let tone_hz = |b: u8| TONES_DHZ.get((b & TONE_INDEX) as usize).map(|&t| t as f64 / 10.0); + let dcs_text = + |b: u8| DTCS_CODES.get((b & DCS_INDEX) as usize).map(|&d| format!("{d:03}")); + if m.is_split_tone { + if let Some(&(name, _)) = CROSS_MODES.iter().find(|&&(_, v)| v == m.tmode) { + c.tone_mode = Some("Cross".into()); + c.cross_mode = name.to_string(); + c.ctcss_uplink = tone_hz(m.tone); + c.ctcss_downlink = tone_hz(m.rxtone); + c.dcs_code = dcs_text(m.dcs); + c.dcs_rx_code = dcs_text(m.rxdcs); + } + } else { + match m.tmode { + TMODE_TONE => { + c.tone_mode = Some("Tone".into()); + c.ctcss_uplink = tone_hz(m.tone); + } + TMODE_TSQL => { + c.tone_mode = Some("TSQL".into()); + c.ctcss_uplink = tone_hz(m.tone); + c.ctcss_downlink = tone_hz(m.rxtone); + } + TMODE_DTCS => { + c.tone_mode = Some("DTCS".into()); + c.dcs_code = dcs_text(m.dcs); + } + _ => c.tone_mode = Some("off".into()), + } + } + c +} + +#[cfg(test)] +mod tests { + use super::super::layout::{BITMAP_LEN, IMAGE_LEN_US}; + use super::*; + + /// Memory 1 exactly as the FT-897 wrote it (capture A, 2026-10-17): + /// 50.000 MHz CW, tag "6CW 50.0", simplex with `is_duplex` set and a 1 MHz + /// default shift in the offset field. + const REAL_6M_CW: [u8; ENTRY_LEN] = [ + 0x82, 0x21, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4C, 0x4B, + 0x40, 0x00, 0x01, 0x86, 0xA0, b'6', b'C', b'W', b' ', b'5', b'0', b'.', b'0', + ]; + /// Memory 15: 7.175 MHz LSB, "40SSB 7.", step byte 0x88. + const REAL_40M_LSB: [u8; ENTRY_LEN] = [ + 0x80, 0x20, 0x00, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0A, 0xF2, + 0xBC, 0x00, 0x00, 0x27, 0x10, b'4', b'0', b'S', b'S', b'B', b' ', b'7', b'.', + ]; + + #[test] + fn real_records_decode_to_what_the_radio_showed() { + let m = decode_memory(&REAL_6M_CW); + assert_eq!(m.freq_10hz * 10, 50_000_000); + assert_eq!(mode_text(&m), "CW"); + assert_eq!(m.freq_range, 1, "6 m is range 1"); + assert_eq!(tag_text(&m), "6CW 50.0"); + assert!(m.is_duplex && m.duplex == DUPLEX_NONE, "the radio's simplex quirk"); + + let m = decode_memory(&REAL_40M_LSB); + assert_eq!(m.freq_10hz * 10, 7_175_000); + assert_eq!(mode_text(&m), "LSB"); + assert_eq!(m.freq_range, 0); + assert_eq!(m.steps, 0x88); + } + + #[test] + fn real_records_survive_both_round_trips() { + for raw in [REAL_6M_CW, REAL_40M_LSB] { + let m = decode_memory(&raw); + assert_eq!(encode_memory(&m), raw, "lossless"); + let c = decode_channel(&m); + let again = encode_channel(&c, &tag_text(&m), Some(&m)).unwrap(); + assert_eq!(encode_memory(&again), raw, "channel-level"); + } + } + + fn fm(rx: f64, tx: f64) -> Channel { + Channel { + rx_freq: rx, + tx_freq: Some(tx), + mode: Some("FM".into()), + dcs_polarity: "NN".into(), + cross_mode: "Tone->Tone".into(), + ..Default::default() + } + } + + #[test] + fn a_fresh_repeater_record_has_every_field_chirp_would_write() { + let mut c = fm(146.94, 146.34); + c.tone_mode = Some("Tone".into()); + c.ctcss_uplink = Some(100.0); + let m = encode_channel(&c, "W8ABC", None).unwrap(); + assert_eq!(m.freq_10hz, 14_694_000); + assert_eq!(m.freq_range, 4, "2 m is range 4"); + assert_eq!((m.duplex, m.is_duplex, m.offset_10hz), (DUPLEX_MINUS, true, 60_000)); + assert_eq!((m.is_split_tone, m.tmode), (false, TMODE_TONE)); + assert_eq!(TONES_DHZ[m.tone as usize], 1000); + assert!(m.tag_on_off); + assert_eq!(&m.name, b"W8ABC "); + let back = decode_channel(&m); + assert_eq!(back.tx_freq.map(|f| (f * 1000.0).round()), Some(146_340.0)); + assert_eq!(back.ctcss_uplink, Some(100.0)); + } + + #[test] + fn narrow_split_and_dcs() { + let mut c = fm(446.0, 446.0); + c.mode = Some("NFM".into()); + c.tone_mode = Some("DTCS".into()); + c.dcs_code = Some("023".into()); + let m = encode_channel(&c, "", None).unwrap(); + assert!(m.is_fm_narrow && m.mode == MODE_FM); + assert_eq!((m.tmode, m.dcs, m.rxdcs), (TMODE_DTCS, 0, 0)); + assert!(!m.tag_on_off); + + let mut c = fm(144.39, 430.0); + c.duplex = Some("split".into()); + let m = encode_channel(&c, "SPLIT", None).unwrap(); + assert_eq!((m.duplex, m.offset_10hz, m.tx_freq_range), (DUPLEX_SPLIT, 43_000_000, 5)); + } + + #[test] + fn refusals_name_the_problem() { + let gap = encode_channel(&fm(222.0, 222.0), "", None).unwrap_err(); + assert!(gap.contains("222.00000 MHz"), "{gap}"); + let mut c = fm(146.52, 146.52); + c.mode = Some("DMR".into()); + assert!(encode_channel(&c, "", None).unwrap_err().contains("DMR")); + c.mode = Some("FM".into()); + c.tone_mode = Some("Tone".into()); + c.ctcss_uplink = Some(150.0); + assert!(encode_channel(&c, "", None).unwrap_err().contains("150.0")); + } + + #[test] + fn bitmaps_are_lsb_first_and_memory_one_cannot_be_hidden() { + let mut image = vec![0xFFu8; IMAGE_LEN_US]; + image[VISIBLE_BASE..VISIBLE_BASE + BITMAP_LEN].fill(0); + image[FILLED_BASE..FILLED_BASE + BITMAP_LEN].fill(0); + store_memory(&mut image, 16, &decode_memory(&REAL_6M_CW)); + // Memory 17 is bit 0 of byte 2 — where the FT-897 put its 17th memory. + assert_eq!(image[VISIBLE_BASE + 2], 0x01); + assert_eq!(image[FILLED_BASE + 2], 0x01); + assert_eq!(visible_slots(&image), vec![16]); + hide_memory(&mut image, 16).unwrap(); + assert!(!is_visible(&image, 16) && is_filled(&image, 16)); + assert!(hide_memory(&mut image, 0).is_err()); + } +} diff --git a/src-tauri/src/radios/yaesu_ft857d/mod.rs b/src-tauri/src/radios/yaesu_ft857d/mod.rs new file mode 100644 index 0000000..f5c19e3 --- /dev/null +++ b/src-tauri/src/radios/yaesu_ft857d/mod.rs @@ -0,0 +1,246 @@ +//! Yaesu FT-857D and FT-897 (issue #145): one driver, because the two radios +//! share one clone protocol and one memory map — CHIRP drives both from a single +//! `FT857Radio` class, and three FT-897 reads matched it block for block. +//! +//! The first radio here whose clone is driven from its own front panel: the +//! operator presses [C] SEND for every read and [A] RCV before every write, and +//! the app tells them when through [`crate::radios::operator`]. A program run is +//! therefore three operator steps — SEND (backup), RCV + Continue (write), SEND +//! (read-back) — inside one session under one port lock. +//! +//! ⚠ Hardware status: +//! - every image this was built against came off an **FT-897** (US). The +//! FT-857D on hand has a dead CAT/LINEAR TXD line (0 V; it sends nothing in +//! CLONE or CAT) — `scratchpad/yaesu_ft857d/FINDINGS.md`. Its layout is the +//! same by CHIRP's account and unmeasured here; +//! - the clone **read** is proven on the FT-897; the **write** has never run +//! against a radio (ladder steps 1-2); +//! - FM tone/DCS/shift encoding is CHIRP's and unseen in a radio-written record. + +#[cfg(test)] +mod hw_ladder; +mod ft857_settings_table; +pub(crate) mod layout; +pub(crate) mod memory; +pub(crate) mod program; +pub(crate) mod protocol; +pub(crate) mod settings; +#[cfg(test)] +mod real_images; + +use crate::commands::export::SlotChannel; +use crate::models::RadioModel; +use crate::radios::driver::{ + with_restore_hint, CodeplugProgramReport, DecodedChannelSample, ImageProgramRequest, + ImageProgrammer, ImageReader, ImageRestorer, RadioDriver, RadioIdentity, +}; +use crate::radios::operator; +use layout::{CHANNEL_COUNT, IMAGE_LEN_US}; +use protocol::{CLONE_MODE_STEP, PATIENCE, RCV_STEP, SEND_STEP}; + +pub(crate) struct YaesuFt857d; + +/// Registry entry (see `radios/registry.rs`). +pub(crate) static DRIVER: YaesuFt857d = YaesuFt857d; + +impl RadioDriver for YaesuFt857d { + fn key(&self) -> &'static str { + "yaesu_ft857d" + } + + fn display_name(&self) -> &'static str { + "Yaesu FT-857D / FT-897" + } + + fn baud(&self) -> u32 { + protocol::BAUD + } + + /// Over CAT, with the radio switched on normally — the clone has no + /// handshake, and this reads nothing but the displayed frequency. + fn identify(&self, port: &str) -> Result { + let (baud, reply) = protocol::cat_identify(port)?; + Ok(RadioIdentity { + matched: format!("FT-857/897 CAT at {baud}"), + ident_hex: reply.iter().map(|b| format!("{b:02X}")).collect::>().join(" "), + ident_ascii: Some(protocol::cat_reply_text(&reply)), + }) + } + + /// `memory.rs` writes every mode the channel library has an analog name for. + fn analog_modes(&self) -> &'static [&'static str] { + &["FM", "NFM", "AM", "USB", "LSB", "CW"] + } + + fn as_image_programmer(&self) -> Option<&dyn ImageProgrammer> { + Some(self) + } + + fn as_image_restorer(&self) -> Option<&dyn ImageRestorer> { + Some(self) + } + + fn as_settings_reader(&self) -> Option<&dyn crate::radios::driver::SettingsReader> { + Some(self) + } + + /// Settings are their own write — read, patch, write, read back — and a + /// codeplug program leaves the radio's settings exactly as it read them. + fn as_settings_writer(&self) -> Option<&dyn crate::radios::driver::SettingsWriter> { + Some(self) + } +} + +/// Listen, ask for SEND, and take the image. +fn read_from_radio(port: &str, purpose: &str) -> Result, String> { + let mut p = protocol::open_port(port)?; + operator::say(&format!( + "{purpose}: if the radio is not showing CLONE MODE — {CLONE_MODE_STEP}. Then \ + {SEND_STEP}. The app is listening." + )); + protocol::read_clone(&mut *p, PATIENCE, &mut |_, _| {}) +} + +/// Ask for RCV, wait for Continue, and send the image. +fn write_to_radio(port: &str, image: &[u8], purpose: &str) -> Result<(), String> { + operator::wait( + &format!( + "{purpose}: if the radio is not showing CLONE MODE — {CLONE_MODE_STEP}. Then \ + {RCV_STEP} and click Continue." + ), + operator::WAIT_LIMIT, + )?; + let mut p = protocol::open_port(port)?; + protocol::write_clone(&mut *p, image, PATIENCE, &mut |_, _| {}) +} + +fn identity_of(image: &[u8]) -> RadioIdentity { + RadioIdentity { + matched: "clone image".to_string(), + ident_hex: image[..2].iter().map(|b| format!("{b:02X}")).collect::>().join(" "), + ident_ascii: Some(if image.len() == IMAGE_LEN_US { "US model" } else { "export model" }.into()), + } +} + +impl ImageReader for YaesuFt857d { + fn download_image(&self, port: &str) -> Result<(RadioIdentity, Vec), String> { + let image = read_from_radio(port, "Backup")?; + Ok((identity_of(&image), image)) + } + + fn decode_sample(&self, image: &[u8]) -> Vec { + program::decode_sample(image) + } +} + +impl ImageProgrammer for YaesuFt857d { + fn upload_image(&self, port: &str, image: &[u8]) -> Result<(), String> { + write_to_radio(port, image, "Write") + } + + fn build_image( + &self, + model: &RadioModel, + channels: &[SlotChannel], + base: &[u8], + ) -> Result, String> { + program::build_image(model, channels, base) + } + + /// Back up, patch, write, read back — three trips to the radio's front + /// panel, one session. + /// + /// ⚠ The write half has never run against a radio. Ladder steps 1-2. + fn program_codeplug( + &self, + port: &str, + req: &ImageProgramRequest, + ) -> Result { + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let slug: String = req + .label + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }) + .collect::() + .trim_matches('-') + .to_string(); + let backup_path = req.backup_dir.join(if slug.is_empty() { + format!("ft857d-prewrite-{stamp}.img") + } else { + format!("ft857d-prewrite-{slug}-{stamp}.img") + }); + + // 1. Back up what the radio holds now. + let base = read_from_radio(port, "Programming, step 1 of 3 — first, a backup of the radio")?; + std::fs::write(&backup_path, &base) + .map_err(|e| format!("could not write backup {}: {e}", backup_path.display()))?; + + // 2. Patch the codeplug into THAT image — refused whole before the + // operator is asked for anything if a channel cannot be held. + let built = program::build_image(req.model, req.channels, &base)?; + + // 3. Write. + write_to_radio(port, &built, "Programming, step 2 of 3 — the write").map_err(|e| { + with_restore_hint( + e, + &backup_path, + "Keep that file. \"Restore backup…\" in this dialog sends it back over the same \ + cable — it is the only copy of what was on the radio before this write.", + ) + })?; + + // 4. Read back. Non-fatal: every block was acknowledged. + let (verified, note) = match read_from_radio(port, "Programming, step 3 of 3 — checking the write") { + Ok(after) if after == built => (true, None), + Ok(after) => { + let differ = after.iter().zip(&built).filter(|(a, b)| a != b).count(); + ( + false, + Some(format!( + "Every block was acknowledged, but the read-back differs from what was \ + written in {differ} bytes. Download again to see what the radio holds." + )), + ) + } + Err(e) => ( + false, + Some(format!("Written, but the read-back check could not run ({e}).")), + ), + }; + + Ok(CodeplugProgramReport { + channels_written: req.channels.len(), + slots_cleared: CHANNEL_COUNT - req.channels.len(), + settings_written: None, + verified: Some(verified), + note, + backup_path: backup_path.to_string_lossy().to_string(), + channels: program::decode_sample(&built), + zones_written: 0, + zones_cleared: 0, + banks_written: 0, + scan_lists_written: 0, + scan_lists_cleared: 0, + contacts_written: 0, + contacts_cleared: 0, + expected_path: None, + windows_written: Vec::new(), + skipped: Vec::new(), + warnings: Vec::new(), + }) + } +} + +impl ImageRestorer for YaesuFt857d { + /// Shape only — a backup from another FT-857D/897 is a legitimate restore. + /// ⚠ What an export radio does with a US image's extra 60 m block, or a US + /// radio with an image that lacks it, is unmeasured — nothing here can tell + /// the radio's kind before the write starts. + fn check_restore_image(&self, image: &[u8]) -> Result<(), String> { + protocol::check_image(image) + } + + fn restore_image(&self, port: &str, image: &[u8]) -> Result<(), String> { + write_to_radio(port, image, "Restore") + } +} diff --git a/src-tauri/src/radios/yaesu_ft857d/program.rs b/src-tauri/src/radios/yaesu_ft857d/program.rs new file mode 100644 index 0000000..91b0594 --- /dev/null +++ b/src-tauri/src/radios/yaesu_ft857d/program.rs @@ -0,0 +1,205 @@ +//! Codeplug → image, and image → the download sanity sample. + +use super::layout::CHANNEL_COUNT; +use super::memory::{ + decode_channel, encode_channel, existing_memory, hide_memory, is_visible, mode_text, + read_memory, store_memory, tag_text, +}; +use super::protocol::check_image; +use crate::commands::export::SlotChannel; +use crate::models::RadioModel; +use crate::radios::driver::DecodedChannelSample; + +/// Patch `channels` into `base` (an image just read off the radio) and return +/// the image to write. +/// +/// Full replace of the 200 regular memories: every slot the codeplug does not +/// fill is hidden, the way the radio erases one (record and filled bit kept). +/// PMS edges, VFOs, HOME, the 60 m channels and every setting go back exactly as +/// they came. +/// +/// Every channel is encoded before a byte changes, so a codeplug with one +/// channel the radio cannot hold is refused whole rather than half applied. +pub(crate) fn build_image( + model: &RadioModel, + channels: &[SlotChannel], + base: &[u8], +) -> Result, String> { + check_image(base)?; + let capacity = model + .memory_channels + .map(|n| (n as usize).min(CHANNEL_COUNT)) + .unwrap_or(CHANNEL_COUNT); + if channels.len() > capacity { + return Err(format!( + "Codeplug has {} programmable channels, but the radio holds only {capacity}.", + channels.len() + )); + } + // CHIRP: "if you upload an empty image you can brick your radio". + if !channels.iter().any(|sc| sc.slot == 0) { + return Err("Memory 1 must hold a channel on this radio; the codeplug leaves it empty." + .to_string()); + } + + let mut records = Vec::with_capacity(channels.len()); + for sc in channels { + if sc.slot >= capacity { + return Err(format!( + "channel \"{}\" resolved to memory {} — beyond the radio's {capacity}.", + sc.name, + sc.slot + 1 + )); + } + let m = encode_channel(&sc.channel, &sc.name, existing_memory(base, sc.slot).as_ref()) + .map_err(|e| { + format!( + "channel \"{}\" ({:.4} MHz) cannot be programmed: {e}", + sc.name, sc.channel.rx_freq + ) + })?; + records.push((sc.slot, m)); + } + + let mut image = base.to_vec(); + let mut taken = [false; CHANNEL_COUNT]; + for (slot, m) in &records { + taken[*slot] = true; + store_memory(&mut image, *slot, m); + } + for (slot, used) in taken.iter().enumerate() { + if !used && is_visible(&image, slot) { + hide_memory(&mut image, slot)?; + } + } + Ok(image) +} + +/// The memories the radio shows, for the "is this read real?" table. +pub(crate) fn decode_sample(image: &[u8]) -> Vec { + if check_image(image).is_err() { + return Vec::new(); + } + (0..CHANNEL_COUNT) + .filter(|&s| is_visible(image, s)) + .map(|slot| { + let m = read_memory(image, slot); + let c = decode_channel(&m); + let shift = match c.duplex.as_deref() { + Some("+") => format!("+{:.3}", c.offset.unwrap_or(0.0)), + Some("-") => format!("−{:.3}", c.offset.unwrap_or(0.0)), + Some("split") => format!("TX {:.4}", c.tx_freq.unwrap_or(0.0)), + _ => String::new(), + }; + let tone = match c.tone_mode.as_deref() { + Some("Tone") => format!("T {:.1}", c.ctcss_uplink.unwrap_or(0.0)), + Some("TSQL") => format!("TSQL {:.1}", c.ctcss_downlink.unwrap_or(0.0)), + Some("DTCS") => format!("DTCS {}", c.dcs_code.clone().unwrap_or_default()), + Some("Cross") => format!("Cross {}", c.cross_mode), + _ => "—".to_string(), + }; + DecodedChannelSample { + index: slot + 1, + name: tag_text(&m), + rx_mhz: c.rx_freq, + shift: Some(shift), + tone, + // No per-memory power: RF POWER SET (menu 075) is per band. + power: "—".to_string(), + mode: Some(mode_text(&m).to_string()), + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Channel; + use crate::radios::yaesu_ft857d::layout::{memory_offset, IMAGE_LEN_US, VISIBLE_BASE}; + use crate::radios::yaesu_ft857d::memory::{decode_memory, encode_memory}; + + /// A blank US image with memories 1-3 visible, filled from a real record. + fn base() -> Vec { + let mut img = vec![0xFFu8; IMAGE_LEN_US]; + img[VISIBLE_BASE..VISIBLE_BASE + 27].fill(0); + img[super::super::layout::FILLED_BASE..super::super::layout::FILLED_BASE + 27].fill(0); + let real: [u8; 28] = [ + 0x82, 0x21, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4C, + 0x4B, 0x40, 0x00, 0x01, 0x86, 0xA0, b'6', b'C', b'W', b' ', b'5', b'0', b'.', b'0', + ]; + for slot in 0..3 { + store_memory(&mut img, slot, &decode_memory(&real)); + } + img + } + + fn model() -> RadioModel { + RadioModel { memory_channels: Some(200), ..Default::default() } + } + + fn sc(slot: usize, name: &str, rx: f64, mode: &str) -> SlotChannel { + SlotChannel { + slot, + name: name.into(), + channel: Channel { + rx_freq: rx, + tx_freq: Some(rx), + mode: Some(mode.into()), + dcs_polarity: "NN".into(), + cross_mode: "Tone->Tone".into(), + ..Default::default() + }, + } + } + + #[test] + fn a_codeplug_replaces_the_memories_and_touches_nothing_else() { + let b = base(); + let out = build_image( + &model(), + &[sc(0, "20M FT8", 14.074, "USB"), sc(1, "2M CALL", 146.52, "FM")], + &b, + ) + .unwrap(); + let sample = decode_sample(&out); + assert_eq!(sample.len(), 2, "memory 3 is hidden"); + assert_eq!((sample[0].name.as_str(), sample[0].mode.as_deref()), ("20M FT8", Some("USB"))); + assert_eq!((sample[1].rx_mhz, sample[1].mode.as_deref()), (146.52, Some("FM"))); + + // Outside the memory table and its bitmaps, not one byte moved. + let changed: Vec = (0..b.len()).filter(|&i| b[i] != out[i]).collect(); + for i in changed { + let in_table = (memory_offset(0)..memory_offset(3)).contains(&i); + let in_visible = i == VISIBLE_BASE; + assert!(in_table || in_visible, "byte 0x{i:04X} changed"); + } + // Memory 3 is hidden but its record stays, like the radio's own erase. + let at = memory_offset(2); + assert_eq!(&out[at..at + 28], &b[at..at + 28]); + } + + #[test] + fn writing_back_what_was_read_changes_nothing() { + let b = base(); + let back: Vec = (0..3) + .map(|s| { + let m = read_memory(&b, s); + SlotChannel { slot: s, name: tag_text(&m), channel: decode_channel(&m) } + }) + .collect(); + assert_eq!(build_image(&model(), &back, &b).unwrap(), b); + let _ = encode_memory(&read_memory(&b, 0)); + } + + #[test] + fn refusals_come_before_any_byte_changes() { + let b = base(); + let e = build_image(&model(), &[sc(1, "X", 146.52, "FM")], &b).unwrap_err(); + assert!(e.contains("Memory 1"), "{e}"); + let e = build_image(&model(), &[sc(0, "OK", 146.52, "FM"), sc(1, "GAP", 222.0, "FM")], &b) + .unwrap_err(); + assert!(e.contains("\"GAP\""), "{e}"); + assert!(build_image(&model(), &[sc(0, "A", 146.52, "FM")], &[0u8; 10]).is_err()); + } +} diff --git a/src-tauri/src/radios/yaesu_ft857d/protocol.rs b/src-tauri/src/radios/yaesu_ft857d/protocol.rs new file mode 100644 index 0000000..7ab691e --- /dev/null +++ b/src-tauri/src/radios/yaesu_ft857d/protocol.rs @@ -0,0 +1,455 @@ +//! The FT-857D / FT-897 clone, both directions. +//! +//! There is no handshake and the PC is never in charge. The operator puts the +//! radio in CLONE MODE (hold MODE◄ + MODE► while switching on) and then: +//! +//! - **read** — the app listens first, then the operator presses [C] SEND. The +//! radio sends `[block#][data][sum8(data)]` and waits for `0x06` after each. +//! Without an ACK it re-sends block 0 roughly every 6.6 s, then shows ERROR. +//! - **write** — the operator presses [A] RCV first, then the app sends the same +//! frames and the radio ACKs each. +//! +//! Hardware status (2026-10-17, FT-897 US, Digirig): the **read** is proven — +//! three full reads, 53 blocks each, every sum good. The **write** follows +//! CHIRP's `_clone_out` and has never been run against a radio here; ladder +//! steps 1 and 2 are what prove it. +//! +//! Two read details came from the radio, not from CHIRP: +//! - Entering clone mode with the port already open can put a stray byte on the +//! line before block 0 (`00 00 1F 06` was seen once). The reader slides past +//! junk until block 0's frame checks out. +//! - Whether the image is US (one more 140-byte block, the 60 m channels) is +//! decided by the radio sending that block or not. CHIRP makes the operator +//! pick a model and errors on the wrong one; here the radio answers it. +//! +//! RTS and DTR are held low: the Digirig keys PTT on RTS, and both low is how +//! every successful read was taken. + +use std::io::Write; +use std::time::{Duration, Instant}; + +use serialport::SerialPort; + +use super::layout::{block_lengths, IMAGE_LEN_EXPORT, IMAGE_LEN_US, US_EXTRA_BLOCK}; + +pub(crate) const BAUD: u32 = 9600; +const ACK: u8 = 0x06; + +/// How long to wait at each point. Tests shrink these; the radio does not. +#[derive(Clone, Copy)] +pub(crate) struct Patience { + /// For block 0 of a read: the operator has to reach the radio and press SEND. + pub first: Duration, + /// For every later block, and for each ACK during a write. + pub block: Duration, + /// After the last export block, for the US radio's extra block. + pub tail: Duration, +} + +pub(crate) const PATIENCE: Patience = Patience { + first: Duration::from_secs(120), + block: Duration::from_secs(5), + tail: Duration::from_secs(3), +}; + +/// What the operator does, worded once so the error messages and the dialog +/// cannot drift apart. +pub(crate) const CLONE_MODE_STEP: &str = + "Turn the radio off, then hold MODE◄ and MODE► while switching it on until it shows CLONE MODE"; +pub(crate) const SEND_STEP: &str = "press [C] (SEND) on the radio"; +pub(crate) const RCV_STEP: &str = "press [A] (RCV) on the radio"; + +pub(crate) fn open_port(port: &str) -> Result, String> { + let mut p = serialport::new(port, BAUD) + .data_bits(serialport::DataBits::Eight) + .parity(serialport::Parity::None) + .stop_bits(serialport::StopBits::One) + .flow_control(serialport::FlowControl::None) + .timeout(Duration::from_millis(200)) + .open() + .map_err(|e| format!("could not open {port}: {e}"))?; + let _ = p.write_request_to_send(false); + let _ = p.write_data_terminal_ready(false); + Ok(p) +} + +/// Read up to `n` bytes, giving up at `patience`. A short result is the +/// caller's to judge — the radio going quiet is an answer, not an I/O error. +fn read_n(p: &mut dyn SerialPort, n: usize, patience: Duration) -> Result, String> { + let mut buf = Vec::with_capacity(n); + let start = Instant::now(); + let mut chunk = [0u8; 512]; + while buf.len() < n && start.elapsed() < patience { + let want = (n - buf.len()).min(chunk.len()); + match p.read(&mut chunk[..want]) { + Ok(k) => buf.extend_from_slice(&chunk[..k]), + Err(e) if e.kind() == std::io::ErrorKind::TimedOut => {} + Err(e) => return Err(format!("serial read failed: {e}")), + } + } + Ok(buf) +} + +fn sum8(data: &[u8]) -> u8 { + data.iter().fold(0u8, |a, b| a.wrapping_add(*b)) +} + +/// Whether `frame` is block `n` of `len` data bytes with a good sum. +fn frame_ok(frame: &[u8], n: usize, len: usize) -> bool { + frame.len() == len + 2 && frame[0] == n as u8 && sum8(&frame[1..=len]) == frame[len + 1] +} + +/// Receive an image the operator sends with [C] SEND. Call this **before** the +/// operator presses SEND; it waits `patience.first` for block 0. +/// +/// `progress(done, total)` is called after each block; `total` is the US count +/// since which kind of radio this is is not known until the end. +pub(crate) fn read_clone( + p: &mut dyn SerialPort, + patience: Patience, + progress: &mut dyn FnMut(usize, usize), +) -> Result, String> { + let blocks = block_lengths(false); + let total = blocks.len() + 1; + let mut image = Vec::with_capacity(IMAGE_LEN_US); + for (n, &len) in blocks.iter().enumerate() { + let mut frame = + read_n(p, len + 2, if n == 0 { patience.first } else { patience.block })?; + if n == 0 { + if frame.is_empty() { + return Err(format!( + "Nothing arrived from the radio. {CLONE_MODE_STEP}, then start the read and \ + {SEND_STEP}." + )); + } + // Slide past line junk until block 0 checks out. + let deadline = Instant::now() + patience.first; + while !frame_ok(&frame, 0, len) && frame.len() == len + 2 && Instant::now() < deadline { + frame.remove(0); + frame.extend(read_n(p, 1, patience.block)?); + } + } + if !frame_ok(&frame, n, len) { + return Err(if frame.len() < len + 2 { + format!( + "The radio stopped sending at block {n} of {total} ({} of {} bytes). Check the \ + cable and try again.", + frame.len(), + len + 2 + ) + } else { + format!( + "Block {n} arrived damaged (header {:02X}, checksum {:02X} vs {:02X}).", + frame[0], + sum8(&frame[1..=len]), + frame[len + 1] + ) + }); + } + image.extend_from_slice(&frame[1..=len]); + p.write_all(&[ACK]).map_err(|e| format!("serial write failed: {e}"))?; + progress(n + 1, total); + } + + // A US radio sends one more block; an export radio goes quiet. + let n = blocks.len(); + let tail = read_n(p, US_EXTRA_BLOCK + 2, patience.tail)?; + if !tail.is_empty() { + if !frame_ok(&tail, n, US_EXTRA_BLOCK) { + return Err(format!( + "The radio sent {} bytes after the last expected block, not a block this app \ + knows. Nothing was changed on the radio.", + tail.len() + )); + } + image.extend_from_slice(&tail[1..=US_EXTRA_BLOCK]); + p.write_all(&[ACK]).map_err(|e| format!("serial write failed: {e}"))?; + progress(total, total); + } + check_image(&image)?; + Ok(image) +} + +/// The image's shape: one of the two lengths the radio produces. Says nothing +/// about which unit it came from. +pub(crate) fn check_image(image: &[u8]) -> Result<(), String> { + match image.len() { + IMAGE_LEN_US | IMAGE_LEN_EXPORT => Ok(()), + n => Err(format!( + "{n} bytes is not an FT-857D/FT-897 clone image ({IMAGE_LEN_EXPORT} or {IMAGE_LEN_US})" + )), + } +} + +/// Send `image` to a radio waiting in RCV. The operator presses [A] RCV +/// **before** this is called. +pub(crate) fn write_clone( + p: &mut dyn SerialPort, + image: &[u8], + patience: Patience, + progress: &mut dyn FnMut(usize, usize), +) -> Result<(), String> { + check_image(image)?; + let blocks = block_lengths(image.len() == IMAGE_LEN_US); + let total = blocks.len(); + let _ = p.clear(serialport::ClearBuffer::Input); + let mut at = 0; + for (n, &len) in blocks.iter().enumerate() { + let data = &image[at..at + len]; + let mut frame = Vec::with_capacity(len + 2); + frame.push(n as u8); + frame.extend_from_slice(data); + frame.push(sum8(data)); + // CHIRP pauses 10 ms before each block. + std::thread::sleep(Duration::from_millis(10)); + p.write_all(&frame).map_err(|e| format!("serial write failed: {e}"))?; + let reply = read_n(p, 1, patience.block)?; + match reply.first() { + Some(&ACK) => {} + None if n == 0 => { + return Err(format!( + "The radio did not answer. {CLONE_MODE_STEP}, {RCV_STEP}, and only then start \ + the write. Nothing was written." + )) + } + None => { + return Err(format!( + "The radio stopped answering at block {n} of {total}. Its memory is now \ + partly written." + )) + } + Some(b) => { + return Err(format!("The radio refused block {n} of {total} (replied {b:02X}).")) + } + } + at += len; + progress(n + 1, total); + } + Ok(()) +} + +/// Identify over CAT, not clone: with the radio switched on normally, send the +/// read-only "read frequency and mode" command (`00 00 00 00 03`) at each of the +/// three CAT rates (menu 019) and report what answers. Proven on the FT-897 +/// (2026-10-17: `01 42 35 65 0A` at 4800 = 14.235.65 MHz DIG, as displayed). +/// It cannot tell an FT-857D from an FT-897 — the command set is shared. +pub(crate) fn cat_identify(port: &str) -> Result<(u32, [u8; 5]), String> { + for baud in [4800, 9600, 38400] { + let mut p = serialport::new(port, baud) + .data_bits(serialport::DataBits::Eight) + .parity(serialport::Parity::None) + .stop_bits(serialport::StopBits::Two) + .flow_control(serialport::FlowControl::None) + .timeout(Duration::from_millis(100)) + .open() + .map_err(|e| format!("could not open {port}: {e}"))?; + let _ = p.write_request_to_send(false); + let _ = p.write_data_terminal_ready(false); + let _ = p.clear(serialport::ClearBuffer::All); + p.write_all(&[0, 0, 0, 0, 0x03]).map_err(|e| format!("serial write failed: {e}"))?; + let r = read_n(&mut *p, 5, Duration::from_millis(600))?; + if let Ok(reply) = <[u8; 5]>::try_from(r.as_slice()) { + return Ok((baud, reply)); + } + } + Err("No answer over CAT at 4800, 9600 or 38400. Switch the radio on normally (not in clone \ + mode), check menu 020 CAT/LIN/TUN is CAT, and that the cable is in the CAT/LINEAR jack." + .to_string()) +} + +/// `01 42 35 65 0A` → "14.23565 MHz DIG". +pub(crate) fn cat_reply_text(r: &[u8; 5]) -> String { + let digits: String = r[..4].iter().map(|b| format!("{b:02X}")).collect(); + let mhz = digits.parse::().map(|v| v as f64 / 100_000.0).unwrap_or(0.0); + let mode = match r[4] { + 0x00 => "LSB", + 0x01 => "USB", + 0x02 => "CW", + 0x03 => "CWR", + 0x04 => "AM", + 0x06 => "WFM", + 0x08 => "FM", + 0x0A => "DIG", + 0x0C => "PKT", + 0x88 => "NFM", + _ => "?", + }; + format!("{mhz:.5} MHz {mode}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::radios::fake_port::{FakePort, FakeRadio}; + + const QUICK: Patience = Patience { + first: Duration::from_millis(50), + block: Duration::from_millis(50), + tail: Duration::from_millis(20), + }; + + fn frame(n: usize, data: &[u8]) -> Vec { + let mut f = vec![n as u8]; + f.extend_from_slice(data); + f.push(sum8(data)); + f + } + + /// A radio that has had SEND pressed: block 0 is already on the wire, and + /// each ACK draws the next. + struct Sender { + frames: Vec>, + next: usize, + } + + impl Sender { + fn port(image: &[u8], junk: &[u8]) -> FakePort { + let lens = block_lengths(image.len() == IMAGE_LEN_US); + let mut frames = Vec::new(); + let mut at = 0; + for (n, len) in lens.into_iter().enumerate() { + frames.push(frame(n, &image[at..at + len])); + at += len; + } + let mut first = junk.to_vec(); + first.extend_from_slice(&frames[0]); + let mut p = FakePort::new(Sender { frames, next: 1 }); + p.radio_sends(&first); + p + } + } + + impl FakeRadio for Sender { + fn step(&mut self, req: &[u8], out: &mut Vec) -> usize { + let Some(&b) = req.first() else { return 0 }; + assert_eq!(b, ACK, "the host only ever sends ACK during a read"); + if let Some(f) = self.frames.get(self.next) { + out.extend_from_slice(f); + self.next += 1; + } + 1 + } + } + + /// A radio that has had RCV pressed: it checks every frame and stores it. + struct Receiver { + lens: Vec, + got: Vec, + refuse: Option, + } + + impl FakeRadio for Receiver { + fn step(&mut self, req: &[u8], out: &mut Vec) -> usize { + if req.is_empty() { + return 0; + } + let n = self.got.len(); + let block = self.lens.iter().scan(0, |a, l| { + let s = *a; + *a += l; + Some(s) + }); + let idx = block.take_while(|&s| s < n).count(); + let Some(&len) = self.lens.get(idx) else { panic!("a frame past the last block") }; + if req.len() < len + 2 { + return 0; + } + assert!(frame_ok(&req[..len + 2], idx, len), "frame {idx} malformed"); + if self.refuse == Some(idx) { + out.push(0x15); + } else { + self.got.extend_from_slice(&req[1..=len]); + out.push(ACK); + } + len + 2 + } + } + + fn image(len: usize) -> Vec { + (0..len).map(|i| (i * 7 + 3) as u8).collect() + } + + #[test] + fn a_us_read_takes_all_53_blocks_and_skips_line_junk() { + let want = image(IMAGE_LEN_US); + // The stray 00 the FT-897 put on the line once. + let mut p = Sender::port(&want, &[0x00]); + let mut last = (0, 0); + let got = read_clone(&mut p, QUICK, &mut |d, t| last = (d, t)).unwrap(); + assert_eq!(got, want); + assert_eq!(last, (53, 53)); + } + + #[test] + fn an_export_read_ends_quietly_after_52_blocks() { + let want = image(IMAGE_LEN_EXPORT); + let mut p = Sender::port(&want, &[]); + assert_eq!(read_clone(&mut p, QUICK, &mut |_, _| {}).unwrap(), want); + } + + #[test] + fn a_silent_radio_is_told_how_to_send() { + let mut p = FakePort::new(Sender { frames: vec![], next: 0 }); + let e = read_clone(&mut p, QUICK, &mut |_, _| {}).unwrap_err(); + assert!(e.contains("CLONE MODE") && e.contains("SEND"), "{e}"); + } + + #[test] + fn a_write_sends_every_block_the_radio_checks() { + for len in [IMAGE_LEN_US, IMAGE_LEN_EXPORT] { + let img = image(len); + let mut p = FakePort::new(Receiver { + lens: block_lengths(len == IMAGE_LEN_US), + got: vec![], + refuse: None, + }); + let mut last = (0, 0); + write_clone(&mut p, &img, QUICK, &mut |d, t| last = (d, t)).unwrap(); + assert_eq!(p.radio.got, img); + assert_eq!(last.0, last.1); + } + } + + #[test] + fn a_refused_block_stops_the_write_and_says_where() { + let img = image(IMAGE_LEN_US); + let mut p = FakePort::new(Receiver { + lens: block_lengths(true), + got: vec![], + refuse: Some(9), + }); + let e = write_clone(&mut p, &img, QUICK, &mut |_, _| {}).unwrap_err(); + assert!(e.contains("block 9 of 53"), "{e}"); + } + + #[test] + fn a_radio_not_in_rcv_is_told_how_and_nothing_was_written() { + struct Deaf; + impl FakeRadio for Deaf { + fn step(&mut self, req: &[u8], _: &mut Vec) -> usize { + req.len() + } + } + let e = write_clone(&mut FakePort::new(Deaf), &image(IMAGE_LEN_US), QUICK, &mut |_, _| {}) + .unwrap_err(); + assert!(e.contains("RCV") && e.contains("Nothing was written"), "{e}"); + } + + #[test] + fn the_cat_reply_the_ft897_gave_reads_as_its_display() { + assert_eq!(cat_reply_text(&[0x01, 0x42, 0x35, 0x65, 0x0A]), "14.23565 MHz DIG"); + } + + #[test] + fn a_wrong_length_image_is_never_sent() { + let e = write_clone(&mut FakePort::new(Deaf2), &[0u8; 100], QUICK, &mut |_, _| {}) + .unwrap_err(); + assert!(e.contains("100 bytes"), "{e}"); + struct Deaf2; + impl FakeRadio for Deaf2 { + fn step(&mut self, _: &[u8], _: &mut Vec) -> usize { + panic!("nothing should reach the radio") + } + } + } +} diff --git a/src-tauri/src/radios/yaesu_ft857d/real_images.rs b/src-tauri/src/radios/yaesu_ft857d/real_images.rs new file mode 100644 index 0000000..1f232d2 --- /dev/null +++ b/src-tauri/src/radios/yaesu_ft857d/real_images.rs @@ -0,0 +1,106 @@ +//! The phase-2 gate: re-encode a radio's own memories out of its own image and +//! get the same bytes back. +//! +//! The images are real clone reads and are not committed — they are the +//! operator's codeplug. They live in the gitignored scratchpad: +//! +//! ```sh +//! CPM_FT857_IMAGES=../scratchpad/yaesu_ft857d/captures \ +//! cargo test --lib yaesu_ft857d::real_images -- --ignored --nocapture +//! ``` +//! +//! ⚠ As of 2026-10-17 every image there came off an **FT-897**. A pass is +//! evidence for the shared layout on that radio, not for the FT-857D. + +use std::path::PathBuf; + +use super::layout::{ + memory_offset, CHANNEL_COUNT, ENTRY_LEN, IMAGE_LEN_EXPORT, IMAGE_LEN_US, MEMORY_BASE, + PMS_COUNT, +}; +use super::memory::{ + decode_channel, decode_memory, encode_channel, encode_memory, existing_memory, is_visible, + store_memory, tag_text, +}; + +fn images() -> Vec<(PathBuf, Vec)> { + let dir = std::env::var("CPM_FT857_IMAGES").expect("set CPM_FT857_IMAGES to a folder of .img"); + let mut out: Vec<_> = std::fs::read_dir(&dir) + .expect("read the image folder") + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|x| x == "img")) + .map(|p| { + let b = std::fs::read(&p).unwrap(); + (p, b) + }) + .collect(); + out.sort(); + assert!(!out.is_empty(), "no .img files in {dir}"); + out +} + +#[test] +#[ignore] +fn every_record_of_every_real_image_re_encodes_byte_identically() { + for (path, img) in images() { + assert!( + img.len() == IMAGE_LEN_US || img.len() == IMAGE_LEN_EXPORT, + "{}: {} bytes", + path.display(), + img.len() + ); + // Memories and PMS alike, empty `FF` slots included. + for slot in 0..CHANNEL_COUNT + PMS_COUNT { + let at = MEMORY_BASE + slot * ENTRY_LEN; + let raw: [u8; ENTRY_LEN] = img[at..at + ENTRY_LEN].try_into().unwrap(); + assert_eq!(encode_memory(&decode_memory(&raw)), raw, "{} slot {slot}", path.display()); + } + println!("{}: lossless", path.display()); + } +} + +#[test] +#[ignore] +fn writing_a_radios_own_channels_back_changes_nothing() { + for (path, img) in images() { + let mut out = img.clone(); + let mut n = 0; + for slot in 0..CHANNEL_COUNT { + if !is_visible(&img, slot) { + continue; + } + let base = existing_memory(&img, slot).expect("a visible memory is filled"); + let channel = decode_channel(&base); + let m = encode_channel(&channel, &tag_text(&base), Some(&base)) + .unwrap_or_else(|e| panic!("{} memory {}: {e}", path.display(), slot + 1)); + store_memory(&mut out, slot, &m); + n += 1; + } + if out != img { + let first = out.iter().zip(&img).position(|(a, b)| a != b).unwrap(); + let slot = first.saturating_sub(MEMORY_BASE) / ENTRY_LEN; + panic!( + "{}: first difference at 0x{first:04X} (memory {} at 0x{:04X})", + path.display(), + slot + 1, + memory_offset(slot) + ); + } + println!("{}: {n} memories re-encoded through the channel model, identical", path.display()); + } +} + +#[test] +#[ignore] +fn every_real_images_settings_round_trip_byte_identically() { + use super::settings::{apply_profile_settings, decode_settings}; + for (path, img) in images() { + let decoded = decode_settings(&img); + let mut out = img.clone(); + let (n, notes) = apply_profile_settings(&mut out, &decoded) + .unwrap_or_else(|e| panic!("{}: {e}", path.display())); + assert!(notes.is_empty(), "{}: {notes:?}", path.display()); + assert_eq!(out, img, "{}", path.display()); + println!("{}: {n} settings, beep-vol {}, identical", path.display(), decoded["beep-vol"]); + } +} diff --git a/src-tauri/src/radios/yaesu_ft857d/settings.rs b/src-tauri/src/radios/yaesu_ft857d/settings.rs new file mode 100644 index 0000000..9c815a7 --- /dev/null +++ b/src-tauri/src/radios/yaesu_ft857d/settings.rs @@ -0,0 +1,386 @@ +//! FT-857D / FT-897 settings: decoded out of, and patched into, the clone image. +//! +//! There is no narrower transport — the clone always carries the whole image — +//! so a settings write sends every byte back. What keeps it narrow is the +//! patch: only the bits of the fields the profile names change, everything else +//! (memories included) goes back exactly as it was read, and the read-back +//! compares the whole image. +//! +//! The field set is short on purpose: [`super::ft857_settings_table`] carries +//! only fields measured on the radio or anchored by the factory-default +//! cross-check with a settled encoding. See its header for what is withheld and +//! why. +//! +//! A key the profile does not carry, or carries blank, is left exactly as the +//! radio has it — a profile is a patch, not a replace. + +use std::path::Path; + +use serde_json::{json, Value}; + +use super::ft857_settings_table::{At, Charset, Kind, FIELDS, SF}; +use super::{read_from_radio, write_to_radio}; +use crate::radios::driver::{ + with_restore_hint, SettingsCapture, SettingsReader, SettingsWriteReport, SettingsWriter, +}; + +/// The radio's alphabets, as CHIRP `ft857.py` defines them. The byte is the +/// index; these are not ASCII. +fn alphabet(set: Charset) -> Vec { + let mut v: Vec = ('0'..='9').chain('A'..='Z').collect(); + v.extend([' ', '/']); + if set == Charset::Beacon { + v.extend(['+', '.']); + } + v +} + +fn bits(image: &[u8], byte: usize, shift: u8, width: u8) -> u16 { + u16::from(image[byte] >> shift) & ((1u16 << width) - 1) +} + +fn set_bits(image: &mut [u8], byte: usize, shift: u8, width: u8, value: u16) { + let mask = (((1u16 << width) - 1) as u8) << shift; + image[byte] = (image[byte] & !mask) | (((value as u8) << shift) & mask); +} + +/// The field's value as the form carries it: a number, an option label, or text. +/// A stored value outside what the field can show reads as `null` (or a named +/// unknown, for an enum) rather than failing the whole read. +fn decode_field(f: &SF, image: &[u8]) -> Value { + match (f.at, f.kind) { + (At::Text { off, len, set }, _) => { + let abc = alphabet(set); + let text: Option = + image[off..off + len].iter().map(|&b| abc.get(b as usize).copied()).collect(); + match text { + Some(t) => json!(t.trim_end()), + None => Value::Null, + } + } + (At::Bits { byte, shift, width }, Kind::Int { lo, hi, bias }) => { + let shown = i32::from(bits(image, byte, shift, width)) - i32::from(bias); + if (i32::from(lo)..=i32::from(hi)).contains(&shown) { + json!(shown) + } else { + Value::Null + } + } + (At::Bits { byte, shift, width }, Kind::Bool) => json!(bits(image, byte, shift, width) != 0), + (At::Bits { byte, shift, width }, _) => { + let v = bits(image, byte, shift, width) as usize; + match f.options.get(v) { + Some(label) => json!(label), + None => json!(format!("(unknown value {v})")), + } + } + } +} + +/// Why a value could not be written. +#[derive(Debug)] +enum Reject { + /// A select label this app cannot name — how a value read off a radio + /// survives a round trip. Left as the radio has it, with a note. + Unnameable(String), + /// Wrong shape or out of range: refused, never clamped. + Invalid(String), +} + +fn encode_field(f: &SF, v: &Value, image: &mut [u8]) -> Result<(), Reject> { + match (f.at, f.kind) { + (At::Text { off, len, set }, _) => { + let s = v + .as_str() + .ok_or_else(|| Reject::Invalid(format!("{}: expected text, got {v}", f.key)))? + .to_ascii_uppercase(); + if s.chars().count() > len { + return Err(Reject::Invalid(format!( + "{} ({}): at most {len} characters", + f.label, f.menu + ))); + } + let abc = alphabet(set); + let mut cells = vec![abc.iter().position(|&c| c == ' ').unwrap() as u8; len]; + for (i, ch) in s.chars().enumerate() { + cells[i] = abc.iter().position(|&c| c == ch).ok_or_else(|| { + Reject::Invalid(format!( + "{} ({}): the radio cannot store '{ch}' — it has {}", + f.label, + f.menu, + if set == Charset::Beacon { "0-9, A-Z, space, / + ." } else { "0-9, A-Z, space and /" } + )) + })? as u8; + } + image[off..off + len].copy_from_slice(&cells); + Ok(()) + } + (At::Bits { byte, shift, width }, Kind::Int { lo, hi, bias }) => { + let n = v + .as_i64() + .ok_or_else(|| Reject::Invalid(format!("{}: expected a number, got {v}", f.key)))?; + if !(i64::from(lo)..=i64::from(hi)).contains(&n) { + return Err(Reject::Invalid(format!("{}: {n} is outside {lo}..={hi}", f.key))); + } + set_bits(image, byte, shift, width, (n + i64::from(bias)) as u16); + Ok(()) + } + (At::Bits { byte, shift, width }, Kind::Bool) => { + let b = v + .as_bool() + .ok_or_else(|| Reject::Invalid(format!("{}: expected true or false, got {v}", f.key)))?; + set_bits(image, byte, shift, width, u16::from(b)); + Ok(()) + } + (At::Bits { byte, shift, width }, _) => { + let s = v.as_str().ok_or_else(|| { + Reject::Invalid(format!("{}: expected one of its options, got {v}", f.key)) + })?; + let i = f.options.iter().position(|o| *o == s).ok_or_else(|| { + Reject::Unnameable(format!( + "{} ({}) holds {s:?}, which this app cannot name; left as the radio had it", + f.label, f.menu + )) + })?; + set_bits(image, byte, shift, width, i as u16); + Ok(()) + } + } +} + +/// Every emitted field, decoded out of a clone image. +pub(crate) fn decode_settings(image: &[u8]) -> Value { + Value::Object(FIELDS.iter().map(|f| (f.key.to_string(), decode_field(f, image))).collect()) +} + +/// Patch the profile's settings into `image`. Returns how many fields were +/// written and a note for each deliberately left alone. Named as the cable +/// radios' encoder is (see the BT-9000's): the caller is `write_settings` +/// itself, not an export path. +pub(crate) fn apply_profile_settings( + image: &mut [u8], + settings: &Value, +) -> Result<(usize, Vec), String> { + let obj = settings.as_object().ok_or("profile settings are not a JSON object")?; + let (mut written, mut notes) = (0, Vec::new()); + for f in &FIELDS { + let Some(v) = obj.get(f.key) else { continue }; + // Blank means "leave it alone": a cleared number box arrives as "". + // Text is the exception — an empty beacon text is a real value. + if v.is_null() || (v.as_str() == Some("") && f.kind != Kind::Text) { + continue; + } + match encode_field(f, v, image) { + Ok(()) => written += 1, + Err(Reject::Unnameable(n)) => notes.push(n), + Err(Reject::Invalid(e)) => return Err(e), + } + } + Ok((written, notes)) +} + +impl SettingsReader for super::YaesuFt857d { + /// A clone read ([C] SEND); the image is the session's backup. + fn read_settings(&self, port: &str, _schema_json: &str) -> Result { + let image = read_from_radio(port, "Read settings")?; + Ok(SettingsCapture { settings: decode_settings(&image), backup: image, backup_ext: "img" }) + } +} + +impl SettingsWriter for super::YaesuFt857d { + /// Read and back up (SEND), patch, write (RCV + Continue), read back (SEND). + /// A profile that changes nothing on this radio skips the write and its + /// front-panel step altogether. + fn write_settings( + &self, + port: &str, + settings: &Value, + schema_json: &str, + backup_dir: &Path, + ) -> Result { + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let backup_path = backup_dir.join(format!("ft857d-presettings-{stamp}.img")); + + let base = read_from_radio(port, "Writing settings, step 1 of 3 — first, a backup of the radio")?; + std::fs::write(&backup_path, &base) + .map_err(|e| format!("could not write backup {}: {e}", backup_path.display()))?; + + let mut settings = settings.clone(); + let mut notes = + crate::radios::settings_bounds::strip_out_of_range(schema_json, &mut settings); + let mut image = base.clone(); + let (fields_written, skipped) = apply_profile_settings(&mut image, &settings)?; + notes.extend(skipped); + + if image == base { + notes.push("The radio already holds every value in the profile; nothing was written.".into()); + return Ok(SettingsWriteReport { + fields_written, + verified: Some(true), + note: Some(notes.join(" ")), + backup_path: backup_path.to_string_lossy().to_string(), + expected_path: None, + windows_written: Vec::new(), + }); + } + + write_to_radio(port, &image, "Writing settings, step 2 of 3 — the write").map_err(|e| { + with_restore_hint( + e, + &backup_path, + "Keep that file — it is the radio as it was read, and \"Restore backup…\" \ + sends it back over the same cable.", + ) + })?; + + let verified = match read_from_radio(port, "Writing settings, step 3 of 3 — checking the write") { + Ok(after) if after == image => true, + Ok(after) => { + let n = after.iter().zip(&image).filter(|(a, b)| a != b).count(); + notes.push(format!("The read-back differs from what was written in {n} bytes.")); + false + } + Err(e) => { + notes.push(format!("Written, but the read-back check could not run ({e}).")); + false + } + }; + + Ok(SettingsWriteReport { + fields_written, + verified: Some(verified), + note: (!notes.is_empty()).then(|| notes.join(" ")), + backup_path: backup_path.to_string_lossy().to_string(), + expected_path: None, + windows_written: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::radios::yaesu_ft857d::layout::IMAGE_LEN_US; + + const SCHEMA: &str = include_str!("../../ft857_settings_schema.json"); + + /// A real FT-897 settings block, bytes 0..71 of the image read 2026-11-03 + /// with EXT MENU switched on — the image whose values were read off the + /// radio's own screen — and its ARTS IDW text. + fn real() -> Vec { + let mut img = vec![0u8; IMAGE_LEN_US]; + let head: [u8; 71] = [ + 0x1F, 0x06, 0x80, 0x00, 0xC0, 0x20, 0x13, 0x54, 0x87, 0x09, 0x40, 0x03, 0x65, 0x19, + 0x32, 0x48, 0xB2, 0x05, 0x00, 0x00, 0xB2, 0x32, 0x32, 0x32, 0x32, 0x32, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x32, + 0x00, 0xC5, 0x00, 0x78, 0x1F, 0x00, 0x32, 0x00, 0x00, 0x32, 0x32, 0xE4, 0x02, 0x85, + 0x86, 0x88, 0x85, 0x86, 0x87, 0x60, 0x00, 0x60, 0x00, 0x00, 0x20, 0x00, 0xE4, 0xB2, + 0x94, + ]; + img[..71].copy_from_slice(&head); + // ARTS IDW "YAESU" + 5 spaces, in the call-sign alphabet. + let abc = alphabet(Charset::Callsign); + for (i, ch) in "YAESU ".chars().enumerate() { + img[0x1BF3 + i] = abc.iter().position(|&c| c == ch).unwrap() as u8; + } + img + } + + #[test] + fn the_table_and_the_schema_describe_the_same_fields() { + let schema: Vec = serde_json::from_str(SCHEMA).unwrap(); + let fields: Vec<&Value> = schema.iter().filter(|f| f["type"] != "section").collect(); + assert_eq!(fields.len(), FIELDS.len()); + for (s, f) in fields.iter().zip(FIELDS.iter()) { + assert_eq!(s["key"], f.key); + match f.kind { + Kind::Bool => assert_eq!(s["type"], "boolean", "{}", f.key), + Kind::Int { lo, hi, .. } => { + assert_eq!((s["type"].as_str(), s["min"].as_u64(), s["max"].as_u64()), + (Some("integer"), Some(lo.into()), Some(hi.into())), "{}", f.key); + } + Kind::Enum => { + let opts: Vec<&str> = + s["options"].as_array().unwrap().iter().map(|o| o.as_str().unwrap()).collect(); + assert_eq!(opts, f.options, "{}", f.key); + } + Kind::Text => assert_eq!(s["type"], "text", "{}", f.key), + } + } + } + + #[test] + fn the_ft897s_own_values_decode_to_what_its_screen_showed() { + let s = decode_settings(&real()); + // Read off the radio's own screen, 2026-11-03. + assert_eq!(s["ext-menu"], true, "001: switched ON, one-bit differential"); + assert_eq!(s["clar-dial-sel"], "CLAR", "021"); + assert_eq!(s["cw-auto-mode"], false, "022"); + assert_eq!(s["cw-qsk"], "10 ms", "028 — CHIRP's list would say 5 ms"); + assert_eq!(s["cw-sidetone"], 50, "029"); + assert_eq!(s["cw-speed"], "12 wpm", "030"); + assert_eq!(s["disp-color"], "FIX", "041"); + assert_eq!(s["disp-color-fix"], 1, "041 FIX:1 at raw 0"); + assert_eq!(s["disp-contrast"], 5, "042 at raw 7 — CHIRP's +2 would say 9"); + assert_eq!(s["beep-vol"], 50, "014"); + // The factory-default cross-check (the radio had been reset). + assert_eq!(s["ssb-mic-gain"], 50); + assert_eq!(s["dsp-nr-level"], 8, "raw 7"); + assert_eq!(s["dsp-lpf-cutoff"], "6000 Hz"); + assert_eq!(s["dig-mode"], "RTTY-L"); + assert_eq!(s["cat-rate"], "4800"); + assert_eq!(s["cw-pitch"], "700 Hz"); + assert_eq!(s["cw-weight"], "1:3.0"); + assert_eq!(s["disp-mode"], "AUTO2"); + assert_eq!( + [&s["pg-a"], &s["pg-b"], &s["pg-c"], &s["pg-acc"], &s["pg-p1"], &s["pg-p2"]], + ["MONI", "Q.SPL", "ATC", "MONI", "Q.SPL", "TCALL"] + ); + assert_eq!( + [&s["rf-power-hf"], &s["rf-power-6m"], &s["rf-power-vhf"], &s["rf-power-uhf"]], + [100, 100, 50, 20] + ); + assert_eq!(s["arts-idw"], "YAESU"); + } + + #[test] + fn reading_then_writing_back_changes_nothing() { + let img = real(); + let mut out = img.clone(); + let (n, notes) = apply_profile_settings(&mut out, &decode_settings(&img)).unwrap(); + assert_eq!(n, FIELDS.len()); + assert!(notes.is_empty(), "{notes:?}"); + assert_eq!(out, img); + } + + #[test] + fn a_change_moves_only_its_own_bits() { + let img = real(); + let mut out = img.clone(); + apply_profile_settings( + &mut out, + &json!({"beep-vol": 37, "cat-rate": "38400", "arts-idw": "ww8l/p", "vox-gain": ""}), + ) + .unwrap(); + let changed: Vec = (0..img.len()).filter(|&i| img[i] != out[i]).collect(); + // BEEP VOL 50 -> 37 (0x32 -> 0x25), top bit kept. + assert_eq!(out[0x29], 0x25); + // CAT RATE: bits 7-6 of byte 17 to 2; VOX DELAY (low 5 bits) untouched. + assert_eq!(out[17], (2 << 6) | (img[17] & 0x3F)); + assert_eq!(decode_settings(&out)["arts-idw"], "WW8L/P"); + assert!(changed.iter().all(|&i| i == 0x29 || i == 17 || (0x1BF3..0x1BFD).contains(&i))); + } + + #[test] + fn refusals() { + let mut img = real(); + let e = apply_profile_settings(&mut img, &json!({"rf-power-uhf": 50})).unwrap_err(); + assert!(e.contains("2..=20"), "{e}"); + let e = apply_profile_settings(&mut img, &json!({"arts-idw": "W8@B"})).unwrap_err(); + assert!(e.contains("'@'"), "{e}"); + let e = apply_profile_settings(&mut img, &json!({"beacon-text-1": "x".repeat(41)})).unwrap_err(); + assert!(e.contains("40 characters"), "{e}"); + let (_, notes) = apply_profile_settings(&mut img, &json!({"cw-pitch": "750 Hz"})).unwrap(); + assert_eq!(notes.len(), 1); + } +} diff --git a/src-tauri/src/radios/yaesu_ftm300/mod.rs b/src-tauri/src/radios/yaesu_ftm300/mod.rs index 030cd30..4996733 100644 --- a/src-tauri/src/radios/yaesu_ftm300/mod.rs +++ b/src-tauri/src/radios/yaesu_ftm300/mod.rs @@ -42,6 +42,11 @@ pub(crate) struct YaesuFtm300; pub(crate) static DRIVER: YaesuFtm300 = YaesuFtm300; impl RadioDriver for YaesuFtm300 { + /// `sd_image::encode_record` sets the AM bit. + fn analog_modes(&self) -> &'static [&'static str] { + &["FM", "NFM", "AM"] + } + fn key(&self) -> &'static str { "yaesu_ftm300" } diff --git a/src-tauri/src/radios/yaesu_ftm510/mod.rs b/src-tauri/src/radios/yaesu_ftm510/mod.rs index b93d711..a5314e4 100644 --- a/src-tauri/src/radios/yaesu_ftm510/mod.rs +++ b/src-tauri/src/radios/yaesu_ftm510/mod.rs @@ -63,6 +63,11 @@ pub(crate) struct YaesuFtm510; pub(crate) static DRIVER: YaesuFtm510 = YaesuFtm510; impl RadioDriver for YaesuFtm510 { + /// Shares the FTM-300's `encode_record`, AM included. + fn analog_modes(&self) -> &'static [&'static str] { + &["FM", "NFM", "AM"] + } + fn key(&self) -> &'static str { "yaesu_ftm510" } diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index d1c2bac..049565f 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -1361,9 +1361,73 @@ fn models() -> Vec { // microSD backup, not over a cable. non_channel_settings_schema: include_str!("ftm510_settings_schema.json"), }, + // -------------------------------------------------------- + // Yaesu FT-857D and FT-897 (#145) — HF/50/144/430 all-mode mobiles, + // 200 memories, 8-char tags. One driver for both: CHIRP drives them + // from a single class, and the FT-897's own clone images matched it. + // NOTES: (a) the clone is started from the radio's front panel ([C] + // SEND / [A] RCV); the dialog walks the operator through it. (b) + // rx_bands is the Operating Manual's spec, gaps and all: 0.1-56, + // 76-108, 118-164, 420-470 (CHIRP's table starts the air band at + // 108). (c) tx_bands are the US amateur allocations the manual's + // "amateur bands only" means. 60 m is left out: the US radio + // transmits there on its five fixed channels, which the app does not + // write. 70 cm is 430-450, the band Yaesu's US spec names. (d) No + // per-memory power — RF POWER SET is per band, a setting. (e) MEM + // GROUP divides the 200 memories by position only, so channel lists + // flatten. (f) ⚠ The FT-857D on hand has a dead CAT TXD, so every + // measurement behind this came off the FT-897. + // -------------------------------------------------------- + ft8x7_seed("FT-857D", "Yaesu FT-857D"), + ft8x7_seed("FT-897D", "Yaesu FT-897 / FT-897D"), ] } +/// The FT-857D and FT-897 differ in this table only in name. +fn ft8x7_seed(model: &'static str, display_name: &'static str) -> ModelSeed { + ModelSeed { + manufacturer: "Yaesu", + model, + driver_key: Some("yaesu_ft857d"), + programming_ui: Some("generic"), + display_name, + analog_capable: true, + dmr_capable: false, + dstar_capable: false, + ysf_capable: false, + nxdn_capable: false, + p25_capable: false, + m17_capable: false, + aprs_capable: false, + covers_hf: true, + covers_vhf: true, + covers_uhf: true, + covers_220: false, + covers_900: false, + freq_min: 1.8, + freq_max: 450.0, + tx_bands: Some( + "[[1.8,2.0],[3.5,4.0],[7.0,7.3],[10.1,10.15],[14.0,14.35],[18.068,18.168],\ + [21.0,21.45],[24.89,24.99],[28.0,29.7],[50.0,54.0],[144.0,148.0],[430.0,450.0]]", + ), + rx_bands: Some("[[0.1,56.0],[76.0,108.0],[118.0,164.0],[420.0,470.0]]"), + memory_channels: 200, + zones_supported: false, + max_zones: None, + channels_per_zone: None, + scan_lists_supported: false, + max_scan_lists: None, + banks_supported: false, + max_name_length: 8, + export_format: "chirp_csv", + connection_type: "CAT cable (CT-62 type) to the CAT/LINEAR jack", + // GENERATED by scratchpad/yaesu_ft857d/settings/gen_settings.py — the + // same run writes radios/yaesu_ft857d/ft857_settings_table.rs. Only the + // fields MEASURED.md grades as settled; the rest await the screen check. + non_channel_settings_schema: include_str!("ft857_settings_schema.json"), + } +} + /// A seeded model as the database would hand it back, for driver tests that /// need a real row without a pool. #[cfg(test)] diff --git a/src/components/OperatorStepBox.tsx b/src/components/OperatorStepBox.tsx new file mode 100644 index 0000000..5deb12e --- /dev/null +++ b/src/components/OperatorStepBox.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { RadioTower } from "lucide-react"; +import { api } from "../lib/api"; +import type { OperatorStep } from "../lib/types"; +import { Button } from "./ui"; + +/** + * What the operator must do at the radio right now, sent by the backend in the + * middle of a radio session (`radio-operator-step`). Only the FT-857D/897 sends + * any: its clone is started from the radio's own front panel, so a read asks + * for SEND and a write waits — Continue/Cancel — for RCV, which the radio cannot + * announce on its own. + * + * Render it wherever a radio command can be running; it draws nothing while + * `active` is false or no step is pending. + */ +export function OperatorStepBox({ active, className }: { active: boolean; className?: string }) { + const [step, setStep] = useState(null); + useEffect(() => { + const un = listen("radio-operator-step", (e) => + setStep(e.payload.text ? e.payload : null), + ); + return () => { + void un.then((f) => f()); + }; + }, []); + useEffect(() => { + if (!active) setStep(null); + }, [active]); + + if (!active || !step?.text) return null; + return ( +
+
+ + {step.text} +
+ {step.needs_continue && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/components/codeplugs/ProgramRadioDialog.tsx b/src/components/codeplugs/ProgramRadioDialog.tsx index 5a908ac..c028a9b 100644 --- a/src/components/codeplugs/ProgramRadioDialog.tsx +++ b/src/components/codeplugs/ProgramRadioDialog.tsx @@ -27,6 +27,7 @@ import type { CallsignDbReport, } from "../../lib/types"; import { FooterClose, Modal } from "../overlays"; +import { OperatorStepBox } from "../OperatorStepBox"; import { PortSelect, usePortChoice } from "../radioPort"; import { WarningList } from "./ReportWarnings"; import { Button, Spinner } from "../ui"; @@ -975,6 +976,10 @@ export function ProgramRadioDialog({ )} + {/* What to do at the radio, mid-session. Above the progress notes: + while it is up, it is the only thing that moves the session on. */} + + {/* Progress note while programming */} {busy === "program" && (
@@ -1206,8 +1211,20 @@ function ResultBlock({ warnings?: string[]; backupPath: string; backupLabel: string; - channels: { index: number; name: string; rx_mhz: number; tone: string; power: string }[]; + channels: { + index: number; + name: string; + rx_mhz: number; + tone: string; + power: string; + shift?: string | null; + mode?: string | null; + }[]; }) { + // Columns a driver does not decode stay hidden (the UV-5R reports neither). + // On an all-mode radio the mode is what tells a USB memory from an FM one. + const showShift = channels.some((c) => c.shift != null); + const showMode = channels.some((c) => c.mode != null); return (
# Name RX (MHz) + {showShift && Shift} Tone + {showMode && Mode} Power @@ -1260,7 +1279,9 @@ function ResultBlock({ {c.index} {c.name || "—"} {c.rx_mhz.toFixed(4)} + {showShift && {c.shift || "—"}} {c.tone} + {showMode && {c.mode ?? "—"}} {c.power} ))} diff --git a/src/components/profiles/ProfileEditor.tsx b/src/components/profiles/ProfileEditor.tsx index 41d160b..ed4d6f9 100644 --- a/src/components/profiles/ProfileEditor.tsx +++ b/src/components/profiles/ProfileEditor.tsx @@ -14,6 +14,7 @@ import { import clsx from "clsx"; import { toast } from "sonner"; import { api, withToast } from "../../lib/api"; +import { OperatorStepBox } from "../OperatorStepBox"; import { mediaWriteForFormat, useDriverCapabilities, @@ -95,6 +96,7 @@ function RadioSyncBar({ {busy ? : } Download from radio +
); } @@ -200,6 +202,7 @@ function WriteToRadioBar({ {blocked && (

{blocked}

)} + {busy && afterWrite && (

diff --git a/src/lib/api.ts b/src/lib/api.ts index bc90025..3ebbd8b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -434,6 +434,9 @@ export const api = { restoreImage: (driverKey: string, port: string, path: string) => invoke("restore_image", { driverKey, port, path }), backupsDir: () => invoke("backups_dir"), + /** 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". */ + operatorAnswer: (proceed: boolean) => invoke("operator_answer", { proceed }), // What is in radio-backups/, and the operator-initiated prune. Nothing here // deletes on its own — see the Rust module for why (#77). radioBackupsSummary: (keep?: number) => diff --git a/src/lib/types.ts b/src/lib/types.ts index 81ebb93..0f049a2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1097,3 +1097,12 @@ export interface TalkgroupRefreshSummary { added: number; total: number; } + +/** A `radio-operator-step` event: what the operator must do at the radio right + * now. `text: null` clears it. `needs_continue` means the radio cannot say + * when it is done (a radio waiting in RCV is silent), so the session is paused + * until Continue or Cancel. */ +export interface OperatorStep { + text: string | null; + needs_continue: boolean; +}