diff --git a/README.md b/README.md index 893693f..5313365 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ including programming several different radios from the same codeplug. | **Yaesu FT5D** | C4FM (System Fusion) + Analog | VHF / UHF TX, wideband RX | microSD — patches the radio's own backup file | 900 channels in 24 banks; channels, banks, and menu settings | | **Yaesu FTM-300DR** | C4FM (System Fusion) + Analog | 2 m / 70 cm TX, 108–999 MHz RX | microSD — patches the radio's own backup file | 999 memories; memories and 147 menu settings, including the APRS and SmartBeaconing setup | | **Yaesu FTM-510DR** | C4FM (System Fusion) + Analog | 2 m / 70 cm TX, 108–550 MHz RX | microSD — patches the radio's own backup file | 999 memories; memories and 284 settings — Setup Menu items, band skip, DTMF memories, and the APRS setup down to digi paths, message texts and status texts | +| **Icom ID-51 Plus** | D-STAR + Analog | 2 m / 70 cm TX, 108–174 / 380–479 MHz RX | microSD — patches the radio's own `.icf` file | 500 memories in 26 named banks; memories and 241 menu settings including the D-PRS (APRS) position setup restore in one operation. Also the 50th Anniversary edition; not the original ID-51 or the Plus2 | | **Icom ID-52** | D-STAR + Analog | VHF / UHF TX, 108–174 / 225–479 MHz RX | microSD — patches the radio's own `.icf` file | 1000 memories in 100 groups; memories and menu settings restore in one operation | | **Icom ID-5100** | D-STAR + Analog | 2 m / 70 cm TX, 118–174 / 375–550 MHz RX | Data cable — read, write, settings | 1000 memories in 26 named banks, programmed by Icom's clone protocol over the cable; 292 menu settings including My Call Sign, TX messages and the D-PRS (APRS) position, object, item and weather setup, written in the same upload as the memories | | **Kenwood TH-D72** | APRS + Analog | 2 m / 70 cm TX, 118–174 / 320–524 MHz RX | Direct USB — read, write, settings | 1000 memories; 113 menu settings over the radio's own `MU` command | diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index c4c94cf..50c68c1 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -550,6 +550,16 @@ pub(crate) fn channel_fit(channel: &Channel, model: &RadioModel) -> ChannelFit { )); } + // A frequency the radio's memory cannot represent, whatever its bands say. + if let Some(why) = crate::radios::registry::driver_for_model(model) + .and_then(|d| d.unstorable_frequency(channel.rx_freq)) + { + return ChannelFit::Excluded(format!( + "Frequency {:.4} MHz {why} on {}", + channel.rx_freq, model.display_name + )); + } + // Transmit: both ends of the channel have to land in a band the radio can // key up on — a repeater channel is no use if the input is out of band. let tx_freq = tx_frequency(channel); @@ -1223,6 +1233,7 @@ pub async fn generate_codeplug( // the shared CHIRP-compatible analog CSV. // The exporter names the file before it writes it, so the report below can // say which one to load on the radio. + let mut driver_notes: Vec = Vec::new(); let path = match model .export_format .as_deref() @@ -1244,7 +1255,8 @@ pub async fn generate_codeplug( profile_settings: profile_settings.as_deref(), memory_group: &memory_group, }; - exporter.export(&target, &req)?; + let (_, notes) = exporter.export_noting(&target, &req)?; + driver_notes = notes; target } None => { @@ -1271,7 +1283,7 @@ pub async fn generate_codeplug( Ok(CodeplugWritten { channels: included.len(), path, - note: crate::radios::settings_bounds::note_line(&dropped), + note: crate::radios::settings_bounds::note_line_with(&dropped, &driver_notes), }) } diff --git a/src-tauri/src/commands/program.rs b/src-tauri/src/commands/program.rs index 35dff7a..6b397bd 100644 --- a/src-tauri/src/commands/program.rs +++ b/src-tauri/src/commands/program.rs @@ -540,6 +540,7 @@ fn volume_name(root: &std::path::Path) -> String { pub async fn find_memory_cards(format: String) -> Vec { tauri::async_runtime::spawn_blocking(move || match format.as_str() { "yaesu_ft5d_sd" => ft5d_cards(), + "icom_id51_icf" => id51_cards(), "icom_id52_icf" => id52_cards(), "kenwood_thd75_sd" => thd75_cards(), "yaesu_ftm300_sd" => ftm300_cards(), @@ -616,6 +617,26 @@ fn ftm510_cards() -> Vec { out } +/// The ID-51 Plus keeps its settings files in `ID-51/Setting/` and names them +/// itself, as the ID-52 does: what is offered is the **folder**, and the write +/// creates a new `SetYYYYMMDD_NN.icf` there from the newest file the radio +/// saved. Offered only if that folder holds a file this driver can use. +fn id51_cards() -> Vec { + let mut out = Vec::new(); + for root in mounted_roots() { + let dir = root.join("ID-51").join("Setting"); + if crate::radios::icom_id51::ID51_CARD.newest_settings_file(&dir).is_err() { + continue; + } + out.push(MemoryCard { + has_original: false, + volume: volume_name(&root), + path: dir.to_string_lossy().into_owned(), + }); + } + out +} + /// The ID-52 keeps its settings files in one fixed folder but names them /// itself, so what gets offered is the **folder**, not a file: the write creates /// a new `SetYYYYMMDD_NN.icf` in it rather than overwriting anything the @@ -806,6 +827,34 @@ pub async fn read_id52_settings_from_card(path: String) -> Result SD Card > Save Setting. Nothing is +/// written; the values land in the profile form and ride back out in the same +/// file as the memories. +#[tauri::command] +pub async fn read_id51_settings_from_card(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let text = std::fs::read_to_string(&path).map_err(|e| { + format!( + "Could not read {path}: {e}. Pick a settings file from ID-51/Setting/ on the \ + radio's microSD card — save one with MENU > SD Card > Save Setting if it is \ + not there." + ) + })?; + let icf = crate::radios::icom_id52::icf::IcfFile::parse(&text)?; + crate::radios::icom_id51::check_is_an_id51_file(&icf)?; + let settings = crate::radios::icom_id51::settings::decode_settings(icf.image()); + let count = settings.as_object().map(|o| o.len()).unwrap_or(0); + Ok(RadioSettingsRead { + settings, + count, + backup_path: path, + }) + }) + .await + .estr()? +} + /// The TH-D75's version of the same, reading the `.d75` the radio writes to its /// microSD card. /// @@ -963,11 +1012,11 @@ pub async fn write_card_settings( let file = exporter.resolve_target(&path)?; crate::commands::write_paths::check_write_target(&file, exporter.target_extensions())?; let writer = exporter.as_card_settings_writer().expect("checked above"); - let fields_written = writer.export_settings(&file, map)?; + let (fields_written, notes) = writer.export_settings_noting(&file, map)?; Ok(CardSettingsWritten { fields_written, path: file, - note: crate::radios::settings_bounds::note_line(&dropped), + note: crate::radios::settings_bounds::note_line_with(&dropped, ¬es), }) } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 60d5f36..eacf9a5 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, and the FT-857D and FT-897D. (0015 + // FTM-300DR and FTM-510DR, the FT-857D and FT-897D, and the Icom ID-51 Plus. (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, 16, + count.0, 17, "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, FTM-510DR, FT-857D and FT-897D seeded models" + TM-D710, ID-5100, MD-380, FTM-300DR, FTM-510DR, FT-857D, FT-897D and ID-51 Plus seeded models" ); let models: Vec<(String,)> = @@ -85,7 +85,7 @@ mod tests { assert_eq!( names, vec![ - "AT-D578UV", "AT-D890UV", "BT-9000", "FT-857D", "FT-897D", "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-51 Plus", "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, 16, "seeding should be idempotent"); + assert_eq!(count2.0, 17, "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/id51_settings_schema.json b/src-tauri/src/id51_settings_schema.json new file mode 100644 index 0000000..dc6c1b2 --- /dev/null +++ b/src-tauri/src/id51_settings_schema.json @@ -0,0 +1,2895 @@ +[ + { + "key": "section-set-mode", + "label": "Set Mode", + "type": "section", + "group": "General" + }, + { + "key": "set-mode-auto-power-off", + "label": "Auto Power OFF", + "type": "select", + "options": [ + "OFF", + "30min", + "60min", + "90min", + "120min" + ] + }, + { + "key": "set-mode-power-save", + "label": "Power Save", + "type": "select", + "options": [ + "OFF", + "Auto (Short)", + "Auto (Middle)", + "Auto (Long)" + ] + }, + { + "key": "set-mode-monitor", + "label": "Monitor", + "type": "select", + "options": [ + "Push", + "Hold" + ] + }, + { + "key": "set-mode-dial-speed-up", + "label": "Dial Speed-UP", + "type": "boolean" + }, + { + "key": "set-mode-auto-repeater", + "label": "Auto Repeater", + "type": "select", + "options": [ + "OFF", + "ON (DUP)", + "ON (DUP,TONE)" + ], + "group": "Transmit & Receive" + }, + { + "key": "set-mode-key-lock", + "label": "Key Lock", + "type": "select", + "options": [ + "Normal", + "No SQL", + "No VOL", + "ALL" + ] + }, + { + "key": "set-mode-ptt-lock", + "label": "PTT Lock", + "type": "boolean", + "group": "Transmit & Receive" + }, + { + "key": "set-mode-busy-lockout", + "label": "Busy Lockout", + "type": "boolean", + "group": "Transmit & Receive" + }, + { + "key": "set-mode-time-out-timer", + "label": "Time-Out Timer", + "type": "select", + "options": [ + "OFF", + "1min", + "3min", + "5min", + "10min", + "15min", + "30min" + ], + "group": "Transmit & Receive" + }, + { + "key": "set-mode-mic-gain-internal", + "label": "MIC Gain (Internal)", + "type": "select", + "options": [ + "1", + "2", + "3", + "4" + ], + "group": "Transmit & Receive" + }, + { + "key": "set-mode-mic-gain-external", + "label": "MIC Gain (External)", + "type": "select", + "options": [ + "1", + "2", + "3", + "4" + ], + "group": "Transmit & Receive" + }, + { + "key": "set-mode-data-speed", + "label": "Data Speed", + "type": "select", + "options": [ + "4800bps", + "9600bps" + ], + "group": "Connections" + }, + { + "key": "set-mode-active-band", + "label": "Active Band", + "type": "select", + "options": [ + "Single", + "ALL" + ] + }, + { + "key": "section-time-set", + "label": "Time Set", + "type": "section", + "group": "General" + }, + { + "key": "time-set-utc-offset", + "label": "UTC Offset", + "type": "select", + "options": [ + "-14:00", + "-13:55", + "-13:50", + "-13:45", + "-13:40", + "-13:35", + "-13:30", + "-13:25", + "-13:20", + "-13:15", + "-13:10", + "-13:05", + "-13:00", + "-12:55", + "-12:50", + "-12:45", + "-12:40", + "-12:35", + "-12:30", + "-12:25", + "-12:20", + "-12:15", + "-12:10", + "-12:05", + "-12:00", + "-11:55", + "-11:50", + "-11:45", + "-11:40", + "-11:35", + "-11:30", + "-11:25", + "-11:20", + "-11:15", + "-11:10", + "-11:05", + "-11:00", + "-10:55", + "-10:50", + "-10:45", + "-10:40", + "-10:35", + "-10:30", + "-10:25", + "-10:20", + "-10:15", + "-10:10", + "-10:05", + "-10:00", + "-9:55", + "-9:50", + "-9:45", + "-9:40", + "-9:35", + "-9:30", + "-9:25", + "-9:20", + "-9:15", + "-9:10", + "-9:05", + "-9:00", + "-8:55", + "-8:50", + "-8:45", + "-8:40", + "-8:35", + "-8:30", + "-8:25", + "-8:20", + "-8:15", + "-8:10", + "-8:05", + "-8:00", + "-7:55", + "-7:50", + "-7:45", + "-7:40", + "-7:35", + "-7:30", + "-7:25", + "-7:20", + "-7:15", + "-7:10", + "-7:05", + "-7:00", + "-6:55", + "-6:50", + "-6:45", + "-6:40", + "-6:35", + "-6:30", + "-6:25", + "-6:20", + "-6:15", + "-6:10", + "-6:05", + "-6:00", + "-5:55", + "-5:50", + "-5:45", + "-5:40", + "-5:35", + "-5:30", + "-5:25", + "-5:20", + "-5:15", + "-5:10", + "-5:05", + "-5:00", + "-4:55", + "-4:50", + "-4:45", + "-4:40", + "-4:35", + "-4:30", + "-4:25", + "-4:20", + "-4:15", + "-4:10", + "-4:05", + "-4:00", + "-3:55", + "-3:50", + "-3:45", + "-3:40", + "-3:35", + "-3:30", + "-3:25", + "-3:20", + "-3:15", + "-3:10", + "-3:05", + "-3:00", + "-2:55", + "-2:50", + "-2:45", + "-2:40", + "-2:35", + "-2:30", + "-2:25", + "-2:20", + "-2:15", + "-2:10", + "-2:05", + "-2:00", + "-1:55", + "-1:50", + "-1:45", + "-1:40", + "-1:35", + "-1:30", + "-1:25", + "-1:20", + "-1:15", + "-1:10", + "-1:05", + "-1:00", + "-0:55", + "-0:50", + "-0:45", + "-0:40", + "-0:35", + "-0:30", + "-0:25", + "-0:20", + "-0:15", + "-0:10", + "-0:05", + "±0:00", + "+0:05", + "+0:10", + "+0:15", + "+0:20", + "+0:25", + "+0:30", + "+0:35", + "+0:40", + "+0:45", + "+0:50", + "+0:55", + "+1:00", + "+1:05", + "+1:10", + "+1:15", + "+1:20", + "+1:25", + "+1:30", + "+1:35", + "+1:40", + "+1:45", + "+1:50", + "+1:55", + "+2:00", + "+2:05", + "+2:10", + "+2:15", + "+2:20", + "+2:25", + "+2:30", + "+2:35", + "+2:40", + "+2:45", + "+2:50", + "+2:55", + "+3:00", + "+3:05", + "+3:10", + "+3:15", + "+3:20", + "+3:25", + "+3:30", + "+3:35", + "+3:40", + "+3:45", + "+3:50", + "+3:55", + "+4:00", + "+4:05", + "+4:10", + "+4:15", + "+4:20", + "+4:25", + "+4:30", + "+4:35", + "+4:40", + "+4:45", + "+4:50", + "+4:55", + "+5:00", + "+5:05", + "+5:10", + "+5:15", + "+5:20", + "+5:25", + "+5:30", + "+5:35", + "+5:40", + "+5:45", + "+5:50", + "+5:55", + "+6:00", + "+6:05", + "+6:10", + "+6:15", + "+6:20", + "+6:25", + "+6:30", + "+6:35", + "+6:40", + "+6:45", + "+6:50", + "+6:55", + "+7:00", + "+7:05", + "+7:10", + "+7:15", + "+7:20", + "+7:25", + "+7:30", + "+7:35", + "+7:40", + "+7:45", + "+7:50", + "+7:55", + "+8:00", + "+8:05", + "+8:10", + "+8:15", + "+8:20", + "+8:25", + "+8:30", + "+8:35", + "+8:40", + "+8:45", + "+8:50", + "+8:55", + "+9:00", + "+9:05", + "+9:10", + "+9:15", + "+9:20", + "+9:25", + "+9:30", + "+9:35", + "+9:40", + "+9:45", + "+9:50", + "+9:55", + "+10:00", + "+10:05", + "+10:10", + "+10:15", + "+10:20", + "+10:25", + "+10:30", + "+10:35", + "+10:40", + "+10:45", + "+10:50", + "+10:55", + "+11:00", + "+11:05", + "+11:10", + "+11:15", + "+11:20", + "+11:25", + "+11:30", + "+11:35", + "+11:40", + "+11:45", + "+11:50", + "+11:55", + "+12:00", + "+12:05", + "+12:10", + "+12:15", + "+12:20", + "+12:25", + "+12:30", + "+12:35", + "+12:40", + "+12:45", + "+12:50", + "+12:55", + "+13:00", + "+13:05", + "+13:10", + "+13:15", + "+13:20", + "+13:25", + "+13:30", + "+13:35", + "+13:40", + "+13:45", + "+13:50", + "+13:55", + "+14:00" + ] + }, + { + "key": "time-set-gps-time-correct", + "label": "GPS Time Correct", + "type": "select", + "options": [ + "OFF", + "Auto" + ] + }, + { + "key": "section-remote-mic-key", + "label": "Remote MIC Key", + "type": "section", + "group": "Keys" + }, + { + "key": "remote-mic-key-during-rx-standby-a", + "label": "During RX/Standby [A]", + "type": "select", + "options": [ + "—", + "UP", + "DOWN", + "VOL UP", + "VOL DOWN", + "Monitor (Push)", + "Monitor (Hold)", + "CALL", + "MR (000 CH)", + "MR (001 CH)", + "VFO/MR", + "DR", + "FROM (DR)", + "TO (DR)", + "Home CH", + "BAND", + "SCAN", + "Temporary Skip", + "RX>CS", + "SPEECH", + "Voice TX" + ] + }, + { + "key": "remote-mic-key-during-rx-standby-b", + "label": "During RX/Standby [B]", + "type": "select", + "options": [ + "—", + "UP", + "DOWN", + "VOL UP", + "VOL DOWN", + "Monitor (Push)", + "Monitor (Hold)", + "CALL", + "MR (000 CH)", + "MR (001 CH)", + "VFO/MR", + "DR", + "FROM (DR)", + "TO (DR)", + "Home CH", + "BAND", + "SCAN", + "Temporary Skip", + "RX>CS", + "SPEECH", + "Voice TX" + ] + }, + { + "key": "remote-mic-key-during-rx-standby-up", + "label": "During RX/Standby [UP]", + "type": "select", + "options": [ + "—", + "UP", + "DOWN", + "VOL UP", + "VOL DOWN", + "Monitor (Push)", + "Monitor (Hold)", + "CALL", + "MR (000 CH)", + "MR (001 CH)", + "VFO/MR", + "DR", + "FROM (DR)", + "TO (DR)", + "Home CH", + "BAND", + "SCAN", + "Temporary Skip", + "RX>CS", + "SPEECH", + "Voice TX" + ] + }, + { + "key": "remote-mic-key-during-rx-standby-down", + "label": "During RX/Standby [DOWN]", + "type": "select", + "options": [ + "—", + "UP", + "DOWN", + "VOL UP", + "VOL DOWN", + "Monitor (Push)", + "Monitor (Hold)", + "CALL", + "MR (000 CH)", + "MR (001 CH)", + "VFO/MR", + "DR", + "FROM (DR)", + "TO (DR)", + "Home CH", + "BAND", + "SCAN", + "Temporary Skip", + "RX>CS", + "SPEECH", + "Voice TX" + ] + }, + { + "key": "remote-mic-key-during-tx-a", + "label": "During TX [A]", + "type": "select", + "options": [ + "—", + "VOL UP", + "VOL DOWN", + "Voice TX", + "T-CALL" + ] + }, + { + "key": "remote-mic-key-during-tx-b", + "label": "During TX [B]", + "type": "select", + "options": [ + "—", + "VOL UP", + "VOL DOWN", + "Voice TX", + "T-CALL" + ] + }, + { + "key": "remote-mic-key-during-tx-up", + "label": "During TX [UP]", + "type": "select", + "options": [ + "—", + "VOL UP", + "VOL DOWN", + "Voice TX", + "T-CALL" + ] + }, + { + "key": "remote-mic-key-during-tx-down", + "label": "During TX [DOWN]", + "type": "select", + "options": [ + "—", + "VOL UP", + "VOL DOWN", + "Voice TX", + "T-CALL" + ] + }, + { + "key": "section-vox", + "label": "VOX", + "type": "section", + "group": "Transmit & Receive" + }, + { + "key": "vox-vox", + "label": "VOX", + "type": "boolean" + }, + { + "key": "vox-vox-level", + "label": "VOX Level", + "type": "select", + "options": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10" + ] + }, + { + "key": "vox-vox-delay", + "label": "VOX Delay", + "type": "select", + "options": [ + "0.5sec", + "1.0sec", + "1.5sec", + "2.0sec", + "2.5sec", + "3.0sec" + ] + }, + { + "key": "vox-vox-time-out-timer", + "label": "VOX Time-Out Timer", + "type": "select", + "options": [ + "OFF", + "1min", + "2min", + "3min", + "4min", + "5min", + "10min", + "15min" + ] + }, + { + "key": "vox-vox-headset-select", + "label": "VOX Headset Select", + "type": "select", + "options": [ + "HS-95", + "Other" + ] + }, + { + "key": "section-ci-v", + "label": "CI-V", + "type": "section", + "group": "Connections" + }, + { + "key": "ci-v-ci-v-data-jack", + "label": "CI-V (DATA Jack)", + "type": "select", + "options": [ + "OFF", + "ON (Echo Back OFF)", + "ON (Echo Back ON)" + ] + }, + { + "key": "ci-v-ci-v-address", + "label": "CI-V Address", + "type": "select", + "options": [ + "01", + "02", + "03", + "04", + "05", + "06", + "07", + "08", + "09", + "0A", + "0B", + "0C", + "0D", + "0E", + "0F", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "1A", + "1B", + "1C", + "1D", + "1E", + "1F", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "2A", + "2B", + "2C", + "2D", + "2E", + "2F", + "30", + "31", + "32", + "33", + "34", + "35", + "36", + "37", + "38", + "39", + "3A", + "3B", + "3C", + "3D", + "3E", + "3F", + "40", + "41", + "42", + "43", + "44", + "45", + "46", + "47", + "48", + "49", + "4A", + "4B", + "4C", + "4D", + "4E", + "4F", + "50", + "51", + "52", + "53", + "54", + "55", + "56", + "57", + "58", + "59", + "5A", + "5B", + "5C", + "5D", + "5E", + "5F", + "60", + "61", + "62", + "63", + "64", + "65", + "66", + "67", + "68", + "69", + "6A", + "6B", + "6C", + "6D", + "6E", + "6F", + "70", + "71", + "72", + "73", + "74", + "75", + "76", + "77", + "78", + "79", + "7A", + "7B", + "7C", + "7D", + "7E", + "7F", + "80", + "81", + "82", + "83", + "84", + "85", + "86", + "87", + "88", + "89", + "8A", + "8B", + "8C", + "8D", + "8E", + "8F", + "90", + "91", + "92", + "93", + "94", + "95", + "96", + "97", + "98", + "99", + "9A", + "9B", + "9C", + "9D", + "9E", + "9F", + "A0", + "A1", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7", + "A8", + "A9", + "AA", + "AB", + "AC", + "AD", + "AE", + "AF", + "B0", + "B1", + "B2", + "B3", + "B4", + "B5", + "B6", + "B7", + "B8", + "B9", + "BA", + "BB", + "BC", + "BD", + "BE", + "BF", + "C0", + "C1", + "C2", + "C3", + "C4", + "C5", + "C6", + "C7", + "C8", + "C9", + "CA", + "CB", + "CC", + "CD", + "CE", + "CF", + "D0", + "D1", + "D2", + "D3", + "D4", + "D5", + "D6", + "D7", + "D8", + "D9", + "DA", + "DB", + "DC", + "DD", + "DE", + "DF" + ] + }, + { + "key": "ci-v-ci-v-baud-rate", + "label": "CI-V Baud Rate", + "type": "select", + "options": [ + "300bps", + "1200bps", + "4800bps", + "9600bps", + "19200bps", + "Auto" + ] + }, + { + "key": "ci-v-ci-v-transceive", + "label": "CI-V Transceive", + "type": "boolean" + }, + { + "key": "section-display", + "label": "Display", + "type": "section", + "group": "Display" + }, + { + "key": "display-backlight", + "label": "Backlight", + "type": "select", + "options": [ + "OFF", + "ON", + "Auto", + "Auto (DC IN:ON)" + ] + }, + { + "key": "display-backlight-timer", + "label": "Backlight Timer", + "type": "select", + "options": [ + "5sec", + "10sec" + ] + }, + { + "key": "display-lcd-dimmer", + "label": "LCD Dimmer", + "type": "select", + "options": [ + "Bright", + "Dark" + ] + }, + { + "key": "display-lcd-contrast", + "label": "LCD Contrast", + "type": "select", + "options": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16" + ] + }, + { + "key": "display-busy-led", + "label": "Busy LED", + "type": "boolean" + }, + { + "key": "display-scroll-speed", + "label": "Scroll Speed", + "type": "select", + "options": [ + "Slow", + "Fast" + ] + }, + { + "key": "display-opening-message", + "label": "Opening Message", + "type": "boolean" + }, + { + "key": "display-voltage-power-on", + "label": "Voltage Indication (Power ON)", + "type": "boolean" + }, + { + "key": "display-latitude-longitude-units", + "label": "Latitude/Longitude Units", + "type": "select", + "options": [ + "ddd°mm.mm'", + "ddd°mm'ss\"" + ] + }, + { + "key": "display-altitude-distance-units", + "label": "Altitude/Distance Units", + "type": "select", + "options": [ + "m", + "ft/ml" + ] + }, + { + "key": "display-speed-units", + "label": "Speed Units", + "type": "select", + "options": [ + "km/h", + "mph", + "knots" + ] + }, + { + "key": "display-temperature-units", + "label": "Temperature Units", + "type": "select", + "options": [ + "°C", + "°F" + ] + }, + { + "key": "display-barometric-units", + "label": "Barometric Units", + "type": "select", + "options": [ + "hPa", + "mb", + "mmHg", + "inHg" + ] + }, + { + "key": "display-rainfall-units", + "label": "Rainfall Units", + "type": "select", + "options": [ + "mm", + "inch" + ] + }, + { + "key": "display-wind-speed-units", + "label": "Wind Speed Units", + "type": "select", + "options": [ + "m/s", + "mph", + "knots" + ] + }, + { + "key": "display-display-language", + "label": "Display Language", + "type": "select", + "options": [ + "English", + "Japanese" + ] + }, + { + "key": "display-system-language", + "label": "System Language", + "type": "select", + "options": [ + "English", + "Japanese" + ] + }, + { + "key": "display-display-type", + "label": "Display Type", + "type": "select", + "options": [ + "Freq (Name OFF)", + "Freq", + "Name" + ] + }, + { + "key": "section-sounds", + "label": "Sounds", + "type": "section", + "group": "Sounds" + }, + { + "key": "sounds-volume-select", + "label": "Volume Select", + "type": "select", + "options": [ + "All", + "BC Radio Separate", + "Separate" + ] + }, + { + "key": "sounds-bc-radio-level", + "label": "BC Radio Level", + "type": "select", + "options": [ + "-5", + "-4", + "-3", + "-2", + "-1", + "0", + "+1", + "+2", + "+3", + "+4", + "+5" + ] + }, + { + "key": "sounds-earphone-mode", + "label": "Earphone Mode", + "type": "boolean" + }, + { + "key": "sounds-beep-level", + "label": "Beep Level", + "type": "select", + "options": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "key": "sounds-beep-vol-level-link", + "label": "Beep/Vol Level Link", + "type": "boolean" + }, + { + "key": "sounds-key-touch-beep", + "label": "Key-Touch Beep", + "type": "boolean" + }, + { + "key": "sounds-home-ch-beep", + "label": "Home CH Beep", + "type": "boolean" + }, + { + "key": "sounds-band-edge-beep", + "label": "Band Edge Beep", + "type": "boolean" + }, + { + "key": "sounds-scan-stop-beep", + "label": "Scan Stop Beep", + "type": "boolean" + }, + { + "key": "sounds-sub-band-mute", + "label": "Sub Band Mute", + "type": "select", + "options": [ + "OFF", + "Mute", + "Beep", + "Mute & Beep" + ] + }, + { + "key": "sounds-scope-af-output", + "label": "Scope AF Output", + "type": "boolean" + }, + { + "key": "section-speech", + "label": "SPEECH", + "type": "section", + "group": "Sounds" + }, + { + "key": "speech-rx-call-sign-speech", + "label": "RX Call Sign SPEECH", + "type": "select", + "options": [ + "OFF", + "ON (Kerchunk)", + "ON (All)" + ] + }, + { + "key": "speech-rx-cs-speech", + "label": "RX>CS SPEECH", + "type": "boolean" + }, + { + "key": "speech-dial-speech", + "label": "DIAL SPEECH", + "type": "boolean" + }, + { + "key": "speech-mode-speech", + "label": "MODE SPEECH", + "type": "boolean" + }, + { + "key": "speech-speech-language", + "label": "SPEECH Language", + "type": "select", + "options": [ + "English", + "Japanese" + ] + }, + { + "key": "speech-alphabet", + "label": "Alphabet", + "type": "select", + "options": [ + "Normal", + "Phonetic Code" + ] + }, + { + "key": "speech-speech-speed", + "label": "SPEECH Speed", + "type": "select", + "options": [ + "Slow", + "Fast" + ] + }, + { + "key": "speech-speech-level", + "label": "SPEECH Level", + "type": "select", + "options": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "key": "section-qso-recorder", + "label": "QSO Recorder", + "type": "section", + "group": "Sounds" + }, + { + "key": "qso-recorder-rec-mode", + "label": "REC Mode", + "type": "select", + "options": [ + "TX&RX", + "RX Only" + ] + }, + { + "key": "qso-recorder-rx-rec-condition", + "label": "RX REC Condition", + "type": "select", + "options": [ + "Always", + "Squelch Auto" + ] + }, + { + "key": "qso-recorder-file-split", + "label": "File Split", + "type": "boolean" + }, + { + "key": "qso-recorder-ptt-auto-rec", + "label": "PTT Auto REC", + "type": "boolean" + }, + { + "key": "qso-recorder-skip-time", + "label": "Player Skip Time", + "type": "select", + "options": [ + "3sec", + "5sec", + "10sec", + "30sec" + ] + }, + { + "key": "section-voice-recorder", + "label": "Voice Recorder", + "type": "section", + "group": "Sounds" + }, + { + "key": "voice-recorder-mic-gain", + "label": "MIC Gain", + "type": "select", + "options": [ + "1", + "2", + "3", + "4" + ] + }, + { + "key": "voice-recorder-skip-time", + "label": "Player Skip Time", + "type": "select", + "options": [ + "3sec", + "5sec", + "10sec", + "30sec" + ] + }, + { + "key": "section-voice-tx", + "label": "Voice TX", + "type": "section", + "group": "Sounds" + }, + { + "key": "voice-tx-repeat-time", + "label": "Repeat Time", + "type": "select", + "options": [ + "1sec", + "2sec", + "3sec", + "4sec", + "5sec", + "6sec", + "7sec", + "8sec", + "9sec", + "10sec", + "11sec", + "12sec", + "13sec", + "14sec", + "15sec" + ] + }, + { + "key": "voice-tx-tx-monitor", + "label": "TX Monitor", + "type": "boolean" + }, + { + "key": "section-bc-radio", + "label": "BC Radio", + "type": "section", + "group": "General" + }, + { + "key": "bc-radio-att-fm", + "label": "ATT(FM)", + "type": "select", + "options": [ + "OFF", + "30dB" + ] + }, + { + "key": "bc-radio-att-am", + "label": "ATT(AM)", + "type": "select", + "options": [ + "OFF", + "Auto", + "30dB", + "50dB" + ] + }, + { + "key": "bc-radio-auto-mute", + "label": "Auto Mute", + "type": "select", + "options": [ + "OFF", + "0sec", + "1sec", + "2sec", + "3sec", + "4sec", + "5sec", + "6sec", + "7sec", + "8sec", + "9sec", + "10sec" + ] + }, + { + "key": "bc-radio-fm-antenna", + "label": "FM Antenna", + "type": "select", + "options": [ + "External", + "Earphone" + ] + }, + { + "key": "bc-radio-power-save-bc-radio", + "label": "Power Save (BC Radio)", + "type": "boolean" + }, + { + "key": "section-qso-rx-history-log", + "label": "QSO/RX History Log", + "type": "section", + "group": "Digital" + }, + { + "key": "qso-rx-history-log-qso-log", + "label": "QSO Log", + "type": "boolean" + }, + { + "key": "qso-rx-history-log-rx-history-log", + "label": "RX History Log", + "type": "boolean" + }, + { + "key": "qso-rx-history-log-csv-format-separator-decimal", + "label": "CSV Separator/Decimal", + "type": "select", + "options": [ + "Sep [,] Dec [.]", + "Sep [;] Dec [.]", + "Sep [;] Dec [,]" + ] + }, + { + "key": "qso-rx-history-log-csv-format-date", + "label": "CSV Date", + "type": "select", + "options": [ + "yyyy/mm/dd", + "mm/dd/yyyy", + "dd/mm/yyyy" + ] + }, + { + "key": "section-import-export-csv-format", + "label": "Import/Export - CSV Format", + "type": "section", + "group": "General" + }, + { + "key": "import-export-csv-format-separator-decimal", + "label": "Separator/Decimal", + "type": "select", + "options": [ + "Sep [,] Dec [.]", + "Sep [;] Dec [.]", + "Sep [;] Dec [,]" + ] + }, + { + "key": "import-export-csv-format-date", + "label": "Date", + "type": "select", + "options": [ + "yyyy/mm/dd", + "mm/dd/yyyy", + "dd/mm/yyyy" + ] + }, + { + "key": "section-others", + "label": "Others", + "type": "section", + "group": "Transmit & Receive" + }, + { + "key": "others-rf-power-vhf", + "label": "RF Power (VHF)", + "type": "select", + "options": [ + "S-Low", + "Low1", + "Low2", + "Mid", + "High" + ] + }, + { + "key": "others-rf-power-uhf", + "label": "RF Power (UHF)", + "type": "select", + "options": [ + "S-Low", + "Low1", + "Low2", + "Mid", + "High" + ] + }, + { + "key": "others-tone-burst", + "label": "Tone Burst", + "type": "boolean" + }, + { + "key": "others-att-air", + "label": "ATT(AIR)", + "type": "boolean" + }, + { + "key": "others-heterodyne", + "label": "Heterodyne", + "type": "select", + "options": [ + "Normal", + "Reverse" + ] + }, + { + "key": "others-charging-power-on", + "label": "Charging (Power ON)", + "type": "boolean" + }, + { + "key": "section-scan", + "label": "Scan", + "type": "section", + "group": "Scan" + }, + { + "key": "scan-pause-timer-a", + "label": "Pause Timer (A band)", + "type": "select", + "options": [ + "2sec", + "4sec", + "6sec", + "8sec", + "10sec", + "12sec", + "14sec", + "16sec", + "18sec", + "20sec", + "Hold" + ] + }, + { + "key": "scan-resume-timer-a", + "label": "Resume Timer (A band)", + "type": "select", + "options": [ + "0sec", + "1sec", + "2sec", + "3sec", + "4sec", + "5sec", + "Hold" + ] + }, + { + "key": "scan-temporary-skip-timer-a", + "label": "Temporary Skip Timer (A band)", + "type": "select", + "options": [ + "5min", + "10min", + "15min" + ] + }, + { + "key": "scan-program-skip-scan-a", + "label": "Program Skip Scan (A band)", + "type": "boolean" + }, + { + "key": "scan-pause-timer-b", + "label": "Pause Timer (B band)", + "type": "select", + "options": [ + "2sec", + "4sec", + "6sec", + "8sec", + "10sec", + "12sec", + "14sec", + "16sec", + "18sec", + "20sec", + "Hold" + ] + }, + { + "key": "scan-resume-timer-b", + "label": "Resume Timer (B band)", + "type": "select", + "options": [ + "0sec", + "1sec", + "2sec", + "3sec", + "4sec", + "5sec", + "Hold" + ] + }, + { + "key": "scan-temporary-skip-timer-b", + "label": "Temporary Skip Timer (B band)", + "type": "select", + "options": [ + "5min", + "10min", + "15min" + ] + }, + { + "key": "scan-program-skip-scan-b", + "label": "Program Skip Scan (B band)", + "type": "boolean" + }, + { + "key": "section-scan-bank-link-a", + "label": "Scan > Bank Link (A band)", + "type": "section", + "group": "Scan" + }, + { + "key": "scan-bank-link-a-a", + "label": "Bank A", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-b", + "label": "Bank B", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-c", + "label": "Bank C", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-d", + "label": "Bank D", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-e", + "label": "Bank E", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-f", + "label": "Bank F", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-g", + "label": "Bank G", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-h", + "label": "Bank H", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-i", + "label": "Bank I", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-j", + "label": "Bank J", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-k", + "label": "Bank K", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-l", + "label": "Bank L", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-m", + "label": "Bank M", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-n", + "label": "Bank N", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-o", + "label": "Bank O", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-p", + "label": "Bank P", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-q", + "label": "Bank Q", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-r", + "label": "Bank R", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-s", + "label": "Bank S", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-t", + "label": "Bank T", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-u", + "label": "Bank U", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-v", + "label": "Bank V", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-w", + "label": "Bank W", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-x", + "label": "Bank X", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-y", + "label": "Bank Y", + "type": "boolean" + }, + { + "key": "scan-bank-link-a-z", + "label": "Bank Z", + "type": "boolean" + }, + { + "key": "section-scan-bank-link-b", + "label": "Scan > Bank Link (B band)", + "type": "section", + "group": "Scan" + }, + { + "key": "scan-bank-link-b-a", + "label": "Bank A", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-b", + "label": "Bank B", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-c", + "label": "Bank C", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-d", + "label": "Bank D", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-e", + "label": "Bank E", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-f", + "label": "Bank F", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-g", + "label": "Bank G", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-h", + "label": "Bank H", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-i", + "label": "Bank I", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-j", + "label": "Bank J", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-k", + "label": "Bank K", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-l", + "label": "Bank L", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-m", + "label": "Bank M", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-n", + "label": "Bank N", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-o", + "label": "Bank O", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-p", + "label": "Bank P", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-q", + "label": "Bank Q", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-r", + "label": "Bank R", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-s", + "label": "Bank S", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-t", + "label": "Bank T", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-u", + "label": "Bank U", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-v", + "label": "Bank V", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-w", + "label": "Bank W", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-x", + "label": "Bank X", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-y", + "label": "Bank Y", + "type": "boolean" + }, + { + "key": "scan-bank-link-b-z", + "label": "Bank Z", + "type": "boolean" + }, + { + "key": "section-wx", + "label": "Weather", + "type": "section", + "group": "Transmit & Receive" + }, + { + "key": "wx-weather-alert-a", + "label": "Weather Alert (A band)", + "type": "boolean" + }, + { + "key": "wx-wx-ch-a", + "label": "WX CH (A band)", + "type": "select", + "options": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10" + ] + }, + { + "key": "wx-weather-alert-b", + "label": "Weather Alert (B band)", + "type": "boolean" + }, + { + "key": "wx-wx-ch-b", + "label": "WX CH (B band)", + "type": "select", + "options": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10" + ] + }, + { + "key": "section-gps", + "label": "GPS", + "type": "section", + "group": "GPS" + }, + { + "key": "gps-gps-select", + "label": "GPS Select", + "type": "select", + "options": [ + "OFF", + "Internal GPS", + "External GPS", + "Manual" + ] + }, + { + "key": "gps-power-save-internal-gps", + "label": "Power Save (Internal GPS)", + "type": "select", + "options": [ + "OFF", + "1min", + "2min", + "4min", + "8min", + "Auto" + ] + }, + { + "key": "gps-compass-direction", + "label": "Compass Direction", + "type": "select", + "options": [ + "Heading Up", + "North Up", + "South Up" + ] + }, + { + "key": "gps-gps-indicator", + "label": "GPS Indicator", + "type": "boolean" + }, + { + "key": "gps-gps-out-to-data-jack", + "label": "GPS Out (To DATA Jack)", + "type": "boolean" + }, + { + "key": "section-gps-manual-position", + "label": "GPS Manual Position", + "type": "section", + "group": "GPS" + }, + { + "key": "gps-manual-position-latitude", + "label": "Latitude", + "type": "text", + "max_length": 11, + "placeholder": "40.585333 (negative = S; blank = leave as is)" + }, + { + "key": "gps-manual-position-longitude", + "label": "Longitude", + "type": "text", + "max_length": 12, + "placeholder": "-105.076000 (negative = W; blank = leave as is)" + }, + { + "key": "gps-manual-position-altitude-m", + "label": "Altitude (m)", + "type": "text", + "max_length": 8, + "placeholder": "blank = leave as the radio has it" + }, + { + "key": "section-gps-alarm", + "label": "GPS Alarm", + "type": "section", + "group": "GPS" + }, + { + "key": "gps-alarm-alarm-area-group", + "label": "Alarm Area (Group), thousandths of a minute", + "type": "integer", + "min": 80, + "max": 59990 + }, + { + "key": "gps-alarm-alarm-area-rx-memory", + "label": "Alarm Area (RX/Memory)", + "type": "select", + "options": [ + "Limited", + "Extended", + "Both" + ] + }, + { + "key": "gps-alarm-alarm-select", + "label": "Alarm Select", + "type": "select", + "options": [ + "OFF", + "RX", + "Group", + "Memory" + ] + }, + { + "key": "gps-alarm-alarm-group", + "label": "Alarm Group", + "type": "select", + "options": [ + "—", + "(No Group)", + "Group A", + "Group B", + "Group C", + "Group D", + "Group E", + "Group F", + "Group G", + "Group H", + "Group I", + "Group J", + "Group K", + "Group L", + "Group M", + "Group N", + "Group O", + "Group P", + "Group Q", + "Group R", + "Group S", + "Group T", + "Group U", + "Group V", + "Group W", + "Group X", + "Group Y", + "Group Z" + ] + }, + { + "key": "gps-alarm-alarm-memory", + "label": "Alarm Memory (GPS memory No.)", + "type": "integer", + "min": 0, + "max": 199 + }, + { + "key": "section-gps-tx", + "label": "GPS TX", + "type": "section", + "group": "GPS" + }, + { + "key": "gps-tx-gps-tx-mode", + "label": "GPS TX Mode", + "type": "select", + "options": [ + "OFF", + "D-PRS (DV-A)", + "NMEA (DV-G)" + ], + "group": "APRS" + }, + { + "key": "gps-tx-gps-auto-tx-timer", + "label": "GPS Auto TX Timer", + "type": "select", + "options": [ + "OFF", + "5sec", + "10sec", + "30sec", + "1min", + "3min", + "5min", + "10min", + "30min" + ] + }, + { + "key": "section-d-prs", + "label": "D-PRS (APRS)", + "type": "section", + "group": "APRS" + }, + { + "key": "d-prs-unproto-address", + "label": "Unproto Address", + "type": "text", + "max_length": 56 + }, + { + "key": "d-prs-position-symbol-ch", + "label": "Symbol CH", + "type": "select", + "options": [ + "No.1", + "No.2", + "No.3", + "No.4" + ] + }, + { + "key": "d-prs-position-symbol-no-1", + "label": "Symbol (No.1)", + "type": "text", + "max_length": 2, + "placeholder": "/> (table, symbol)" + }, + { + "key": "d-prs-position-symbol-no-2", + "label": "Symbol (No.2)", + "type": "text", + "max_length": 2, + "placeholder": "/> (table, symbol)" + }, + { + "key": "d-prs-position-symbol-no-3", + "label": "Symbol (No.3)", + "type": "text", + "max_length": 2, + "placeholder": "/> (table, symbol)" + }, + { + "key": "d-prs-position-symbol-no-4", + "label": "Symbol (No.4)", + "type": "text", + "max_length": 2, + "placeholder": "/> (table, symbol)" + }, + { + "key": "d-prs-position-ssid", + "label": "SSID", + "type": "select", + "options": [ + "—", + "(-0)", + "-1", + "-2", + "-3", + "-4", + "-5", + "-6", + "-7", + "-8", + "-9", + "-10", + "-11", + "-12", + "-13", + "-14", + "-15", + "-A", + "-B", + "-C", + "-D", + "-E", + "-F", + "-G", + "-H", + "-I", + "-J", + "-K", + "-L", + "-M", + "-N", + "-O", + "-P", + "-Q", + "-R", + "-S", + "-T", + "-U", + "-V", + "-W", + "-X", + "-Y", + "-Z" + ] + }, + { + "key": "d-prs-position-comment", + "label": "Comment", + "type": "text", + "max_length": 43 + }, + { + "key": "d-prs-position-time-stamp", + "label": "Time Stamp", + "type": "select", + "options": [ + "OFF", + "DHM", + "HMS" + ] + }, + { + "key": "d-prs-position-altitude", + "label": "Altitude", + "type": "boolean" + }, + { + "key": "d-prs-position-data-extension", + "label": "Data Extension", + "type": "select", + "options": [ + "OFF", + "Course/Speed" + ] + }, + { + "key": "section-gps-tx-nmea", + "label": "GPS TX (NMEA)", + "type": "section", + "group": "GPS" + }, + { + "key": "gps-tx-nmea-gps-message", + "label": "GPS Message", + "type": "text", + "max_length": 20 + }, + { + "key": "section-gps-tx-nmea-sentences", + "label": "GPS TX (NMEA) sentences — at most 4", + "type": "section", + "group": "GPS" + }, + { + "key": "gps-tx-nmea-sentence-rmc", + "label": "RMC", + "type": "boolean" + }, + { + "key": "gps-tx-nmea-sentence-gga", + "label": "GGA", + "type": "boolean" + }, + { + "key": "gps-tx-nmea-sentence-gll", + "label": "GLL", + "type": "boolean" + }, + { + "key": "gps-tx-nmea-sentence-gsa", + "label": "GSA", + "type": "boolean" + }, + { + "key": "gps-tx-nmea-sentence-vtg", + "label": "VTG", + "type": "boolean" + }, + { + "key": "gps-tx-nmea-sentence-gsv", + "label": "GSV", + "type": "boolean" + }, + { + "key": "section-gps-logger", + "label": "GPS Logger", + "type": "section", + "group": "GPS" + }, + { + "key": "gps-logger-gps-logger", + "label": "GPS Logger", + "type": "boolean" + }, + { + "key": "gps-logger-record-interval", + "label": "Record Interval", + "type": "select", + "options": [ + "1sec", + "5sec", + "10sec", + "30sec", + "60sec" + ] + }, + { + "key": "section-gps-logger-record-sentences", + "label": "GPS Logger record sentences — at least 1", + "type": "section", + "group": "GPS" + }, + { + "key": "gps-logger-record-sentence-rmc", + "label": "RMC", + "type": "boolean" + }, + { + "key": "gps-logger-record-sentence-gga", + "label": "GGA", + "type": "boolean" + }, + { + "key": "gps-logger-record-sentence-vtg", + "label": "VTG", + "type": "boolean" + }, + { + "key": "gps-logger-record-sentence-gsa", + "label": "GSA", + "type": "boolean" + }, + { + "key": "section-tone-control", + "label": "Tone Control", + "type": "section", + "group": "Sounds" + }, + { + "key": "tone-control-rx-bass", + "label": "RX Bass", + "type": "select", + "options": [ + "Cut", + "Normal", + "Boost" + ] + }, + { + "key": "tone-control-rx-treble", + "label": "RX Treble", + "type": "select", + "options": [ + "Cut", + "Normal", + "Boost" + ] + }, + { + "key": "tone-control-rx-bass-boost", + "label": "RX Bass Boost", + "type": "boolean" + }, + { + "key": "tone-control-tx-bass", + "label": "TX Bass", + "type": "select", + "options": [ + "Cut", + "Normal", + "Boost" + ] + }, + { + "key": "tone-control-tx-treble", + "label": "TX Treble", + "type": "select", + "options": [ + "Cut", + "Normal", + "Boost" + ] + }, + { + "key": "section-dv-set-mode", + "label": "DV Set Mode", + "type": "section", + "group": "Digital" + }, + { + "key": "dv-set-mode-auto-reply", + "label": "Auto Reply", + "type": "select", + "options": [ + "OFF", + "ON", + "Voice", + "Position" + ] + }, + { + "key": "dv-set-mode-dv-data-tx", + "label": "DV Data TX", + "type": "select", + "options": [ + "PTT", + "Auto" + ] + }, + { + "key": "dv-set-mode-dv-fast-data", + "label": "DV Fast Data", + "type": "boolean" + }, + { + "key": "dv-set-mode-dv-fast-data-gps-data-speed", + "label": "DV Fast Data GPS Data Speed", + "type": "select", + "options": [ + "Slow", + "Fast" + ] + }, + { + "key": "dv-set-mode-dv-fast-data-tx-delay-ptt", + "label": "DV Fast Data TX Delay (PTT)", + "type": "select", + "options": [ + "OFF", + "1sec", + "2sec", + "3sec", + "4sec", + "5sec", + "6sec", + "7sec", + "8sec", + "9sec", + "10sec" + ] + }, + { + "key": "dv-set-mode-digital-monitor", + "label": "Digital Monitor", + "type": "select", + "options": [ + "Auto", + "Digital", + "Analog" + ] + }, + { + "key": "dv-set-mode-digital-repeater-set", + "label": "Digital Repeater Set", + "type": "boolean" + }, + { + "key": "dv-set-mode-rx-call-sign-auto-write", + "label": "RX Call Sign Auto Write", + "type": "select", + "options": [ + "OFF", + "Auto" + ] + }, + { + "key": "dv-set-mode-repeater-call-sign-auto-write", + "label": "Repeater Call Sign Auto Write", + "type": "select", + "options": [ + "OFF", + "Auto" + ] + }, + { + "key": "dv-set-mode-dv-mode-auto-detect", + "label": "DV Mode Auto Detect", + "type": "boolean" + }, + { + "key": "dv-set-mode-rx-record-rpt", + "label": "RX Record (RPT)", + "type": "select", + "options": [ + "ALL", + "Latest Only" + ] + }, + { + "key": "section-display-dv", + "label": "Display (DV)", + "type": "section", + "group": "Display" + }, + { + "key": "display-dv-rx-call-sign-display", + "label": "RX Call Sign Display", + "type": "select", + "options": [ + "OFF", + "Auto", + "Auto (RX Hold)" + ] + }, + { + "key": "display-dv-rx-message-display", + "label": "RX Message Display", + "type": "select", + "options": [ + "OFF", + "Auto" + ] + }, + { + "key": "display-dv-reply-position-display", + "label": "Reply Position Display", + "type": "boolean" + }, + { + "key": "display-dv-dv-rx-backlight", + "label": "DV RX Backlight", + "type": "boolean" + }, + { + "key": "display-dv-tx-call-sign-display", + "label": "TX Call Sign Display", + "type": "select", + "options": [ + "OFF", + "Your Call Sign", + "My Call Sign" + ] + }, + { + "key": "display-dv-display-type-dr", + "label": "Display Type (DR)", + "type": "select", + "options": [ + "Normal", + "Large" + ] + }, + { + "key": "display-dv-display-type-rx-history", + "label": "Display Type (RX History)", + "type": "select", + "options": [ + "Call Sign", + "Name" + ] + }, + { + "key": "section-sounds-dv", + "label": "Sounds (DV)", + "type": "section", + "group": "Sounds" + }, + { + "key": "sounds-dv-standby-beep", + "label": "Standby Beep", + "type": "select", + "options": [ + "OFF", + "ON", + "ON (to me: High Tone)", + "ON (to me: Alarm/High Tone)" + ] + }, + { + "key": "sounds-dv-emr-af-level", + "label": "EMR AF Level", + "type": "select", + "options": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30", + "31", + "32", + "33", + "34", + "35", + "36", + "37", + "38", + "39" + ] + }, + { + "key": "section-my-station", + "label": "My Station", + "type": "section", + "group": "Digital" + }, + { + "key": "my-station-my-call-sign-m01", + "label": "My Call Sign M01", + "type": "text", + "max_length": 8 + }, + { + "key": "my-station-my-call-sign-m01-note", + "label": "My Call Sign M01 note", + "type": "text", + "max_length": 4 + }, + { + "key": "my-station-my-call-sign-m02", + "label": "My Call Sign M02", + "type": "text", + "max_length": 8 + }, + { + "key": "my-station-my-call-sign-m02-note", + "label": "My Call Sign M02 note", + "type": "text", + "max_length": 4 + }, + { + "key": "my-station-my-call-sign-m03", + "label": "My Call Sign M03", + "type": "text", + "max_length": 8 + }, + { + "key": "my-station-my-call-sign-m03-note", + "label": "My Call Sign M03 note", + "type": "text", + "max_length": 4 + }, + { + "key": "my-station-my-call-sign-m04", + "label": "My Call Sign M04", + "type": "text", + "max_length": 8 + }, + { + "key": "my-station-my-call-sign-m04-note", + "label": "My Call Sign M04 note", + "type": "text", + "max_length": 4 + }, + { + "key": "my-station-my-call-sign-m05", + "label": "My Call Sign M05", + "type": "text", + "max_length": 8 + }, + { + "key": "my-station-my-call-sign-m05-note", + "label": "My Call Sign M05 note", + "type": "text", + "max_length": 4 + }, + { + "key": "my-station-my-call-sign-m06", + "label": "My Call Sign M06", + "type": "text", + "max_length": 8 + }, + { + "key": "my-station-my-call-sign-m06-note", + "label": "My Call Sign M06 note", + "type": "text", + "max_length": 4 + }, + { + "key": "my-station-my-call-sign-in-use", + "label": "My Call Sign in use", + "type": "select", + "options": [ + "M01", + "M02", + "M03", + "M04", + "M05", + "M06" + ] + }, + { + "key": "my-station-tx-message-1", + "label": "TX Message 1", + "type": "text", + "max_length": 20 + }, + { + "key": "my-station-tx-message-2", + "label": "TX Message 2", + "type": "text", + "max_length": 20 + }, + { + "key": "my-station-tx-message-3", + "label": "TX Message 3", + "type": "text", + "max_length": 20 + }, + { + "key": "my-station-tx-message-4", + "label": "TX Message 4", + "type": "text", + "max_length": 20 + }, + { + "key": "my-station-tx-message-5", + "label": "TX Message 5", + "type": "text", + "max_length": 20 + }, + { + "key": "my-station-tx-message-in-use", + "label": "TX Message in use", + "type": "select", + "options": [ + "1", + "2", + "3", + "4", + "5" + ] + }, + { + "key": "my-station-tx-message", + "label": "TX Message", + "type": "boolean" + }, + { + "key": "section-dtmf", + "label": "DTMF", + "type": "section", + "group": "DTMF & Signaling" + }, + { + "key": "dtmf-dtmf-speed", + "label": "DTMF Speed", + "type": "select", + "options": [ + "100ms", + "200ms", + "300ms", + "500ms" + ] + } +] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5699d18..ce1fd72 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -235,6 +235,7 @@ pub fn run() { commands::program::read_thd75_settings_from_card, commands::program::read_ftm300_settings_from_backup, commands::program::read_ftm510_settings_from_backup, + commands::program::read_id51_settings_from_card, commands::codeplugs::get_codeplug_memory_group, commands::codeplugs::set_codeplug_memory_group, commands::program::find_memory_cards, diff --git a/src-tauri/src/radios/driver.rs b/src-tauri/src/radios/driver.rs index b444e91..6d5810c 100644 --- a/src-tauri/src/radios/driver.rs +++ b/src-tauri/src/radios/driver.rs @@ -138,6 +138,18 @@ pub(crate) trait RadioDriver: Send + Sync { &["FM", "NFM"] } + /// Why this radio cannot store a memory at `rx_mhz`, or `None` when it can. + /// + /// For a frequency that is inside the receiver's coverage but on none of the + /// channel grids the encoder writes (the ID-51's 5 and 6.25 kHz, which leave + /// out 8.33 kHz airband). `channel_fit` excludes such a channel with this + /// reason, so it is listed like any other channel the radio cannot hold + /// instead of failing the whole build when the encoder meets it (#50 + /// review). The default stores everything the bands allow. + fn unstorable_frequency(&self, _rx_mhz: f64) -> Option { + None + } + // --- Capability accessors ------------------------------------------------- // Default `None`; a driver overrides the ones it supports. The derived // `DriverCapabilities` (below) reads these, so a capability is advertised to @@ -661,6 +673,19 @@ pub(crate) trait CardSettingsWriter { target: &str, settings: &serde_json::Map, ) -> Result; + + /// [`export_settings`](Self::export_settings) plus a note for every setting + /// the driver could not write (a refused combination, an unparseable + /// position, a key it does not know). The default has none to give; a + /// driver whose encoder reports them overrides this so they reach the + /// operator instead of being dropped (#50 review). + fn export_settings_noting( + &self, + target: &str, + settings: &serde_json::Map, + ) -> Result<(usize, Vec), String> { + self.export_settings(target, settings).map(|n| (n, Vec::new())) + } } /// File-format exporters (CHIRP-style CSV, AnyTone dual-CSV bundle, …). The @@ -672,6 +697,13 @@ pub(crate) trait CodeplugExporter { /// Write the codeplug file(s) rooted at `path`, returning channels written. fn export(&self, path: &str, req: &ExportRequest) -> Result; + /// [`export`](Self::export) plus a note for every profile setting the + /// driver could not write. What the export command calls; the default has + /// no notes to give (see [`CardSettingsWriter::export_settings_noting`]). + fn export_noting(&self, path: &str, req: &ExportRequest) -> Result<(usize, Vec), String> { + self.export(path, req).map(|n| (n, Vec::new())) + } + /// The format's settings-only write, when its file can take the /// profile's settings on their own (the card radios). Declared, not /// inferred, like every other capability: a format either implements diff --git a/src-tauri/src/radios/icom_card.rs b/src-tauri/src/radios/icom_card.rs new file mode 100644 index 0000000..b4bd244 --- /dev/null +++ b/src-tauri/src/radios/icom_card.rs @@ -0,0 +1,128 @@ +//! The `.icf` card-file rules the Icom card radios share (ID-52 #38, ID-51 +//! Plus #50): which file in the radio's `Setting` folder is the template, what +//! a new file is called, and how a write patches without touching anything the +//! operator saved. +//! +//! The radio names its own saves `SetYYYYMMDD_NN.icf` and lists them on its +//! Load Setting screen, so a write into the folder creates the next free name +//! from the newest usable save there; a write to an existing file patches it in +//! place and keeps the untouched original once as `.orig`. + +use std::path::{Path, PathBuf}; + +use crate::radios::icom_id52::icf::IcfFile; + +/// One radio's card folder: how it is named to the operator, and which files in +/// it this driver can patch. +pub(crate) struct IcfCard { + /// "ID-52", "ID-51 Plus". + pub radio: &'static str, + /// The folder on the card, for messages: "ID-52/Setting/". + pub folder: &'static str, + /// The radio's own menu path for saving one: "SET > SD Card > Save Setting". + pub save_menu: &'static str, + /// Whether a parsed file is one this driver's offsets describe. + pub is_ours: fn(&IcfFile) -> bool, +} + +impl IcfCard { + /// The template a new file is built from: the newest settings file in + /// `dir` that this driver can patch. The radio's names sort by date, so the + /// files are tried newest first and the first usable one wins — one parse in + /// the usual case, rather than every file on the card. + pub(crate) fn newest_settings_file(&self, dir: &Path) -> Result { + let mut names: Vec = std::fs::read_dir(dir) + .map(|entries| { + entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("icf"))) + .collect() + }) + .unwrap_or_default(); + names.sort(); + names + .iter() + .rev() + .find(|p| { + std::fs::read_to_string(p).is_ok_and(|t| IcfFile::parse(&t).is_ok_and(|f| (self.is_ours)(&f))) + }) + .map(|p| p.to_string_lossy().into_owned()) + .ok_or_else(|| { + format!( + "No {} settings file to work from in {}. On the radio, save one with {}, \ + then try again.", + self.radio, + dir.display(), + self.save_menu + ) + }) + } + + /// The `.icf` file rule, shared by the export and the settings-only write. + /// + /// A target that does not exist yet is a NEW file on the card, and the + /// template is the newest usable settings file beside it. A target that does + /// exist is the operator pointing at one file and meaning it: it is patched + /// in place, with the untouched original kept once as `.orig` (written + /// once and never overwritten, so a second export cannot clobber the only + /// pristine copy). + pub(crate) fn patch_icf( + &self, + path: &str, + patch: impl FnOnce(&mut IcfFile) -> Result, + ) -> Result { + let template = if Path::new(path).is_file() { + path.to_string() + } else { + let dir = Path::new(path).parent().ok_or("There is no folder to write into.")?; + self.newest_settings_file(dir)? + }; + let text = std::fs::read_to_string(&template).map_err(|e| { + format!( + "Could not read {template}: {e}. Pick a settings file the radio saved for itself \ + with {} — they live in {}.", + self.save_menu, self.folder + ) + })?; + let mut icf = IcfFile::parse(&text)?; + let out = patch(&mut icf)?; + // Only when overwriting the operator's own file: a NEW file leaves + // theirs untouched by construction. + if template == path { + let orig = format!("{path}.orig"); + if !Path::new(&orig).exists() { + std::fs::write(&orig, &text) + .map_err(|e| format!("Could not save the original as {orig}: {e}"))?; + } + } + std::fs::write(path, icf.render()).map_err(|e| format!("Could not write {path}: {e}"))?; + Ok(out) + } + + /// What the exporter writes for a picked target: a folder means "make me a + /// new file in here"; anything else is written where it points. + pub(crate) fn resolve_target(&self, path: &str) -> Result { + let dir = Path::new(path); + if !dir.is_dir() { + return Ok(path.to_string()); + } + // Prove there is something to patch before naming the output. + self.newest_settings_file(dir)?; + Ok(next_settings_file(dir).to_string_lossy().into_owned()) + } +} + +/// The radios' own naming scheme, `SetYYYYMMDD_NN.icf`, with today's date and +/// the first free number. 14 characters before the extension: inside both the +/// ID-52's picker limit (23) and the ID-51's (16). +pub(crate) fn next_settings_file(dir: &Path) -> PathBuf { + let today = chrono::Local::now().format("%Y%m%d").to_string(); + for n in 1..=99 { + let candidate = dir.join(format!("Set{today}_{n:02}.icf")); + if !candidate.exists() { + return candidate; + } + } + dir.join(format!("Set{today}_99.icf")) +} diff --git a/src-tauri/src/radios/icom_id51/dev_export.rs b/src-tauri/src/radios/icom_id51/dev_export.rs new file mode 100644 index 0000000..e25e2c1 --- /dev/null +++ b/src-tauri/src/radios/icom_id51/dev_export.rs @@ -0,0 +1,90 @@ +//! THROWAWAY (#50, step 4): run the app's own export pipeline against the dev +//! database and write a new `.icf` from a real codeplug into a folder holding a +//! copy of the radio's own file — the folder → new `SetYYYYMMDD_NN.icf` path the +//! card action takes. +//! +//! A harness, not a unit test: it needs Tim's dev SQLite DB and his radio's +//! file, neither of which is in the repo. +//! +//! ```sh +//! CPM_DEV_DB="$HOME/Library/Application Support/com.ww8l.codeplugmagic.dev/codeplug_manager.sqlite3" \ +//! CPM_CODEPLUG=3 cargo test --lib icom_id51::dev_export -- --ignored --nocapture +//! ``` + +use crate::commands::export::{exclusion_reason, expand_for_export, resolve_codeplug_groups, ExpandedChannel}; +use crate::radios::driver::ExportRequest; +use crate::radios::icom_id52::icf::IcfFile; + +use super::layout::{BANK_NAMES, BANK_TABLE, CHANNEL_COUNT, EMPTY_BITMAP, REC_LEN}; + +#[tokio::test] +#[ignore = "needs the dev database and scratchpad/icom_id51/id51_01_base.icf"] +async fn a_real_codeplug_exports_through_the_app_pipeline() { + let db = std::env::var("CPM_DEV_DB").expect("CPM_DEV_DB"); + let codeplug_id: i64 = std::env::var("CPM_CODEPLUG").expect("CPM_CODEPLUG").parse().unwrap(); + let pool = sqlx::SqlitePool::connect(&format!("sqlite:file:{db}?mode=ro")).await.expect("open dev db"); + + let model = crate::seed::test_model("ID-51 Plus"); + let groups = resolve_codeplug_groups(&pool, codeplug_id).await.expect("groups"); + let mut seen = std::collections::HashSet::new(); + let channels: Vec<_> = groups + .iter() + .flat_map(|g| g.channels.iter().cloned()) + .filter(|c| seen.insert(c.id)) + .collect(); + let expanded = expand_for_export(&pool, channels).await.expect("expand"); + let mut excluded = Vec::new(); + let included: Vec<&ExpandedChannel> = expanded + .iter() + .filter(|ec| match exclusion_reason(&ec.channel, &model) { + Some(why) => { + excluded.push(format!(" {:>10.4} {}", ec.channel.rx_freq, why)); + false + } + None => true, + }) + .collect(); + println!("{} channels in, {} included, {} excluded", expanded.len(), included.len(), excluded.len()); + for line in &excluded { + println!("{line}"); + } + + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let template = root.join("scratchpad/icom_id51/id51_01_base.icf"); + let dir = std::env::temp_dir().join("id51_dev_export/ID-51/Setting"); + let _ = std::fs::remove_dir_all(dir.parent().unwrap().parent().unwrap()); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::copy(&template, dir.join("Set20261104_01.icf")).expect("copy template"); + + let exporter = crate::radios::registry::exporter_for_format("icom_id51_icf").expect("exporter"); + let target = exporter.resolve_target(dir.to_str().unwrap()).expect("resolve"); + println!("target {target}"); + let n = exporter + .export( + &target, + &ExportRequest { channels: &included, groups: &groups, model: &model, profile_settings: None, memory_group: &[] }, + ) + .expect("export"); + + let out = IcfFile::parse(&std::fs::read_to_string(&target).unwrap()).expect("our file parses and its #CD verifies"); + super::check_is_an_id51_file(&out).unwrap(); + let before = IcfFile::parse(&std::fs::read_to_string(&template).unwrap()).unwrap(); + let (o, b) = (out.image(), before.image()); + let used = (0..CHANNEL_COUNT).filter(|&s| o[EMPTY_BITMAP + s / 8] & (1 << (s % 8)) == 0).count(); + println!("wrote {n}; {used} memories marked used"); + for bank in 0..26 { + let name = String::from_utf8_lossy(&o[BANK_NAMES + 16 * bank..BANK_NAMES + 16 * bank + 16]).trim_end().to_string(); + if !name.is_empty() { + let members = (0..CHANNEL_COUNT).filter(|&s| o[BANK_TABLE + 2 * s] & 0x1F == bank as u8).count(); + println!(" bank {} {name:?}: {members}", (b'A' + bank as u8) as char); + } + } + assert_eq!((n, used), (included.len(), included.len())); + // Everything outside the memory tables is the radio's own file. + let tables = |i: usize| i < CHANNEL_COUNT * REC_LEN || (EMPTY_BITMAP..BANK_NAMES + 26 * 16).contains(&i); + let strays: Vec = (0..o.len()).filter(|&i| !tables(i) && o[i] != b[i]).collect(); + assert!(strays.is_empty(), "bytes outside the memory tables moved: {strays:x?}"); + // The radio's own file was not touched. + assert_eq!(std::fs::read(dir.join("Set20261104_01.icf")).unwrap(), std::fs::read(&template).unwrap()); + println!("nothing outside the memory tables moved; the radio's own file is untouched"); +} diff --git a/src-tauri/src/radios/icom_id51/id51_settings_table.rs b/src-tauri/src/radios/icom_id51/id51_settings_table.rs new file mode 100644 index 0000000..5d069d1 --- /dev/null +++ b/src-tauri/src/radios/icom_id51/id51_settings_table.rs @@ -0,0 +1,247 @@ +// GENERATED by scratchpad/icom_id51/gen_table.py from the CS-51PLUS +// measurements in scratchpad/icom_id51/MEASURED.md. Every address and every +// stored value was measured by moving one control in CS-51PLUS and diffing its +// saved file. Do not hand-edit; regenerate. +pub(crate) const ID51_SETTINGS_FIELDS: &[SF] = &[ + SF { key: "set-mode-auto-power-off", byte: 0x1AA82, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "30min"), (2, "60min"), (3, "90min"), (4, "120min")] } }, + SF { key: "set-mode-power-save", byte: 0x1AA89, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Auto (Short)"), (2, "Auto (Middle)"), (3, "Auto (Long)")] } }, + SF { key: "set-mode-monitor", byte: 0x1AA8B, kind: SK::Enum { width: 1, labels: &[(0, "Push"), (1, "Hold")] } }, + SF { key: "set-mode-dial-speed-up", byte: 0x1AA8C, kind: SK::Bool }, + SF { key: "set-mode-auto-repeater", byte: 0x1AA84, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "ON (DUP)"), (2, "ON (DUP,TONE)")] } }, + SF { key: "set-mode-key-lock", byte: 0x1AA8E, kind: SK::Enum { width: 1, labels: &[(0, "Normal"), (1, "No SQL"), (2, "No VOL"), (3, "ALL")] } }, + SF { key: "set-mode-ptt-lock", byte: 0x1AA85, kind: SK::Bool }, + SF { key: "set-mode-busy-lockout", byte: 0x1AA86, kind: SK::Bool }, + SF { key: "set-mode-time-out-timer", byte: 0x1AA87, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "1min"), (2, "3min"), (3, "5min"), (4, "10min"), (5, "15min"), (6, "30min")] } }, + SF { key: "set-mode-mic-gain-internal", byte: 0x1AA90, kind: SK::Enum { width: 1, labels: &[(0, "1"), (1, "2"), (2, "3"), (3, "4")] } }, + SF { key: "set-mode-mic-gain-external", byte: 0x1AA91, kind: SK::Enum { width: 1, labels: &[(0, "1"), (1, "2"), (2, "3"), (3, "4")] } }, + SF { key: "set-mode-data-speed", byte: 0x1AA99, kind: SK::Enum { width: 1, labels: &[(0, "4800bps"), (1, "9600bps")] } }, + SF { key: "set-mode-active-band", byte: 0x1AA88, kind: SK::Enum { width: 1, labels: &[(0, "Single"), (1, "ALL")] } }, + SF { key: "time-set-utc-offset", byte: 0x1AB6A, kind: SK::IntEnum { width: 2, labels: &[(-840, "-14:00"), (-835, "-13:55"), (-830, "-13:50"), (-825, "-13:45"), (-820, "-13:40"), (-815, "-13:35"), (-810, "-13:30"), (-805, "-13:25"), (-800, "-13:20"), (-795, "-13:15"), (-790, "-13:10"), (-785, "-13:05"), (-780, "-13:00"), (-775, "-12:55"), (-770, "-12:50"), (-765, "-12:45"), (-760, "-12:40"), (-755, "-12:35"), (-750, "-12:30"), (-745, "-12:25"), (-740, "-12:20"), (-735, "-12:15"), (-730, "-12:10"), (-725, "-12:05"), (-720, "-12:00"), (-715, "-11:55"), (-710, "-11:50"), (-705, "-11:45"), (-700, "-11:40"), (-695, "-11:35"), (-690, "-11:30"), (-685, "-11:25"), (-680, "-11:20"), (-675, "-11:15"), (-670, "-11:10"), (-665, "-11:05"), (-660, "-11:00"), (-655, "-10:55"), (-650, "-10:50"), (-645, "-10:45"), (-640, "-10:40"), (-635, "-10:35"), (-630, "-10:30"), (-625, "-10:25"), (-620, "-10:20"), (-615, "-10:15"), (-610, "-10:10"), (-605, "-10:05"), (-600, "-10:00"), (-595, "-9:55"), (-590, "-9:50"), (-585, "-9:45"), (-580, "-9:40"), (-575, "-9:35"), (-570, "-9:30"), (-565, "-9:25"), (-560, "-9:20"), (-555, "-9:15"), (-550, "-9:10"), (-545, "-9:05"), (-540, "-9:00"), (-535, "-8:55"), (-530, "-8:50"), (-525, "-8:45"), (-520, "-8:40"), (-515, "-8:35"), (-510, "-8:30"), (-505, "-8:25"), (-500, "-8:20"), (-495, "-8:15"), (-490, "-8:10"), (-485, "-8:05"), (-480, "-8:00"), (-475, "-7:55"), (-470, "-7:50"), (-465, "-7:45"), (-460, "-7:40"), (-455, "-7:35"), (-450, "-7:30"), (-445, "-7:25"), (-440, "-7:20"), (-435, "-7:15"), (-430, "-7:10"), (-425, "-7:05"), (-420, "-7:00"), (-415, "-6:55"), (-410, "-6:50"), (-405, "-6:45"), (-400, "-6:40"), (-395, "-6:35"), (-390, "-6:30"), (-385, "-6:25"), (-380, "-6:20"), (-375, "-6:15"), (-370, "-6:10"), (-365, "-6:05"), (-360, "-6:00"), (-355, "-5:55"), (-350, "-5:50"), (-345, "-5:45"), (-340, "-5:40"), (-335, "-5:35"), (-330, "-5:30"), (-325, "-5:25"), (-320, "-5:20"), (-315, "-5:15"), (-310, "-5:10"), (-305, "-5:05"), (-300, "-5:00"), (-295, "-4:55"), (-290, "-4:50"), (-285, "-4:45"), (-280, "-4:40"), (-275, "-4:35"), (-270, "-4:30"), (-265, "-4:25"), (-260, "-4:20"), (-255, "-4:15"), (-250, "-4:10"), (-245, "-4:05"), (-240, "-4:00"), (-235, "-3:55"), (-230, "-3:50"), (-225, "-3:45"), (-220, "-3:40"), (-215, "-3:35"), (-210, "-3:30"), (-205, "-3:25"), (-200, "-3:20"), (-195, "-3:15"), (-190, "-3:10"), (-185, "-3:05"), (-180, "-3:00"), (-175, "-2:55"), (-170, "-2:50"), (-165, "-2:45"), (-160, "-2:40"), (-155, "-2:35"), (-150, "-2:30"), (-145, "-2:25"), (-140, "-2:20"), (-135, "-2:15"), (-130, "-2:10"), (-125, "-2:05"), (-120, "-2:00"), (-115, "-1:55"), (-110, "-1:50"), (-105, "-1:45"), (-100, "-1:40"), (-95, "-1:35"), (-90, "-1:30"), (-85, "-1:25"), (-80, "-1:20"), (-75, "-1:15"), (-70, "-1:10"), (-65, "-1:05"), (-60, "-1:00"), (-55, "-0:55"), (-50, "-0:50"), (-45, "-0:45"), (-40, "-0:40"), (-35, "-0:35"), (-30, "-0:30"), (-25, "-0:25"), (-20, "-0:20"), (-15, "-0:15"), (-10, "-0:10"), (-5, "-0:05"), (0, "±0:00"), (5, "+0:05"), (10, "+0:10"), (15, "+0:15"), (20, "+0:20"), (25, "+0:25"), (30, "+0:30"), (35, "+0:35"), (40, "+0:40"), (45, "+0:45"), (50, "+0:50"), (55, "+0:55"), (60, "+1:00"), (65, "+1:05"), (70, "+1:10"), (75, "+1:15"), (80, "+1:20"), (85, "+1:25"), (90, "+1:30"), (95, "+1:35"), (100, "+1:40"), (105, "+1:45"), (110, "+1:50"), (115, "+1:55"), (120, "+2:00"), (125, "+2:05"), (130, "+2:10"), (135, "+2:15"), (140, "+2:20"), (145, "+2:25"), (150, "+2:30"), (155, "+2:35"), (160, "+2:40"), (165, "+2:45"), (170, "+2:50"), (175, "+2:55"), (180, "+3:00"), (185, "+3:05"), (190, "+3:10"), (195, "+3:15"), (200, "+3:20"), (205, "+3:25"), (210, "+3:30"), (215, "+3:35"), (220, "+3:40"), (225, "+3:45"), (230, "+3:50"), (235, "+3:55"), (240, "+4:00"), (245, "+4:05"), (250, "+4:10"), (255, "+4:15"), (260, "+4:20"), (265, "+4:25"), (270, "+4:30"), (275, "+4:35"), (280, "+4:40"), (285, "+4:45"), (290, "+4:50"), (295, "+4:55"), (300, "+5:00"), (305, "+5:05"), (310, "+5:10"), (315, "+5:15"), (320, "+5:20"), (325, "+5:25"), (330, "+5:30"), (335, "+5:35"), (340, "+5:40"), (345, "+5:45"), (350, "+5:50"), (355, "+5:55"), (360, "+6:00"), (365, "+6:05"), (370, "+6:10"), (375, "+6:15"), (380, "+6:20"), (385, "+6:25"), (390, "+6:30"), (395, "+6:35"), (400, "+6:40"), (405, "+6:45"), (410, "+6:50"), (415, "+6:55"), (420, "+7:00"), (425, "+7:05"), (430, "+7:10"), (435, "+7:15"), (440, "+7:20"), (445, "+7:25"), (450, "+7:30"), (455, "+7:35"), (460, "+7:40"), (465, "+7:45"), (470, "+7:50"), (475, "+7:55"), (480, "+8:00"), (485, "+8:05"), (490, "+8:10"), (495, "+8:15"), (500, "+8:20"), (505, "+8:25"), (510, "+8:30"), (515, "+8:35"), (520, "+8:40"), (525, "+8:45"), (530, "+8:50"), (535, "+8:55"), (540, "+9:00"), (545, "+9:05"), (550, "+9:10"), (555, "+9:15"), (560, "+9:20"), (565, "+9:25"), (570, "+9:30"), (575, "+9:35"), (580, "+9:40"), (585, "+9:45"), (590, "+9:50"), (595, "+9:55"), (600, "+10:00"), (605, "+10:05"), (610, "+10:10"), (615, "+10:15"), (620, "+10:20"), (625, "+10:25"), (630, "+10:30"), (635, "+10:35"), (640, "+10:40"), (645, "+10:45"), (650, "+10:50"), (655, "+10:55"), (660, "+11:00"), (665, "+11:05"), (670, "+11:10"), (675, "+11:15"), (680, "+11:20"), (685, "+11:25"), (690, "+11:30"), (695, "+11:35"), (700, "+11:40"), (705, "+11:45"), (710, "+11:50"), (715, "+11:55"), (720, "+12:00"), (725, "+12:05"), (730, "+12:10"), (735, "+12:15"), (740, "+12:20"), (745, "+12:25"), (750, "+12:30"), (755, "+12:35"), (760, "+12:40"), (765, "+12:45"), (770, "+12:50"), (775, "+12:55"), (780, "+13:00"), (785, "+13:05"), (790, "+13:10"), (795, "+13:15"), (800, "+13:20"), (805, "+13:25"), (810, "+13:30"), (815, "+13:35"), (820, "+13:40"), (825, "+13:45"), (830, "+13:50"), (835, "+13:55"), (840, "+14:00")] } }, // i16 BE minutes, 5-minute steps + SF { key: "time-set-gps-time-correct", byte: 0x1AAD1, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Auto")] } }, + SF { key: "remote-mic-key-during-rx-standby-a", byte: 0x1AB07, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "UP"), (2, "DOWN"), (3, "VOL UP"), (4, "VOL DOWN"), (5, "Monitor (Push)"), (6, "Monitor (Hold)"), (7, "CALL"), (8, "MR (000 CH)"), (9, "MR (001 CH)"), (10, "VFO/MR"), (11, "DR"), (12, "FROM (DR)"), (13, "TO (DR)"), (14, "Home CH"), (15, "BAND"), (16, "SCAN"), (17, "Temporary Skip"), (18, "RX>CS"), (19, "SPEECH"), (20, "Voice TX")] } }, + SF { key: "remote-mic-key-during-rx-standby-b", byte: 0x1AB08, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "UP"), (2, "DOWN"), (3, "VOL UP"), (4, "VOL DOWN"), (5, "Monitor (Push)"), (6, "Monitor (Hold)"), (7, "CALL"), (8, "MR (000 CH)"), (9, "MR (001 CH)"), (10, "VFO/MR"), (11, "DR"), (12, "FROM (DR)"), (13, "TO (DR)"), (14, "Home CH"), (15, "BAND"), (16, "SCAN"), (17, "Temporary Skip"), (18, "RX>CS"), (19, "SPEECH"), (20, "Voice TX")] } }, + SF { key: "remote-mic-key-during-rx-standby-up", byte: 0x1AB09, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "UP"), (2, "DOWN"), (3, "VOL UP"), (4, "VOL DOWN"), (5, "Monitor (Push)"), (6, "Monitor (Hold)"), (7, "CALL"), (8, "MR (000 CH)"), (9, "MR (001 CH)"), (10, "VFO/MR"), (11, "DR"), (12, "FROM (DR)"), (13, "TO (DR)"), (14, "Home CH"), (15, "BAND"), (16, "SCAN"), (17, "Temporary Skip"), (18, "RX>CS"), (19, "SPEECH"), (20, "Voice TX")] } }, + SF { key: "remote-mic-key-during-rx-standby-down", byte: 0x1AB0A, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "UP"), (2, "DOWN"), (3, "VOL UP"), (4, "VOL DOWN"), (5, "Monitor (Push)"), (6, "Monitor (Hold)"), (7, "CALL"), (8, "MR (000 CH)"), (9, "MR (001 CH)"), (10, "VFO/MR"), (11, "DR"), (12, "FROM (DR)"), (13, "TO (DR)"), (14, "Home CH"), (15, "BAND"), (16, "SCAN"), (17, "Temporary Skip"), (18, "RX>CS"), (19, "SPEECH"), (20, "Voice TX")] } }, + SF { key: "remote-mic-key-during-tx-a", byte: 0x1AB0B, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "VOL UP"), (2, "VOL DOWN"), (3, "Voice TX"), (4, "T-CALL")] } }, + SF { key: "remote-mic-key-during-tx-b", byte: 0x1AB0C, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "VOL UP"), (2, "VOL DOWN"), (3, "Voice TX"), (4, "T-CALL")] } }, + SF { key: "remote-mic-key-during-tx-up", byte: 0x1AB0D, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "VOL UP"), (2, "VOL DOWN"), (3, "Voice TX"), (4, "T-CALL")] } }, + SF { key: "remote-mic-key-during-tx-down", byte: 0x1AB0E, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "VOL UP"), (2, "VOL DOWN"), (3, "Voice TX"), (4, "T-CALL")] } }, + SF { key: "vox-vox", byte: 0x1AA92, kind: SK::Bool }, + SF { key: "vox-vox-level", byte: 0x1AA93, kind: SK::Enum { width: 1, labels: &[(1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5"), (6, "6"), (7, "7"), (8, "8"), (9, "9"), (10, "10")] } }, + SF { key: "vox-vox-delay", byte: 0x1AA94, kind: SK::Enum { width: 1, labels: &[(0, "0.5sec"), (1, "1.0sec"), (2, "1.5sec"), (3, "2.0sec"), (4, "2.5sec"), (5, "3.0sec")] } }, + SF { key: "vox-vox-time-out-timer", byte: 0x1AA95, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "1min"), (2, "2min"), (3, "3min"), (4, "4min"), (5, "5min"), (6, "10min"), (7, "15min")] } }, + SF { key: "vox-vox-headset-select", byte: 0x1AA9A, kind: SK::Enum { width: 1, labels: &[(0, "HS-95"), (1, "Other")] } }, + SF { key: "ci-v-ci-v-data-jack", byte: 0x1AB1A, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "ON (Echo Back OFF)"), (2, "ON (Echo Back ON)")] } }, + SF { key: "ci-v-ci-v-address", byte: 0x1AA96, kind: SK::Enum { width: 1, labels: &[(1, "01"), (2, "02"), (3, "03"), (4, "04"), (5, "05"), (6, "06"), (7, "07"), (8, "08"), (9, "09"), (10, "0A"), (11, "0B"), (12, "0C"), (13, "0D"), (14, "0E"), (15, "0F"), (16, "10"), (17, "11"), (18, "12"), (19, "13"), (20, "14"), (21, "15"), (22, "16"), (23, "17"), (24, "18"), (25, "19"), (26, "1A"), (27, "1B"), (28, "1C"), (29, "1D"), (30, "1E"), (31, "1F"), (32, "20"), (33, "21"), (34, "22"), (35, "23"), (36, "24"), (37, "25"), (38, "26"), (39, "27"), (40, "28"), (41, "29"), (42, "2A"), (43, "2B"), (44, "2C"), (45, "2D"), (46, "2E"), (47, "2F"), (48, "30"), (49, "31"), (50, "32"), (51, "33"), (52, "34"), (53, "35"), (54, "36"), (55, "37"), (56, "38"), (57, "39"), (58, "3A"), (59, "3B"), (60, "3C"), (61, "3D"), (62, "3E"), (63, "3F"), (64, "40"), (65, "41"), (66, "42"), (67, "43"), (68, "44"), (69, "45"), (70, "46"), (71, "47"), (72, "48"), (73, "49"), (74, "4A"), (75, "4B"), (76, "4C"), (77, "4D"), (78, "4E"), (79, "4F"), (80, "50"), (81, "51"), (82, "52"), (83, "53"), (84, "54"), (85, "55"), (86, "56"), (87, "57"), (88, "58"), (89, "59"), (90, "5A"), (91, "5B"), (92, "5C"), (93, "5D"), (94, "5E"), (95, "5F"), (96, "60"), (97, "61"), (98, "62"), (99, "63"), (100, "64"), (101, "65"), (102, "66"), (103, "67"), (104, "68"), (105, "69"), (106, "6A"), (107, "6B"), (108, "6C"), (109, "6D"), (110, "6E"), (111, "6F"), (112, "70"), (113, "71"), (114, "72"), (115, "73"), (116, "74"), (117, "75"), (118, "76"), (119, "77"), (120, "78"), (121, "79"), (122, "7A"), (123, "7B"), (124, "7C"), (125, "7D"), (126, "7E"), (127, "7F"), (128, "80"), (129, "81"), (130, "82"), (131, "83"), (132, "84"), (133, "85"), (134, "86"), (135, "87"), (136, "88"), (137, "89"), (138, "8A"), (139, "8B"), (140, "8C"), (141, "8D"), (142, "8E"), (143, "8F"), (144, "90"), (145, "91"), (146, "92"), (147, "93"), (148, "94"), (149, "95"), (150, "96"), (151, "97"), (152, "98"), (153, "99"), (154, "9A"), (155, "9B"), (156, "9C"), (157, "9D"), (158, "9E"), (159, "9F"), (160, "A0"), (161, "A1"), (162, "A2"), (163, "A3"), (164, "A4"), (165, "A5"), (166, "A6"), (167, "A7"), (168, "A8"), (169, "A9"), (170, "AA"), (171, "AB"), (172, "AC"), (173, "AD"), (174, "AE"), (175, "AF"), (176, "B0"), (177, "B1"), (178, "B2"), (179, "B3"), (180, "B4"), (181, "B5"), (182, "B6"), (183, "B7"), (184, "B8"), (185, "B9"), (186, "BA"), (187, "BB"), (188, "BC"), (189, "BD"), (190, "BE"), (191, "BF"), (192, "C0"), (193, "C1"), (194, "C2"), (195, "C3"), (196, "C4"), (197, "C5"), (198, "C6"), (199, "C7"), (200, "C8"), (201, "C9"), (202, "CA"), (203, "CB"), (204, "CC"), (205, "CD"), (206, "CE"), (207, "CF"), (208, "D0"), (209, "D1"), (210, "D2"), (211, "D3"), (212, "D4"), (213, "D5"), (214, "D6"), (215, "D7"), (216, "D8"), (217, "D9"), (218, "DA"), (219, "DB"), (220, "DC"), (221, "DD"), (222, "DE"), (223, "DF")] } }, // raw hex byte (86 -> 7E measured) + SF { key: "ci-v-ci-v-baud-rate", byte: 0x1AA97, kind: SK::Enum { width: 1, labels: &[(0, "300bps"), (1, "1200bps"), (2, "4800bps"), (3, "9600bps"), (4, "19200bps"), (5, "Auto")] } }, + SF { key: "ci-v-ci-v-transceive", byte: 0x1AA98, kind: SK::Bool }, + SF { key: "display-backlight", byte: 0x1AAB2, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "ON"), (2, "Auto"), (3, "Auto (DC IN:ON)")] } }, + SF { key: "display-backlight-timer", byte: 0x1AAB1, kind: SK::Enum { width: 1, labels: &[(0, "5sec"), (1, "10sec")] } }, + SF { key: "display-lcd-dimmer", byte: 0x1AAB4, kind: SK::Enum { width: 1, labels: &[(0, "Bright"), (1, "Dark")] } }, + SF { key: "display-lcd-contrast", byte: 0x1AAB0, kind: SK::Enum { width: 1, labels: &[(0, "1"), (1, "2"), (2, "3"), (3, "4"), (4, "5"), (5, "6"), (6, "7"), (7, "8"), (8, "9"), (9, "10"), (10, "11"), (11, "12"), (12, "13"), (13, "14"), (14, "15"), (15, "16")] } }, + SF { key: "display-busy-led", byte: 0x1AAAF, kind: SK::Bool }, + SF { key: "display-scroll-speed", byte: 0x1AAB9, kind: SK::Enum { width: 1, labels: &[(0, "Slow"), (1, "Fast")] } }, + SF { key: "display-opening-message", byte: 0x1AABB, kind: SK::Bool }, + SF { key: "display-voltage-power-on", byte: 0x1AAB3, kind: SK::Bool }, + SF { key: "display-latitude-longitude-units", byte: 0x1AAC6, kind: SK::Enum { width: 1, labels: &[(0, "ddd°mm.mm'"), (1, "ddd°mm'ss\"")] } }, + SF { key: "display-altitude-distance-units", byte: 0x1AAC7, kind: SK::Enum { width: 1, labels: &[(0, "m"), (1, "ft/ml")] } }, + SF { key: "display-speed-units", byte: 0x1AAD7, kind: SK::Enum { width: 1, labels: &[(0, "km/h"), (1, "mph"), (2, "knots")] } }, + SF { key: "display-temperature-units", byte: 0x1AB16, kind: SK::Enum { width: 1, labels: &[(0, "°C"), (1, "°F")] } }, + SF { key: "display-barometric-units", byte: 0x1AB17, kind: SK::Enum { width: 1, labels: &[(0, "hPa"), (1, "mb"), (2, "mmHg"), (3, "inHg")] } }, + SF { key: "display-rainfall-units", byte: 0x1AB18, kind: SK::Enum { width: 1, labels: &[(0, "mm"), (1, "inch")] } }, + SF { key: "display-wind-speed-units", byte: 0x1AB19, kind: SK::Enum { width: 1, labels: &[(0, "m/s"), (1, "mph"), (2, "knots")] } }, + SF { key: "display-display-language", byte: 0x1AABC, kind: SK::Enum { width: 1, labels: &[(0, "English"), (1, "Japanese")] } }, + SF { key: "display-system-language", byte: 0x1AABD, kind: SK::Enum { width: 1, labels: &[(0, "English"), (1, "Japanese")] } }, + SF { key: "display-display-type", byte: 0x1AAD4, kind: SK::Enum { width: 1, labels: &[(0, "Freq (Name OFF)"), (1, "Freq"), (3, "Name")] } }, + SF { key: "sounds-volume-select", byte: 0x1AAF1, kind: SK::Enum { width: 1, labels: &[(0, "All"), (1, "BC Radio Separate"), (2, "Separate")] } }, + SF { key: "sounds-bc-radio-level", byte: 0x1AAF2, kind: SK::Enum { width: 1, labels: &[(0, "-5"), (1, "-4"), (2, "-3"), (3, "-2"), (4, "-1"), (5, "0"), (6, "+1"), (7, "+2"), (8, "+3"), (9, "+4"), (10, "+5")] } }, + SF { key: "sounds-earphone-mode", byte: 0x1AACF, kind: SK::Bool }, + SF { key: "sounds-beep-level", byte: 0x1AABE, kind: SK::Enum { width: 1, labels: &[(0, "0"), (1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5"), (6, "6"), (7, "7"), (8, "8"), (9, "9")] } }, + SF { key: "sounds-beep-vol-level-link", byte: 0x1AAC3, kind: SK::Bool }, + SF { key: "sounds-key-touch-beep", byte: 0x1AABF, kind: SK::Bool }, + SF { key: "sounds-home-ch-beep", byte: 0x1AB0F, kind: SK::Bool }, + SF { key: "sounds-band-edge-beep", byte: 0x1AAC2, kind: SK::Bool }, + SF { key: "sounds-scan-stop-beep", byte: 0x1AAC0, kind: SK::Bool }, + SF { key: "sounds-sub-band-mute", byte: 0x1AAEE, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Mute"), (2, "Beep"), (3, "Mute & Beep")] } }, + SF { key: "sounds-scope-af-output", byte: 0x1AAC4, kind: SK::Bool }, + SF { key: "speech-rx-call-sign-speech", byte: 0x1AAE2, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "ON (Kerchunk)"), (2, "ON (All)")] } }, + SF { key: "speech-rx-cs-speech", byte: 0x1AAE3, kind: SK::Bool }, + SF { key: "speech-dial-speech", byte: 0x1AAE4, kind: SK::Bool }, + SF { key: "speech-mode-speech", byte: 0x1AAE5, kind: SK::Bool }, + SF { key: "speech-speech-language", byte: 0x1AAE6, kind: SK::Enum { width: 1, labels: &[(0, "English"), (1, "Japanese")] } }, + SF { key: "speech-alphabet", byte: 0x1AAE7, kind: SK::Enum { width: 1, labels: &[(0, "Normal"), (1, "Phonetic Code")] } }, + SF { key: "speech-speech-speed", byte: 0x1AAE8, kind: SK::Enum { width: 1, labels: &[(0, "Slow"), (1, "Fast")] } }, + SF { key: "speech-speech-level", byte: 0x1AAE9, kind: SK::Enum { width: 1, labels: &[(0, "0"), (1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5"), (6, "6"), (7, "7"), (8, "8"), (9, "9")] } }, + SF { key: "qso-recorder-rec-mode", byte: 0x1AADA, kind: SK::Enum { width: 1, labels: &[(0, "TX&RX"), (1, "RX Only")] } }, + SF { key: "qso-recorder-rx-rec-condition", byte: 0x1AAD9, kind: SK::Enum { width: 1, labels: &[(0, "Always"), (1, "Squelch Auto")] } }, + SF { key: "qso-recorder-file-split", byte: 0x1AAD8, kind: SK::Bool }, + SF { key: "qso-recorder-ptt-auto-rec", byte: 0x1AAEA, kind: SK::Bool }, + SF { key: "qso-recorder-skip-time", byte: 0x1AAEB, kind: SK::Enum { width: 1, labels: &[(0, "3sec"), (1, "5sec"), (2, "10sec"), (3, "30sec")] } }, + SF { key: "voice-recorder-mic-gain", byte: 0x1AAF8, kind: SK::Enum { width: 1, labels: &[(0, "1"), (1, "2"), (2, "3"), (3, "4")] } }, + SF { key: "voice-recorder-skip-time", byte: 0x1AAF7, kind: SK::Enum { width: 1, labels: &[(0, "3sec"), (1, "5sec"), (2, "10sec"), (3, "30sec")] } }, + SF { key: "voice-tx-repeat-time", byte: 0x1AAF9, kind: SK::Enum { width: 1, labels: &[(0, "1sec"), (1, "2sec"), (2, "3sec"), (3, "4sec"), (4, "5sec"), (5, "6sec"), (6, "7sec"), (7, "8sec"), (8, "9sec"), (9, "10sec"), (10, "11sec"), (11, "12sec"), (12, "13sec"), (13, "14sec"), (14, "15sec")] } }, + SF { key: "voice-tx-tx-monitor", byte: 0x1AAFA, kind: SK::Bool }, + SF { key: "bc-radio-att-fm", byte: 0x1AB34, kind: SK::EnumBits { shift: 6, bits: 1, labels: &[(0, "OFF"), (1, "30dB")] } }, + SF { key: "bc-radio-att-am", byte: 0x1AB34, kind: SK::EnumBits { shift: 4, bits: 2, labels: &[(0, "OFF"), (1, "Auto"), (2, "30dB"), (3, "50dB")] } }, + SF { key: "bc-radio-auto-mute", byte: 0x1AAF3, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "0sec"), (2, "1sec"), (3, "2sec"), (4, "3sec"), (5, "4sec"), (6, "5sec"), (7, "6sec"), (8, "7sec"), (9, "8sec"), (10, "9sec"), (11, "10sec")] } }, + SF { key: "bc-radio-fm-antenna", byte: 0x1AAF4, kind: SK::Enum { width: 1, labels: &[(0, "External"), (1, "Earphone")] } }, + SF { key: "bc-radio-power-save-bc-radio", byte: 0x1AAF5, kind: SK::Bool }, + SF { key: "qso-rx-history-log-qso-log", byte: 0x1AAFB, kind: SK::Bool }, + SF { key: "qso-rx-history-log-rx-history-log", byte: 0x1AAFC, kind: SK::Bool }, + SF { key: "qso-rx-history-log-csv-format-separator-decimal", byte: 0x1AAFD, kind: SK::Enum { width: 1, labels: &[(0, "Sep [,] Dec [.]"), (1, "Sep [;] Dec [.]"), (2, "Sep [;] Dec [,]")] } }, + SF { key: "qso-rx-history-log-csv-format-date", byte: 0x1AAFE, kind: SK::Enum { width: 1, labels: &[(0, "yyyy/mm/dd"), (1, "mm/dd/yyyy"), (2, "dd/mm/yyyy")] } }, + SF { key: "import-export-csv-format-separator-decimal", byte: 0x1AB1B, kind: SK::Enum { width: 1, labels: &[(0, "Sep [,] Dec [.]"), (1, "Sep [;] Dec [.]"), (2, "Sep [;] Dec [,]")] } }, + SF { key: "import-export-csv-format-date", byte: 0x1AB1C, kind: SK::Enum { width: 1, labels: &[(0, "yyyy/mm/dd"), (1, "mm/dd/yyyy"), (2, "dd/mm/yyyy")] } }, + SF { key: "others-rf-power-vhf", byte: 0x1AB25, kind: SK::Enum { width: 1, labels: &[(0, "S-Low"), (1, "Low1"), (2, "Low2"), (3, "Mid"), (4, "High")] } }, + SF { key: "others-rf-power-uhf", byte: 0x1AB26, kind: SK::Enum { width: 1, labels: &[(0, "S-Low"), (1, "Low1"), (2, "Low2"), (3, "Mid"), (4, "High")] } }, + SF { key: "others-tone-burst", byte: 0x1AA8F, kind: SK::Bool }, + SF { key: "others-att-air", byte: 0x1AB34, kind: SK::BoolBit { shift: 7 } }, + SF { key: "others-heterodyne", byte: 0x1AB10, kind: SK::Enum { width: 1, labels: &[(0, "Normal"), (1, "Reverse")] } }, + SF { key: "others-charging-power-on", byte: 0x1AB11, kind: SK::Bool }, + SF { key: "scan-pause-timer-a", byte: 0x1AAA6, kind: SK::Enum { width: 1, labels: &[(0, "2sec"), (1, "4sec"), (2, "6sec"), (3, "8sec"), (4, "10sec"), (5, "12sec"), (6, "14sec"), (7, "16sec"), (8, "18sec"), (9, "20sec"), (10, "Hold")] } }, + SF { key: "scan-resume-timer-a", byte: 0x1AAA8, kind: SK::Enum { width: 1, labels: &[(0, "0sec"), (1, "1sec"), (2, "2sec"), (3, "3sec"), (4, "4sec"), (5, "5sec"), (6, "Hold")] } }, + SF { key: "scan-temporary-skip-timer-a", byte: 0x1AAEF, kind: SK::Enum { width: 1, labels: &[(0, "5min"), (1, "10min"), (2, "15min")] } }, + SF { key: "scan-program-skip-scan-a", byte: 0x1AAAD, kind: SK::Bool }, + SF { key: "scan-pause-timer-b", byte: 0x1AAA7, kind: SK::Enum { width: 1, labels: &[(0, "2sec"), (1, "4sec"), (2, "6sec"), (3, "8sec"), (4, "10sec"), (5, "12sec"), (6, "14sec"), (7, "16sec"), (8, "18sec"), (9, "20sec"), (10, "Hold")] } }, + SF { key: "scan-resume-timer-b", byte: 0x1AAA9, kind: SK::Enum { width: 1, labels: &[(0, "0sec"), (1, "1sec"), (2, "2sec"), (3, "3sec"), (4, "4sec"), (5, "5sec"), (6, "Hold")] } }, + SF { key: "scan-temporary-skip-timer-b", byte: 0x1AAF0, kind: SK::Enum { width: 1, labels: &[(0, "5min"), (1, "10min"), (2, "15min")] } }, + SF { key: "scan-program-skip-scan-b", byte: 0x1AAAE, kind: SK::Bool }, + SF { key: "scan-bank-link-a-a", byte: 0x1AEAD, kind: SK::BoolBit { shift: 0 } }, + SF { key: "scan-bank-link-a-b", byte: 0x1AEAD, kind: SK::BoolBit { shift: 1 } }, + SF { key: "scan-bank-link-a-c", byte: 0x1AEAD, kind: SK::BoolBit { shift: 2 } }, + SF { key: "scan-bank-link-a-d", byte: 0x1AEAD, kind: SK::BoolBit { shift: 3 } }, + SF { key: "scan-bank-link-a-e", byte: 0x1AEAD, kind: SK::BoolBit { shift: 4 } }, + SF { key: "scan-bank-link-a-f", byte: 0x1AEAD, kind: SK::BoolBit { shift: 5 } }, + SF { key: "scan-bank-link-a-g", byte: 0x1AEAD, kind: SK::BoolBit { shift: 6 } }, + SF { key: "scan-bank-link-a-h", byte: 0x1AEAD, kind: SK::BoolBit { shift: 7 } }, + SF { key: "scan-bank-link-a-i", byte: 0x1AEAE, kind: SK::BoolBit { shift: 0 } }, + SF { key: "scan-bank-link-a-j", byte: 0x1AEAE, kind: SK::BoolBit { shift: 1 } }, + SF { key: "scan-bank-link-a-k", byte: 0x1AEAE, kind: SK::BoolBit { shift: 2 } }, + SF { key: "scan-bank-link-a-l", byte: 0x1AEAE, kind: SK::BoolBit { shift: 3 } }, + SF { key: "scan-bank-link-a-m", byte: 0x1AEAE, kind: SK::BoolBit { shift: 4 } }, + SF { key: "scan-bank-link-a-n", byte: 0x1AEAE, kind: SK::BoolBit { shift: 5 } }, + SF { key: "scan-bank-link-a-o", byte: 0x1AEAE, kind: SK::BoolBit { shift: 6 } }, + SF { key: "scan-bank-link-a-p", byte: 0x1AEAE, kind: SK::BoolBit { shift: 7 } }, + SF { key: "scan-bank-link-a-q", byte: 0x1AEAF, kind: SK::BoolBit { shift: 0 } }, + SF { key: "scan-bank-link-a-r", byte: 0x1AEAF, kind: SK::BoolBit { shift: 1 } }, + SF { key: "scan-bank-link-a-s", byte: 0x1AEAF, kind: SK::BoolBit { shift: 2 } }, + SF { key: "scan-bank-link-a-t", byte: 0x1AEAF, kind: SK::BoolBit { shift: 3 } }, + SF { key: "scan-bank-link-a-u", byte: 0x1AEAF, kind: SK::BoolBit { shift: 4 } }, + SF { key: "scan-bank-link-a-v", byte: 0x1AEAF, kind: SK::BoolBit { shift: 5 } }, + SF { key: "scan-bank-link-a-w", byte: 0x1AEAF, kind: SK::BoolBit { shift: 6 } }, + SF { key: "scan-bank-link-a-x", byte: 0x1AEAF, kind: SK::BoolBit { shift: 7 } }, + SF { key: "scan-bank-link-a-y", byte: 0x1AEB0, kind: SK::BoolBit { shift: 0 } }, + SF { key: "scan-bank-link-a-z", byte: 0x1AEB0, kind: SK::BoolBit { shift: 1 } }, + SF { key: "scan-bank-link-b-a", byte: 0x1AEB1, kind: SK::BoolBit { shift: 0 } }, + SF { key: "scan-bank-link-b-b", byte: 0x1AEB1, kind: SK::BoolBit { shift: 1 } }, + SF { key: "scan-bank-link-b-c", byte: 0x1AEB1, kind: SK::BoolBit { shift: 2 } }, + SF { key: "scan-bank-link-b-d", byte: 0x1AEB1, kind: SK::BoolBit { shift: 3 } }, + SF { key: "scan-bank-link-b-e", byte: 0x1AEB1, kind: SK::BoolBit { shift: 4 } }, + SF { key: "scan-bank-link-b-f", byte: 0x1AEB1, kind: SK::BoolBit { shift: 5 } }, + SF { key: "scan-bank-link-b-g", byte: 0x1AEB1, kind: SK::BoolBit { shift: 6 } }, + SF { key: "scan-bank-link-b-h", byte: 0x1AEB1, kind: SK::BoolBit { shift: 7 } }, + SF { key: "scan-bank-link-b-i", byte: 0x1AEB2, kind: SK::BoolBit { shift: 0 } }, + SF { key: "scan-bank-link-b-j", byte: 0x1AEB2, kind: SK::BoolBit { shift: 1 } }, + SF { key: "scan-bank-link-b-k", byte: 0x1AEB2, kind: SK::BoolBit { shift: 2 } }, + SF { key: "scan-bank-link-b-l", byte: 0x1AEB2, kind: SK::BoolBit { shift: 3 } }, + SF { key: "scan-bank-link-b-m", byte: 0x1AEB2, kind: SK::BoolBit { shift: 4 } }, + SF { key: "scan-bank-link-b-n", byte: 0x1AEB2, kind: SK::BoolBit { shift: 5 } }, + SF { key: "scan-bank-link-b-o", byte: 0x1AEB2, kind: SK::BoolBit { shift: 6 } }, + SF { key: "scan-bank-link-b-p", byte: 0x1AEB2, kind: SK::BoolBit { shift: 7 } }, + SF { key: "scan-bank-link-b-q", byte: 0x1AEB3, kind: SK::BoolBit { shift: 0 } }, + SF { key: "scan-bank-link-b-r", byte: 0x1AEB3, kind: SK::BoolBit { shift: 1 } }, + SF { key: "scan-bank-link-b-s", byte: 0x1AEB3, kind: SK::BoolBit { shift: 2 } }, + SF { key: "scan-bank-link-b-t", byte: 0x1AEB3, kind: SK::BoolBit { shift: 3 } }, + SF { key: "scan-bank-link-b-u", byte: 0x1AEB3, kind: SK::BoolBit { shift: 4 } }, + SF { key: "scan-bank-link-b-v", byte: 0x1AEB3, kind: SK::BoolBit { shift: 5 } }, + SF { key: "scan-bank-link-b-w", byte: 0x1AEB3, kind: SK::BoolBit { shift: 6 } }, + SF { key: "scan-bank-link-b-x", byte: 0x1AEB3, kind: SK::BoolBit { shift: 7 } }, + SF { key: "scan-bank-link-b-y", byte: 0x1AEB4, kind: SK::BoolBit { shift: 0 } }, + SF { key: "scan-bank-link-b-z", byte: 0x1AEB4, kind: SK::BoolBit { shift: 1 } }, + SF { key: "wx-weather-alert-a", byte: 0x1AAAB, kind: SK::Bool }, + SF { key: "wx-wx-ch-a", byte: 0x1AE9B, kind: SK::Enum { width: 1, labels: &[(0, "1"), (1, "2"), (2, "3"), (3, "4"), (4, "5"), (5, "6"), (6, "7"), (7, "8"), (8, "9"), (9, "10")] } }, + SF { key: "wx-weather-alert-b", byte: 0x1AAAC, kind: SK::Bool }, + SF { key: "wx-wx-ch-b", byte: 0x1AE9C, kind: SK::Enum { width: 1, labels: &[(0, "1"), (1, "2"), (2, "3"), (3, "4"), (4, "5"), (5, "6"), (6, "7"), (7, "8"), (8, "9"), (9, "10")] } }, + SF { key: "gps-gps-select", byte: 0x1AAD0, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Internal GPS"), (2, "External GPS"), (3, "Manual")] } }, + SF { key: "gps-power-save-internal-gps", byte: 0x1AABA, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "1min"), (2, "2min"), (3, "4min"), (4, "8min"), (5, "Auto")] } }, + SF { key: "gps-compass-direction", byte: 0x1AACE, kind: SK::Enum { width: 1, labels: &[(0, "Heading Up"), (1, "North Up"), (2, "South Up")] } }, + SF { key: "gps-gps-indicator", byte: 0x1AAC8, kind: SK::Bool }, + SF { key: "gps-gps-out-to-data-jack", byte: 0x1AAC9, kind: SK::Bool }, + SF { key: "gps-alarm-alarm-area-group", byte: 0x1AB68, kind: SK::Uint { width: 2 } }, // u16 BE minutes x 1000 (01.50' -> 1500) + SF { key: "gps-alarm-alarm-area-rx-memory", byte: 0x1AACA, kind: SK::Enum { width: 1, labels: &[(0, "Limited"), (1, "Extended"), (2, "Both")] } }, + SF { key: "gps-alarm-alarm-select", byte: 0x1AB65, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "RX"), (2, "Group"), (3, "Memory")] } }, + SF { key: "gps-alarm-alarm-group", byte: 0x1AB66, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "(No Group)"), (2, "Group A"), (3, "Group B"), (4, "Group C"), (5, "Group D"), (6, "Group E"), (7, "Group F"), (8, "Group G"), (9, "Group H"), (10, "Group I"), (11, "Group J"), (12, "Group K"), (13, "Group L"), (14, "Group M"), (15, "Group N"), (16, "Group O"), (17, "Group P"), (18, "Group Q"), (19, "Group R"), (20, "Group S"), (21, "Group T"), (22, "Group U"), (23, "Group V"), (24, "Group W"), (25, "Group X"), (26, "Group Y"), (27, "Group Z")] } }, + SF { key: "gps-alarm-alarm-memory", byte: 0x1AB67, kind: SK::Uint { width: 1 } }, // the GPS memory number, 0-199 (TESTA 0 / TESTB 1 measured) + SF { key: "gps-tx-gps-tx-mode", byte: 0x1AAA1, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "D-PRS (DV-A)"), (2, "NMEA (DV-G)")] } }, + SF { key: "gps-tx-gps-auto-tx-timer", byte: 0x1AAA3, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "5sec"), (2, "10sec"), (3, "30sec"), (4, "1min"), (5, "3min"), (6, "5min"), (7, "10min"), (8, "30min")] } }, + SF { key: "d-prs-unproto-address", byte: 0x1AB8C, kind: SK::TextFill { len: 56, fill: 0xFF } }, + SF { key: "d-prs-position-symbol-ch", byte: 0x1AACD, kind: SK::Enum { width: 1, labels: &[(0, "No.1"), (1, "No.2"), (2, "No.3"), (3, "No.4")] } }, + SF { key: "d-prs-position-symbol-no-1", byte: 0x1ABEF, kind: SK::Text { len: 2 } }, + SF { key: "d-prs-position-symbol-no-2", byte: 0x1ABF1, kind: SK::Text { len: 2 } }, + SF { key: "d-prs-position-symbol-no-3", byte: 0x1ABF3, kind: SK::Text { len: 2 } }, + SF { key: "d-prs-position-symbol-no-4", byte: 0x1ABF5, kind: SK::Text { len: 2 } }, + SF { key: "d-prs-position-ssid", byte: 0x1AAD2, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "(-0)"), (2, "-1"), (3, "-2"), (4, "-3"), (5, "-4"), (6, "-5"), (7, "-6"), (8, "-7"), (9, "-8"), (10, "-9"), (11, "-10"), (12, "-11"), (13, "-12"), (14, "-13"), (15, "-14"), (16, "-15"), (17, "-A"), (18, "-B"), (19, "-C"), (20, "-D"), (21, "-E"), (22, "-F"), (23, "-G"), (24, "-H"), (25, "-I"), (26, "-J"), (27, "-K"), (28, "-L"), (29, "-M"), (30, "-N"), (31, "-O"), (32, "-P"), (33, "-Q"), (34, "-R"), (35, "-S"), (36, "-T"), (37, "-U"), (38, "-V"), (39, "-W"), (40, "-X"), (41, "-Y"), (42, "-Z")] } }, + SF { key: "d-prs-position-comment", byte: 0x1ABC4, kind: SK::TextFill { len: 43, fill: 0xFF } }, + SF { key: "d-prs-position-time-stamp", byte: 0x1AACC, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "DHM"), (2, "HMS")] } }, + SF { key: "d-prs-position-altitude", byte: 0x1AAD3, kind: SK::Bool }, + SF { key: "d-prs-position-data-extension", byte: 0x1AACB, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Course/Speed")] } }, + SF { key: "gps-tx-nmea-gps-message", byte: 0x1AB78, kind: SK::Text { len: 20 } }, + SF { key: "gps-tx-nmea-sentence-rmc", byte: 0x1AAA2, kind: SK::BoolBit { shift: 4 } }, + SF { key: "gps-tx-nmea-sentence-gga", byte: 0x1AAA2, kind: SK::BoolBit { shift: 3 } }, + SF { key: "gps-tx-nmea-sentence-gll", byte: 0x1AAA2, kind: SK::BoolBit { shift: 2 } }, + SF { key: "gps-tx-nmea-sentence-gsa", byte: 0x1AAA2, kind: SK::BoolBit { shift: 1 } }, + SF { key: "gps-tx-nmea-sentence-vtg", byte: 0x1AAA2, kind: SK::BoolBit { shift: 0 } }, + SF { key: "gps-tx-nmea-sentence-gsv", byte: 0x1AAA2, kind: SK::BoolBit { shift: 5 } }, + SF { key: "gps-logger-gps-logger", byte: 0x1AADB, kind: SK::Bool }, + SF { key: "gps-logger-record-interval", byte: 0x1AAEC, kind: SK::Enum { width: 1, labels: &[(0, "1sec"), (1, "5sec"), (2, "10sec"), (3, "30sec"), (4, "60sec")] } }, + SF { key: "gps-logger-record-sentence-rmc", byte: 0x1AB1D, kind: SK::BoolBit { shift: 7 } }, + SF { key: "gps-logger-record-sentence-gga", byte: 0x1AB1D, kind: SK::BoolBit { shift: 6 } }, + SF { key: "gps-logger-record-sentence-vtg", byte: 0x1AB1D, kind: SK::BoolBit { shift: 5 } }, + SF { key: "gps-logger-record-sentence-gsa", byte: 0x1AB1D, kind: SK::BoolBit { shift: 4 } }, + SF { key: "tone-control-rx-bass", byte: 0x1AADD, kind: SK::Enum { width: 1, labels: &[(0, "Cut"), (1, "Normal"), (2, "Boost")] } }, + SF { key: "tone-control-rx-treble", byte: 0x1AADE, kind: SK::Enum { width: 1, labels: &[(0, "Cut"), (1, "Normal"), (2, "Boost")] } }, + SF { key: "tone-control-rx-bass-boost", byte: 0x1AADF, kind: SK::Bool }, + SF { key: "tone-control-tx-bass", byte: 0x1AAE0, kind: SK::Enum { width: 1, labels: &[(0, "Cut"), (1, "Normal"), (2, "Boost")] } }, + SF { key: "tone-control-tx-treble", byte: 0x1AAE1, kind: SK::Enum { width: 1, labels: &[(0, "Cut"), (1, "Normal"), (2, "Boost")] } }, + SF { key: "dv-set-mode-auto-reply", byte: 0x1AA9B, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "ON"), (2, "Voice"), (3, "Position")] } }, + SF { key: "dv-set-mode-dv-data-tx", byte: 0x1AA9C, kind: SK::Enum { width: 1, labels: &[(0, "PTT"), (1, "Auto")] } }, + SF { key: "dv-set-mode-dv-fast-data", byte: 0x1AB12, kind: SK::Bool }, + SF { key: "dv-set-mode-dv-fast-data-gps-data-speed", byte: 0x1AB14, kind: SK::Enum { width: 1, labels: &[(0, "Slow"), (1, "Fast")] } }, + SF { key: "dv-set-mode-dv-fast-data-tx-delay-ptt", byte: 0x1AB13, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "1sec"), (2, "2sec"), (3, "3sec"), (4, "4sec"), (5, "5sec"), (6, "6sec"), (7, "7sec"), (8, "8sec"), (9, "9sec"), (10, "10sec")] } }, + SF { key: "dv-set-mode-digital-monitor", byte: 0x1AA9D, kind: SK::Enum { width: 1, labels: &[(0, "Auto"), (1, "Digital"), (2, "Analog")] } }, + SF { key: "dv-set-mode-digital-repeater-set", byte: 0x1AA9E, kind: SK::Bool }, + SF { key: "dv-set-mode-rx-call-sign-auto-write", byte: 0x1AA9F, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Auto")] } }, + SF { key: "dv-set-mode-repeater-call-sign-auto-write", byte: 0x1AAA0, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Auto")] } }, + SF { key: "dv-set-mode-dv-mode-auto-detect", byte: 0x1AAA4, kind: SK::Bool }, + SF { key: "dv-set-mode-rx-record-rpt", byte: 0x1AAA5, kind: SK::Enum { width: 1, labels: &[(0, "ALL"), (1, "Latest Only")] } }, + SF { key: "display-dv-rx-call-sign-display", byte: 0x1AAB5, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Auto"), (2, "Auto (RX Hold)")] } }, + SF { key: "display-dv-rx-message-display", byte: 0x1AAB7, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Auto")] } }, + SF { key: "display-dv-reply-position-display", byte: 0x1AAF6, kind: SK::Bool }, + SF { key: "display-dv-dv-rx-backlight", byte: 0x1AAB8, kind: SK::Bool }, + SF { key: "display-dv-tx-call-sign-display", byte: 0x1AAB6, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Your Call Sign"), (2, "My Call Sign")] } }, + SF { key: "display-dv-display-type-dr", byte: 0x1AAD5, kind: SK::Enum { width: 1, labels: &[(0, "Normal"), (1, "Large")] } }, + SF { key: "display-dv-display-type-rx-history", byte: 0x1AB15, kind: SK::Enum { width: 1, labels: &[(0, "Call Sign"), (1, "Name")] } }, + SF { key: "sounds-dv-standby-beep", byte: 0x1AAC1, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "ON"), (2, "ON (to me: High Tone)"), (3, "ON (to me: Alarm/High Tone)")] } }, + SF { key: "sounds-dv-emr-af-level", byte: 0x1AAC5, kind: SK::Enum { width: 1, labels: &[(0, "0"), (1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5"), (6, "6"), (7, "7"), (8, "8"), (9, "9"), (10, "10"), (11, "11"), (12, "12"), (13, "13"), (14, "14"), (15, "15"), (16, "16"), (17, "17"), (18, "18"), (19, "19"), (20, "20"), (21, "21"), (22, "22"), (23, "23"), (24, "24"), (25, "25"), (26, "26"), (27, "27"), (28, "28"), (29, "29"), (30, "30"), (31, "31"), (32, "32"), (33, "33"), (34, "34"), (35, "35"), (36, "36"), (37, "37"), (38, "38"), (39, "39")] } }, + SF { key: "my-station-my-call-sign-m01", byte: 0x1519E, kind: SK::Text { len: 8 } }, + SF { key: "my-station-my-call-sign-m01-note", byte: 0x151A6, kind: SK::Text { len: 4 } }, + SF { key: "my-station-my-call-sign-m02", byte: 0x151AA, kind: SK::Text { len: 8 } }, + SF { key: "my-station-my-call-sign-m02-note", byte: 0x151B2, kind: SK::Text { len: 4 } }, + SF { key: "my-station-my-call-sign-m03", byte: 0x151B6, kind: SK::Text { len: 8 } }, + SF { key: "my-station-my-call-sign-m03-note", byte: 0x151BE, kind: SK::Text { len: 4 } }, + SF { key: "my-station-my-call-sign-m04", byte: 0x151C2, kind: SK::Text { len: 8 } }, + SF { key: "my-station-my-call-sign-m04-note", byte: 0x151CA, kind: SK::Text { len: 4 } }, + SF { key: "my-station-my-call-sign-m05", byte: 0x151CE, kind: SK::Text { len: 8 } }, + SF { key: "my-station-my-call-sign-m05-note", byte: 0x151D6, kind: SK::Text { len: 4 } }, + SF { key: "my-station-my-call-sign-m06", byte: 0x151DA, kind: SK::Text { len: 8 } }, + SF { key: "my-station-my-call-sign-m06-note", byte: 0x151E2, kind: SK::Text { len: 4 } }, + SF { key: "my-station-my-call-sign-in-use", byte: 0x1AB24, kind: SK::Enum { width: 1, labels: &[(0, "M01"), (1, "M02"), (2, "M03"), (3, "M04"), (4, "M05"), (5, "M06")] } }, + SF { key: "my-station-tx-message-1", byte: 0x18544, kind: SK::Text { len: 20 } }, + SF { key: "my-station-tx-message-2", byte: 0x18558, kind: SK::Text { len: 20 } }, + SF { key: "my-station-tx-message-3", byte: 0x1856C, kind: SK::Text { len: 20 } }, + SF { key: "my-station-tx-message-4", byte: 0x18580, kind: SK::Text { len: 20 } }, + SF { key: "my-station-tx-message-5", byte: 0x18594, kind: SK::Text { len: 20 } }, + SF { key: "my-station-tx-message-in-use", byte: 0x185A8, kind: SK::Enum { width: 1, labels: &[(1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5")] } }, + SF { key: "my-station-tx-message", byte: 0x185A9, kind: SK::Bool }, + SF { key: "dtmf-dtmf-speed", byte: 0x1AAAA, kind: SK::Enum { width: 1, labels: &[(0, "100ms"), (1, "200ms"), (2, "300ms"), (3, "500ms")] } }, +]; diff --git a/src-tauri/src/radios/icom_id51/ladder.rs b/src-tauri/src/radios/icom_id51/ladder.rs new file mode 100644 index 0000000..40b460a --- /dev/null +++ b/src-tauri/src/radios/icom_id51/ladder.rs @@ -0,0 +1,154 @@ +//! THROWAWAY (#50, step 7): build the hardware-ladder files from the radio's own +//! save, each checked for the ONE property its rung tests before Tim loads it. +//! +//! ```sh +//! CPM_DEV_DB="$HOME/Library/Application Support/com.ww8l.codeplugmagic.dev/codeplug_manager.sqlite3" \ +//! CPM_CODEPLUG=2 cargo test --lib icom_id51::ladder -- --ignored --nocapture +//! ``` +//! Writes `scratchpad/icom_id51/ladder/LAD*.icf`. + +use crate::commands::export::{exclusion_reason, expand_for_export, resolve_codeplug_groups, CodeplugGroup, ExpandedChannel}; +use crate::models::Channel; +use crate::radios::driver::ExportRequest; +use crate::radios::icom_id52::icf::IcfFile; + +use super::layout::{record_offset, BANK_NAMES, CHANNEL_COUNT, EMPTY_BITMAP, REC_LEN}; + +fn root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..") +} + +fn base_text() -> String { + std::fs::read_to_string(root().join("scratchpad/icom_id51/id51_01_base.icf")).expect("the radio's own file") +} + +fn moved(a: &[u8], b: &[u8]) -> Vec { + (0..a.len()).filter(|&i| a[i] != b[i]).collect() +} + +fn in_memory_tables(i: usize) -> bool { + i < CHANNEL_COUNT * REC_LEN || (EMPTY_BITMAP..BANK_NAMES + 26 * 16).contains(&i) +} + +fn used(image: &[u8]) -> Vec { + (0..CHANNEL_COUNT).filter(|&s| image[EMPTY_BITMAP + s / 8] & (1 << (s % 8)) == 0).collect() +} + +fn ec(c: Channel) -> ExpandedChannel { + ExpandedChannel { channel: c, tg_label: None, timeslot: None, tg_number: None, tg_call_type: None, tg_inline: false } +} + +fn write(dir: &std::path::Path, name: &str, icf: &IcfFile) { + let text = icf.render(); + // Every file must read back as an ID-51 Plus file with a verifying #CD. + let back = IcfFile::parse(&text).expect("re-parses, #CD verifies"); + super::check_is_an_id51_file(&back).unwrap(); + std::fs::write(dir.join(name), text).unwrap(); + println!(" wrote {name}"); +} + +#[tokio::test] +#[ignore = "needs the dev database and scratchpad/icom_id51/id51_01_base.icf"] +async fn build_the_ladder() { + let dir = root().join("scratchpad/icom_id51/ladder"); + std::fs::create_dir_all(&dir).unwrap(); + let text = base_text(); + let base = IcfFile::parse(&text).unwrap(); + let b = base.image().to_vec(); + let model = crate::seed::test_model("ID-51 Plus"); + + // 1. Identity: the radio's own file, re-rendered by the app. Byte-identical. + assert_eq!(base.render(), text); + write(&dir, "LAD1_IDENT.icf", &base); + + // 2. One field: memory 0's name -> "LADDER TEST". Only name bytes move, so + // a refusal here and not on rung 1 is the #CD. + let mut two = base.clone(); + let at = record_offset(0) + 11; + crate::radios::icom_id52::memory::ascii_field(&mut two.image_mut()[at..at + 16], "LADDER TEST"); + let m = moved(&b, two.image()); + assert!(!m.is_empty() && m.iter().all(|&i| (at..at + 16).contains(&i)), "{m:x?}"); + println!("rung 2: {} name bytes moved, nothing else", m.len()); + write(&dir, "LAD2_RENAME.icf", &two); + + // 3. Full codeplug through the real exporter: HOME-HT from the dev DB. + let db = std::env::var("CPM_DEV_DB").expect("CPM_DEV_DB"); + let cp: i64 = std::env::var("CPM_CODEPLUG").expect("CPM_CODEPLUG").parse().unwrap(); + let pool = sqlx::SqlitePool::connect(&format!("sqlite:file:{db}?mode=ro")).await.expect("open dev db"); + let groups = resolve_codeplug_groups(&pool, cp).await.unwrap(); + let mut seen = std::collections::HashSet::new(); + let chans: Vec<_> = groups.iter().flat_map(|g| g.channels.iter().cloned()).filter(|c| seen.insert(c.id)).collect(); + let expanded = expand_for_export(&pool, chans).await.unwrap(); + let included: Vec<&ExpandedChannel> = expanded.iter().filter(|e| exclusion_reason(&e.channel, &model).is_none()).collect(); + let tmp = std::env::temp_dir().join("id51_ladder3.icf"); + std::fs::write(&tmp, &text).unwrap(); + let exporter = crate::radios::registry::exporter_for_format("icom_id51_icf").unwrap(); + let n = exporter + .export(tmp.to_str().unwrap(), &ExportRequest { channels: &included, groups: &groups, model: &model, profile_settings: None, memory_group: &[] }) + .unwrap(); + let three = IcfFile::parse(&std::fs::read_to_string(&tmp).unwrap()).unwrap(); + let _ = std::fs::remove_file(&tmp); + let _ = std::fs::remove_file(format!("{}.orig", tmp.display())); + assert_eq!(used(three.image()).len(), n); + assert!(moved(&b, three.image()).iter().all(|&i| in_memory_tables(i))); + println!("rung 3: {n} memories; memory list:"); + for s in used(three.image()) { + let r = super::memory::Record::decode(&three.image()[record_offset(s)..record_offset(s) + REC_LEN]); + let bank = three.image()[super::layout::BANK_TABLE + 2 * s]; + println!(" {s:3} {:<16} {:>9.4} bank {}", r.name(), r.rx_hz() / 1e6, (b'A' + (bank & 0x1F)) as char); + } + write(&dir, "LAD3_CODEPLG.icf", &three); + + // 4. Band probe: the receive edges of both A/B ranges, plus a 2 m and a + // 70 cm simplex channel the radio can transmit on. + let probe: Vec<(f64, &str, &str)> = vec![ + (108.000, "AM", "P108.000 AM"), + (173.995, "FM", "P173.995"), + (380.000, "FM", "P380.000"), + (478.995, "FM", "P478.995"), + (146.520, "FM", "P146.520 TX"), + (446.000, "FM", "P446.000 TX"), + ]; + let pchans: Vec = probe + .iter() + .enumerate() + .map(|(i, (f, mode, name))| Channel { + id: 9000 + i as i64, + rx_freq: *f, + mode: Some((*mode).into()), + name_long: Some((*name).into()), + name_short: Some((*name).into()), + dcs_polarity: "NN".into(), + ..Default::default() + }) + .collect(); + let pecs: Vec = pchans.iter().cloned().map(ec).collect(); + let prefs: Vec<&ExpandedChannel> = pecs.iter().collect(); + let pgroups = vec![CodeplugGroup { list_id: 1, list_name: "PROBE".into(), channels: pchans.clone() }]; + let mut four = base.clone(); + let n4 = super::memory::write_memories(four.image_mut(), &prefs, &pgroups, &model).unwrap(); + assert_eq!((n4, used(four.image()).len()), (6, 6)); + assert!(moved(&b, four.image()).iter().all(|&i| in_memory_tables(i))); + for (s, (f, _, _)) in probe.iter().enumerate() { + let r = super::memory::Record::decode(&four.image()[record_offset(s)..record_offset(s) + REC_LEN]); + assert!((r.rx_hz() / 1e6 - f).abs() < 1e-6, "slot {s}"); + println!("rung 4: memory {s} {:<16} {:.4} MHz", r.name(), r.rx_hz() / 1e6); + } + write(&dir, "LAD4_BAND.icf", &four); + + // 5. Settings: two values the radio does not have now, nothing else. + let mut five = base.clone(); + let cur = super::settings::decode_settings(&b); + println!("rung 5: radio has Beep Level {} and Backlight {}", cur["sounds-beep-level"], cur["display-backlight"]); + let mut s = serde_json::Map::new(); + s.insert("sounds-beep-level".into(), "8".into()); + s.insert("display-backlight".into(), "ON".into()); + assert_ne!(cur["sounds-beep-level"], "8"); + assert_ne!(cur["display-backlight"], "ON"); + let (applied, notes) = super::settings::apply_settings(five.image_mut(), &s); + assert_eq!((applied, notes.len()), (2, 0)); + let m5 = moved(&b, five.image()); + assert_eq!(m5, vec![0x1AAB2, 0x1AABE], "only Backlight and Beep Level move"); + write(&dir, "LAD5_SETTNG.icf", &five); + println!("all five built and checked"); +} diff --git a/src-tauri/src/radios/icom_id51/layout.rs b/src-tauri/src/radios/icom_id51/layout.rs new file mode 100644 index 0000000..eb1e0c3 --- /dev/null +++ b/src-tauri/src/radios/icom_id51/layout.rs @@ -0,0 +1,71 @@ +//! Where things are in an ID-51 Plus `.icf` image. +//! +//! Offsets are CHIRP's `id51plus.py` (`ID51PLUSRadio`, "Icom ID-51 Plus/50th +//! Anniversary"), **checked against a file the radio wrote** before a line of +//! the encoder existed: all 87 memories on Tim's radio decode to plausible +//! repeaters, and Icom's own CS-51PLUS shows the same values for every one of +//! them (`scratchpad/icom_id51/FINDINGS.md`, `MEASURED.md`). The record itself is +//! the ID-31 lineage's 49 bytes, the same codec as the ID-5100's. +//! +//! | offset | shape | what | +//! |---|---|---| +//! | `0x0000` | 500 x 49 | memory records | +//! | `0x6A40` | 63 bytes used | "empty" bitmap, LSB-first — bit SET = slot empty | +//! | `0x6A86` | 63 bytes used | skip bitmap | +//! | `0x6ACB` | 63 bytes used | P-skip bitmap | +//! | `0x6B40` | 500 x 2 | `[mult: bits 5-6][bank: bits 0-4][index in bank]` | +//! | `0x6FD0` | 26 x 16 | bank names A-Z, space padded | +//! | `0x1A000`-`0x1AF00` | | menu settings ([`super::settings`]) and the radio's working state | + +/// The decoded image, `0x1FB40` bytes — CHIRP's `_memsize`, and exactly what +/// the radio's own file decodes to. +pub(crate) const IMAGE_LEN: usize = 0x1FB40; + +pub(crate) const REC_LEN: usize = 49; +/// Programmable memories 0-499. The radio's 50 scan edges and 4 call channels +/// live elsewhere and are not touched. +pub(crate) const CHANNEL_COUNT: usize = 500; + +pub(crate) const EMPTY_BITMAP: usize = 0x6A40; +pub(crate) const SKIP_BITMAP: usize = 0x6A86; +pub(crate) const PSKIP_BITMAP: usize = 0x6ACB; +/// Bytes of each bitmap that carry a memory's bit (500 / 8, rounded up). +pub(crate) const BITMAP_LEN: usize = 63; + +pub(crate) const BANK_TABLE: usize = 0x6B40; +pub(crate) const BANK_NAMES: usize = 0x6FD0; +pub(crate) const BANK_NAME_LEN: usize = 16; +pub(crate) const BANK_COUNT: usize = 26; +/// Memories one bank can index (`00`-`99`). +pub(crate) const BANK_CAPACITY: usize = 100; +/// The bank number that means "in no bank". Every empty slot on Tim's radio +/// holds `1F 00`. +pub(crate) const NO_BANK: u8 = 0x1F; + +pub(crate) const NAME_LEN: usize = 16; + +/// What the receiver covers, in MHz: the A/B-band ranges of the USA version +/// (Advanced Manual p. 19-1, "RX 108~174, 380~479"), which CHIRP's +/// `valid_bands` repeats. The broadcast receiver (AM/FM BC radio) is a separate +/// receiver with its own memories, not a memory band. +/// +/// A memory outside this is refused by the codeplug build rather than written: +/// the ID-52 dropped such memories silently while the app reported them +/// written, and the ID-5100 refused a whole clone over one and factory-reset. +/// What the ID-51 does with one in a loaded file is unmeasured, and does not +/// need to be. +pub(crate) const RX_COVERAGE: [(f64, f64); 2] = [(108.0, 174.0), (380.0, 479.0)]; + +pub(crate) const fn record_offset(slot: usize) -> usize { + slot * REC_LEN +} + +// The tables sit where CHIRP puts them and the radio's file shows them, with +// the record array ending before the first bitmap. +const _: () = assert!(CHANNEL_COUNT * REC_LEN <= EMPTY_BITMAP); +const _: () = assert!(BITMAP_LEN * 8 >= CHANNEL_COUNT); +const _: () = assert!(EMPTY_BITMAP + BITMAP_LEN <= SKIP_BITMAP); +const _: () = assert!(SKIP_BITMAP + BITMAP_LEN <= PSKIP_BITMAP); +const _: () = assert!(PSKIP_BITMAP + BITMAP_LEN <= BANK_TABLE); +const _: () = assert!(BANK_TABLE + CHANNEL_COUNT * 2 <= BANK_NAMES); +const _: () = assert!(REC_LEN == crate::radios::icom_id5100::layout::REC_LEN); diff --git a/src-tauri/src/radios/icom_id51/memory.rs b/src-tauri/src/radios/icom_id51/memory.rs new file mode 100644 index 0000000..b8a90f6 --- /dev/null +++ b/src-tauri/src/radios/icom_id51/memory.rs @@ -0,0 +1,520 @@ +//! Memories, flags and banks inside an ID-51 Plus `.icf` image. +//! +//! The 49-byte record is the ID-31 lineage's, field for field the ID-5100's — +//! measured here against Tim's own file: TONE 1 / TSQL 3 / DTCS 5, FM 0 / FM-N 1 +//! / AM 3 / DV 5, byte `+0x09` = `E4` in all 87 memories, and the bank byte's +//! bits 5-6 equal to the record's multiplier in all 87. So the codec is the +//! ID-5100's [`Record`], reused rather than copied; what differs is decided +//! here. +//! +//! ## What differs from the ID-5100 +//! +//! - **Which multiplier** is the ID-5100's rule: 5 kHz units first, 6.25 kHz +//! only for a frequency 5 kHz cannot reach. That is what the two Icom sources +//! write — the radio's own front panel (memories 58 and 82 in Tim's file, +//! 448.400 and 438.900, both 6.25-divisible and both stored in 5 kHz units) +//! and CS-51PLUS, which re-encodes every memory that way on save. 49 of Tim's +//! 87 memories are stored in 6.25 kHz units instead; they came from some other +//! tool, and decode to the same frequencies either way. +//! - **No 8.33 kHz multiplier.** The ID-5100 stores airband on an 8.33 kHz grid +//! (`mult` 2). Nothing on the ID-51 has been seen to, and CHIRP's ID-51 code +//! only knows 5 and 6.25 kHz, so a channel on neither grid is refused with a +//! reason rather than written with an unmeasured encoding. +//! - **The tuning-step table.** Index 2 is 8.33 kHz on this radio (measured in +//! CS-51PLUS, s002); the ID-5100 stores airband as 14. Every memory written +//! here takes index 0 (5 kHz), which is what the radio stored for every memory +//! it has, airband included. +//! - **The layout**: 500 memories, 26 banks, different table offsets +//! ([`super::layout`]). + +use std::collections::HashMap; + +use crate::commands::export::{expanded_names, CodeplugGroup, ExpandedChannel}; +use crate::models::RadioModel; +use crate::radios::icom_id52::memory::{ascii_field, dtcs_index, hz, pack_call, tone_index}; +use crate::radios::icom_id52::memory_csv::{call_signs, duplex_and_offset, mode_of, tone_columns}; +pub(crate) use crate::radios::icom_id5100::memory::Record; + +use super::layout::{ + record_offset, BANK_CAPACITY, BANK_COUNT, BANK_NAMES, BANK_NAME_LEN, BANK_TABLE, + CHANNEL_COUNT, EMPTY_BITMAP, NAME_LEN, NO_BANK, PSKIP_BITMAP, REC_LEN, RX_COVERAGE, + SKIP_BITMAP, +}; + +/// Multipliers in Hz, indexed by the stored `mult` value. Only 0 and 1 are +/// written; 2 is the ID-5100's 8.33 kHz grid, kept so a record carrying it still +/// decodes to the right frequency. +const MULT_HZ: [f64; 3] = [5_000.0, 6_250.0, 25_000.0 / 3.0]; + +const SQL_OFF: u8 = 0; +const SQL_TONE: u8 = 1; +const SQL_TSQL: u8 = 3; +const SQL_DTCS: u8 = 5; + +const MODE_FM: u8 = 0; +const MODE_FM_N: u8 = 1; +const MODE_AM: u8 = 3; +const MODE_DV: u8 = 5; + +/// Tuning step index 0 = 5 kHz: what the radio stored for every memory in +/// Tim's file except two the operator set to 25 kHz by hand. +const STEP_DEFAULT: u8 = 0; + +/// Byte `+0x09`, `E4` in every one of the radio's 87 memories. +const BYTE9: u8 = 0xE4; + +/// Which multiplier a frequency is stored in, and how many of them: 5 kHz +/// when it divides evenly, else 6.25 kHz. `None` when it is on neither grid. +fn multiplier_for(freq_hz: u32) -> Option<(u8, u32)> { + [0u8, 1].iter().find_map(|&m| { + let step = MULT_HZ[usize::from(m)]; + let units = f64::from(freq_hz) / step; + (units.fract() == 0.0 && units < f64::from(1u32 << 18)).then_some((m, units as u32)) + }) +} + +/// Whether a memory can hold `rx_mhz` at all — the driver's +/// `unstorable_frequency`, so `channel_fit` excludes what [`encode_channel`] +/// would refuse. +pub(crate) fn storable(rx_mhz: f64) -> Option<(u8, u32)> { + multiplier_for(hz(rx_mhz)) +} + +/// Build a record for an app channel. Errors rather than approximates when a +/// frequency or offset is on none of the radio's grids. +pub(crate) fn encode_channel(ec: &ExpandedChannel, name: &str) -> Result { + let c = &ec.channel; + let rx = hz(c.rx_freq); + let (dup, offset_mhz) = duplex_and_offset(ec); + let offset_hz = hz(offset_mhz); + let (mult, freq_units) = multiplier_for(rx).ok_or_else(|| { + format!( + "{:.5} MHz is not on the radio's 5 or 6.25 kHz channel grid", + c.rx_freq + ) + })?; + let step = MULT_HZ[usize::from(mult)]; + let offset_units = f64::from(offset_hz) / step; + if offset_units.fract() != 0.0 || offset_units > f64::from(u16::MAX) { + return Err(format!( + "a {offset_mhz:.4} MHz offset is not on the radio's {} kHz grid", + step / 1000.0 + )); + } + let duplex = match dup { + "DUP-" => 1, + "DUP+" => 2, + _ => 0, + }; + let mode = match mode_of(ec) { + "FM-N" => MODE_FM_N, + "AM" => MODE_AM, + "DV" => MODE_DV, + // WFM and anything else a memory cannot hold is FM, as on the ID-52 and + // ID-5100. + _ => MODE_FM, + }; + + let mut name_bytes = [b' '; NAME_LEN]; + ascii_field(&mut name_bytes, name); + + let mut rec = Record { + mult1: mult, + mult2: mult, + freq_units, + offset_units: offset_units as u16, + rtone: tone_index(88.5), + ctone: tone_index(88.5), + unknown1: 0, + mode, + dtcs: 0, + step: STEP_DEFAULT, + unknown2: 0, + byte9: BYTE9, + squelch: SQL_OFF, + duplex, + polarity: 0, + name: name_bytes, + dv_code: 0, + // Every analog memory on Tim's radio carries `CQCQCQ` and blank repeaters. + ur: pack_call("CQCQCQ"), + rpt1: pack_call(""), + rpt2: pack_call(""), + }; + + if mode == MODE_DV { + let (ur, rpt1, rpt2) = call_signs(ec, true); + rec.ur = pack_call(&ur); + rec.rpt1 = pack_call(&rpt1); + rec.rpt2 = pack_call(&rpt2); + // A DV memory does not read its tone fields. They keep the 88.5 Hz + // default here; the radio's own DV memories carry 94.8 (Tim's 0-2). + return Ok(rec); + } + + let t = tone_columns(ec); + rec.squelch = match t.tone { + "TONE" => SQL_TONE, + "TSQL" => SQL_TSQL, + "DTCS" | "DTCS(T)" | "DTCS(T)/TSQL(R)" => SQL_DTCS, + "OFF" => SQL_OFF, + // The cross types are not measured on this radio: keep the TRANSMIT side + // right, which is what keys the repeater (the ID-5100's choice). + _ => SQL_TONE, + }; + rec.rtone = tone_index(t.rpt_hz); + rec.ctone = tone_index(t.tsql_hz); + rec.dtcs = dtcs_index(t.dtcs.as_deref().or(c.dcs_code.as_deref())); + // CHIRP's polarity order (NN, NR, RN, RR). Only NN = 0 is measured. + rec.polarity = match c.dcs_polarity.to_uppercase().as_str() { + "NR" => 1, + "RN" => 2, + "RR" => 3, + _ => 0, + }; + Ok(rec) +} + +// ============================================================ +// Image-level: flags and banks +// ============================================================ + +#[cfg(test)] +fn bit(image: &[u8], base: usize, slot: usize) -> bool { + image[base + slot / 8] & (1 << (slot % 8)) != 0 +} + +fn set_bit(image: &mut [u8], base: usize, slot: usize, on: bool) { + let mask = 1u8 << (slot % 8); + if on { + image[base + slot / 8] |= mask; + } else { + image[base + slot / 8] &= !mask; + } +} + +/// Whether memory `slot` holds a channel. The empty bitmap is what the radio +/// reads; a cleared slot keeps its old record bytes. +#[cfg(test)] +pub(crate) fn is_used(image: &[u8], slot: usize) -> bool { + !bit(image, EMPTY_BITMAP, slot) +} + +#[cfg(test)] +pub(crate) fn read_record(image: &[u8], slot: usize) -> Record { + let at = record_offset(slot); + Record::decode(&image[at..at + REC_LEN]) +} + +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ScanSkip { + None, + Memory, + Program, +} + +/// `Skip` or `PSkip`, as CS-51PLUS shows them. A PSkip memory has both bits set +/// on this radio (memories 15/19/20/22/24 in Tim's file). +#[cfg(test)] +pub(crate) fn read_skip(image: &[u8], slot: usize) -> ScanSkip { + if bit(image, PSKIP_BITMAP, slot) { + ScanSkip::Program + } else if bit(image, SKIP_BITMAP, slot) { + ScanSkip::Memory + } else { + ScanSkip::None + } +} + +/// `(bank, index)` of a memory, or `None` when it is in no bank. +#[cfg(test)] +pub(crate) fn read_bank(image: &[u8], slot: usize) -> Option<(u8, u8)> { + let at = BANK_TABLE + slot * 2; + let bank = image[at] & 0x1F; + (bank != NO_BANK && usize::from(bank) < BANK_COUNT).then_some((bank, image[at + 1])) +} + +/// Store a channel in `slot`: record, empty/skip bits, and its bank entry. +/// +/// The bank byte is `[mult: bits 5-6][bank: bits 0-4]` — the multiplier bits +/// match the record in all 87 of Tim's memories, and CS-51PLUS clears them when +/// it re-encodes a record to 5 kHz. CHIRP writes the bare bank number. +pub(crate) fn store(image: &mut [u8], slot: usize, rec: &Record, bank: Option<(u8, u8)>) { + assert!(slot < CHANNEL_COUNT); + let at = record_offset(slot); + image[at..at + REC_LEN].copy_from_slice(&rec.encode()); + set_bit(image, EMPTY_BITMAP, slot, false); + set_bit(image, SKIP_BITMAP, slot, false); + set_bit(image, PSKIP_BITMAP, slot, false); + let (b, idx) = match bank { + Some((b, idx)) => { + assert!(usize::from(b) < BANK_COUNT && usize::from(idx) < BANK_CAPACITY); + (b, idx) + } + None => (NO_BANK, 0), + }; + let entry = BANK_TABLE + slot * 2; + image[entry] = ((rec.mult1 & 3) << 5) | b; + image[entry + 1] = idx; +} + +/// Empty `slot` the way the radio does: empty bit set, skips cleared, `1F 00` +/// bank entry (what all 413 empty slots in Tim's file hold). The record bytes +/// stay, as the radio leaves them. +pub(crate) fn clear(image: &mut [u8], slot: usize) { + set_bit(image, EMPTY_BITMAP, slot, true); + set_bit(image, SKIP_BITMAP, slot, false); + set_bit(image, PSKIP_BITMAP, slot, false); + let entry = BANK_TABLE + slot * 2; + image[entry] = NO_BANK; + image[entry + 1] = 0; +} + +#[cfg(test)] +pub(crate) fn bank_name(image: &[u8], bank: usize) -> String { + let at = BANK_NAMES + bank * BANK_NAME_LEN; + String::from_utf8_lossy(&image[at..at + BANK_NAME_LEN]).trim_end().to_string() +} + +pub(crate) fn set_bank_name(image: &mut [u8], bank: usize, name: &str) { + let at = BANK_NAMES + bank * BANK_NAME_LEN; + ascii_field(&mut image[at..at + BANK_NAME_LEN], name); +} + +// ============================================================ +// The codeplug write +// ============================================================ + +/// Overwrite the memory area — records, the three bitmaps, the bank table and +/// all 26 bank names — with `channels`, in order, from memory 0. +/// +/// Lists become banks A, B, C … in codeplug order, only for lists that received +/// a memory. A channel in two lists is in the first one's bank (a memory carries +/// one bank byte). More than 26 such lists, or more than 100 channels in one, is +/// REFUSED with a reason, as the ID-52 does: a card export has no way to show a +/// warning, and a channel silently left out of its bank is the failure a +/// warning nobody reads produces. Nothing outside those tables is +/// touched, so the call channels, scan edges, repeater list, call signs, GPS +/// memories and every MENU setting stay exactly as the radio wrote them. +/// +/// Every channel is encoded before the image is touched, so one bad channel +/// leaves nothing half-written. +pub(crate) fn write_memories( + image: &mut [u8], + channels: &[&ExpandedChannel], + groups: &[CodeplugGroup], + model: &RadioModel, +) -> Result { + if image.len() != super::layout::IMAGE_LEN { + return Err(format!( + "That is not an ID-51 Plus settings image: expected {} bytes, got {}.", + super::layout::IMAGE_LEN, + image.len() + )); + } + let capacity = model + .memory_channels + .map(|n| (n as usize).min(CHANNEL_COUNT)) + .unwrap_or(CHANNEL_COUNT); + if channels.len() > capacity { + return Err(format!( + "This codeplug has {} channels; the ID-51 holds {capacity}. Remove {} before \ + programming.", + channels.len(), + channels.len() - capacity + )); + } + + let names = expanded_names(channels.iter().copied(), model); + let mut records = Vec::with_capacity(channels.len()); + for (ec, name) in channels.iter().zip(&names) { + let f = ec.channel.rx_freq; + if !RX_COVERAGE.iter().any(|&(lo, hi)| f >= lo && f <= hi) { + return Err(format!( + "Channel \"{name}\" ({f:.4} MHz) is outside the ID-51's receive coverage \ + (108–174 and 380–479 MHz). Nothing was written." + )); + } + records.push(encode_channel(ec, name).map_err(|e| { + format!("Channel \"{name}\" ({f:.4} MHz) cannot be programmed: {e}") + })?); + } + + // Channel id -> the first list that names it. + let mut first_list: HashMap = HashMap::new(); + for (gi, g) in groups.iter().enumerate() { + for c in &g.channels { + first_list.entry(c.id).or_insert(gi); + } + } + let mut bank_names: Vec = Vec::new(); + let mut letter_of_list: HashMap = HashMap::new(); + let mut counts = [0usize; BANK_COUNT]; + let mut bank_of: Vec> = vec![None; channels.len()]; + for (slot, ec) in channels.iter().enumerate() { + let Some(&li) = first_list.get(&ec.channel.id) else { + continue; + }; + let letter = match letter_of_list.get(&li) { + Some(&l) => l, + None => { + if bank_names.len() == BANK_COUNT { + return Err(format!( + "This codeplug's channels come from more than {BANK_COUNT} channel lists, \ + and the ID-51 has {BANK_COUNT} banks (A-Z). Combine or unassign lists \ + — \"{}\" is the first that does not fit.", + groups[li].list_name + )); + } + bank_names.push(groups[li].list_name.clone()); + let l = (bank_names.len() - 1) as u8; + letter_of_list.insert(li, l); + l + } + }; + let idx = counts[usize::from(letter)]; + if idx == BANK_CAPACITY { + return Err(format!( + "List \"{}\" has more than {BANK_CAPACITY} channels, and an ID-51 bank holds \ + {BANK_CAPACITY}. Split it into two lists.", + groups[li].list_name + )); + } + counts[usize::from(letter)] += 1; + bank_of[slot] = Some((letter, idx as u8)); + } + + for (slot, rec) in records.iter().enumerate() { + store(image, slot, rec, bank_of[slot]); + } + for slot in channels.len()..CHANNEL_COUNT { + clear(image, slot); + } + for bank in 0..BANK_COUNT { + set_bank_name(image, bank, bank_names.get(bank).map(String::as_str).unwrap_or("")); + } + Ok(records.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Channel; + + fn ec(c: Channel) -> ExpandedChannel { + ExpandedChannel { + channel: c, + tg_label: None, + timeslot: None, + tg_number: None, + tg_call_type: None, + tg_inline: false, + } + } + + fn ch(id: i64, rx: f64) -> Channel { + Channel { + id, + rx_freq: rx, + mode: Some("FM".into()), + dcs_polarity: "NN".into(), + ..Default::default() + } + } + + /// 5 kHz first (the radio's front panel and CS-51PLUS), 6.25 kHz only when + /// 5 kHz cannot reach the frequency, and nothing off both grids. + #[test] + fn multiplier_follows_the_radio() { + assert_eq!(multiplier_for(448_400_000), Some((0, 89_680)), "memory 58, front panel"); + assert_eq!(multiplier_for(146_625_000), Some((0, 29_325))); + assert_eq!(multiplier_for(145_115_000), Some((0, 29_023))); + assert_eq!(multiplier_for(446_812_500), Some((1, 71_490)), "CSU DSTAR, 6.25 kHz only"); + assert_eq!(multiplier_for(118_400_000), Some((0, 23_680))); + assert_eq!(multiplier_for(118_008_333), None, "an 8.33 kHz channel"); + } + + /// Memory 1 on Tim's radio, `CSU DSTAR`: 446.8125 DV (6.25 kHz units — 5 kHz + /// cannot reach it), DUP− 5 MHz, UR CQCQCQ, R1 `W0QEY B`, R2 `W0QEY G`. + /// Re-encoded from the channel the app would hold, it is the radio's own 49 + /// bytes but for the two tone fields a DV memory does not read. + #[test] + fn a_dv_memory_re_encodes_to_the_radios_bytes() { + let radio = hex("251742032028a50000e404435355204453544152202020202020200087461d18745020aec28c5b281042aec28c5b281047"); + let mut c = ch(1, 446.8125); + c.offset = Some(5.0); + c.duplex = Some("-".into()); + c.mode = Some("DV".into()); + c.dstar_capable = true; + c.dstar_ur_call = Some("CQCQCQ".into()); + c.dstar_rpt1 = Some("W0QEY B".into()); + c.dstar_rpt2 = Some("W0QEY G".into()); + let mut rec = encode_channel(&ec(c), "CSU DSTAR").unwrap(); + // Bytes 5-6: the radio left 94.8 Hz in this DV memory's unused tone + // fields; a new one gets the 88.5 Hz default. + let theirs = Record::decode(&radio); + assert_eq!((rec.rtone, rec.ctone), (tone_index(88.5), tone_index(88.5))); + rec.rtone = theirs.rtone; + rec.ctone = theirs.ctone; + assert_eq!(rec.encode().to_vec(), radio); + } + + fn hex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() + } + + /// Lists become banks in codeplug order, a channel in two lists lands in the + /// first, unused slots are emptied, and the bank byte carries the + /// multiplier. + #[test] + fn write_memories_places_banks_and_clears_the_rest() { + let mut image = vec![0u8; super::super::layout::IMAGE_LEN]; + let a = ch(1, 446.8125); + let b = ch(2, 145.115); + let groups = vec![ + CodeplugGroup { list_id: 10, list_name: "Local".into(), channels: vec![a.clone()] }, + CodeplugGroup { list_id: 11, list_name: "Both".into(), channels: vec![a.clone(), b.clone()] }, + ]; + let ea = ec(a); + let eb = ec(b); + let model = RadioModel { memory_channels: Some(500), max_name_length: Some(16), ..Default::default() }; + let n = write_memories(&mut image, &[&ea, &eb], &groups, &model).unwrap(); + assert_eq!(n, 2); + assert_eq!(read_bank(&image, 0), Some((0, 0))); + assert_eq!(read_bank(&image, 1), Some((1, 0))); + assert_eq!(image[BANK_TABLE] >> 5, 1, "446.8125 is a 6.25 kHz record"); + assert_eq!(image[BANK_TABLE + 2] >> 5, 0, "145.115 is a 5 kHz record"); + assert!(is_used(&image, 0) && is_used(&image, 1) && !is_used(&image, 2)); + assert_eq!(bank_name(&image, 0), "Local"); + assert_eq!(bank_name(&image, 1), "Both"); + assert_eq!(bank_name(&image, 2), ""); + assert_eq!(&image[BANK_TABLE + 4..BANK_TABLE + 6], &[NO_BANK, 0]); + } + + /// `channel_fit` excludes an 8.33 kHz channel with a reason, so it never + /// reaches the encoder (which would fail the whole export on it). + #[test] + fn an_833_khz_channel_is_excluded_upstream_with_a_reason() { + let model = crate::seed::test_model("ID-51 Plus"); + let mut c = ch(1, 118.008333); + c.mode = Some("AM".into()); + let why = crate::commands::export::exclusion_reason(&c, &model).expect("excluded"); + assert!(why.contains("6.25 kHz"), "{why}"); + let mut ok = ch(2, 118.400); + ok.mode = Some("AM".into()); + assert_eq!(crate::commands::export::exclusion_reason(&ok, &model), None); + } + + /// Outside the receiver's coverage is refused before anything is written. + #[test] + fn a_channel_outside_coverage_is_refused_and_nothing_moves() { + let mut image = vec![0u8; super::super::layout::IMAGE_LEN]; + let before = image.clone(); + let x = ec(ch(1, 222.10)); + let model = RadioModel { memory_channels: Some(500), ..Default::default() }; + let err = write_memories(&mut image, &[&x], &[], &model).unwrap_err(); + assert!(err.contains("outside"), "{err}"); + assert_eq!(image, before); + } +} diff --git a/src-tauri/src/radios/icom_id51/mod.rs b/src-tauri/src/radios/icom_id51/mod.rs new file mode 100644 index 0000000..199d35e --- /dev/null +++ b/src-tauri/src/radios/icom_id51/mod.rs @@ -0,0 +1,240 @@ +//! Icom ID-51 Plus driver (`driver_key = "icom_id51"`, issue #50) — microSD +//! programming. +//! +//! The radio saves and loads everything through one file on its card: +//! `MENU > SD Card > Save Setting` writes `ID-51/Setting/Setyyyymmdd_xx.icf` +//! ("memory channels, item settings in the menu screen, and Repeater List", +//! Advanced Manual p. 2-13), and `Load Setting > ALL` reads one back. So the +//! codeplug export patches a copy of the radio's own file — memories, banks and +//! the profile's MENU settings together — and writes it beside the original as +//! a NEW file named the way the radio names its own. Nothing the operator saved +//! is modified. +//! +//! The ID-51 Plus can also clone over a cable, but Tim chose the card for this +//! radio (2026-11-04), and there is no cable path here. +//! +//! - [`layout`] — offsets (CHIRP's `id51plus.py`, checked against Tim's file) +//! - [`memory`] — records, flags, banks; the ID-5100's 49-byte codec, this +//! radio's rules +//! - [`settings`] — 241 MENU settings + the manual position, every one measured +//! in CS-51PLUS +//! - the `.icf` envelope and its `#CD` MD5 are the ID-52's ([`IcfFile`]) — the +//! radio's file reproduces its checksum under that algorithm +//! +//! Working notes: `scratchpad/icom_id51/` (PLAN, FINDINGS, MEASURED; gitignored). +//! +//! ## Hardware-proven (2026-11-07, Tim's ID-51 Plus 50th Anniversary) +//! +//! The ladder in [`ladder`] passed every rung, each file loaded with +//! `Load Setting > ALL`: an identity copy; a one-field rename (proves the +//! `#CD`); a full 50-memory codeplug in two named banks through the real +//! exporter; a band probe at 108.000 AM / 173.995 / 380.000 / 478.995 plus 2 m +//! and 70 cm (all six landed); and two settings (Backlight, Beep Level) shown on +//! the radio's own menus. + +pub(crate) mod layout; +pub(crate) mod memory; +pub(crate) mod settings; +#[cfg(test)] +mod dev_export; +#[cfg(test)] +mod ladder; +#[cfg(test)] +mod real_file; + +use crate::radios::driver::{ + CardSettingsWriter, CodeplugExporter, ExportRequest, RadioDriver, RadioIdentity, +}; +use crate::radios::icom_id52::icf::IcfFile; + +/// Line 1 of the radio's `.icf`: the ID-51 **Plus** (and the 50th Anniversary +/// edition, which CHIRP's driver names alongside it). The original ID-51 is +/// `33900001` and the Plus2 `33900003`; neither has been measured. +pub(crate) const ID51_MODEL_ID: &str = "33900002"; + +/// `#MapRev` of the file the radio writes, and the only one these offsets were +/// measured against. +pub(crate) const ID51_MAP_REV: u32 = 1; + +/// Refuse a file this driver's measured offsets do not describe, saying which +/// radio it came from. +pub(crate) fn check_is_an_id51_file(icf: &IcfFile) -> Result<(), String> { + match icf.model_id() { + ID51_MODEL_ID => {} + "33900001" => { + return Err("That settings file is from an original ID-51 (not the Plus). This app \ + has only been measured against the ID-51 Plus, and the two lay their \ + memories out differently." + .into()) + } + "33900003" => { + return Err("That settings file is from an ID-51 Plus2. This app has only been \ + measured against the ID-51 Plus." + .into()) + } + other => { + return Err(format!( + "That settings file belongs to a different Icom (model {other}, not the ID-51 \ + Plus's {ID51_MODEL_ID}). Use a file saved by this radio." + )) + } + } + if icf.map_rev() != Some(ID51_MAP_REV) { + return Err(format!( + "This settings file is layout revision {}, and this app only writes revision \ + {ID51_MAP_REV} — the one the radio saves for itself. Save a fresh file on the radio \ + with MENU > SD Card > Save Setting and use that.", + icf.map_rev().map_or_else(|| "unknown".to_string(), |r| r.to_string()) + )); + } + if icf.image().len() != layout::IMAGE_LEN { + return Err(format!( + "This settings file holds {} bytes; an ID-51 Plus file holds {}.", + icf.image().len(), + layout::IMAGE_LEN + )); + } + Ok(()) +} + +pub(crate) struct IcomId51; + +pub(crate) static DRIVER: IcomId51 = IcomId51; + +impl RadioDriver for IcomId51 { + /// `memory.rs`: FM, FM-N and AM memories. + fn analog_modes(&self) -> &'static [&'static str] { + &["FM", "NFM", "AM"] + } + + fn key(&self) -> &'static str { + "icom_id51" + } + + /// Off the 5 and 6.25 kHz grids — in practice an 8.33 kHz airband channel, + /// which no ID-51 memory has been seen to store. + fn unstorable_frequency(&self, rx_mhz: f64) -> Option { + memory::storable(rx_mhz) + .is_none() + .then(|| "is not on the 5 or 6.25 kHz channel grid a memory can store".to_string()) + } + + fn display_name(&self) -> &'static str { + "Icom ID-51 Plus" + } + + /// Unused: there is no cable session. The family's CI-V default is quoted + /// so the number is not invented. + fn baud(&self) -> u32 { + 9600 + } + + fn identify(&self, _port: &str) -> Result { + Err("The ID-51 Plus is programmed from its microSD card here, not over a cable. Save \ + its settings to the card (MENU > SD Card > Save Setting), put the card in this \ + computer, and use the card actions instead." + .into()) + } + + fn as_codeplug_exporter(&self) -> Option<&dyn CodeplugExporter> { + Some(self) + } +} + +/// The ID-51 Plus's card folder, for the shared `.icf` file rules +/// ([`crate::radios::icom_card`]). +pub(crate) const ID51_CARD: crate::radios::icom_card::IcfCard = crate::radios::icom_card::IcfCard { + radio: "ID-51 Plus", + folder: "ID-51/Setting/", + save_menu: "MENU > SD Card > Save Setting", + is_ours: |f| check_is_an_id51_file(f).is_ok(), +}; + +impl CodeplugExporter for IcomId51 { + fn export_format(&self) -> &'static str { + "icom_id51_icf" + } + + fn target_extensions(&self) -> &'static [&'static str] { + &["icf"] + } + + fn export(&self, path: &str, req: &ExportRequest) -> Result { + self.export_noting(path, req).map(|(n, _)| n) + } + + /// Memories, banks and the profile's settings, patched into one `.icf` + /// (`Load Setting > ALL` restores all of it in one operation). Every + /// setting the encoder declined comes back as a note for the report. + fn export_noting(&self, path: &str, req: &ExportRequest) -> Result<(usize, Vec), String> { + ID51_CARD.patch_icf(path, |icf| { + check_is_an_id51_file(icf)?; + let written = memory::write_memories(icf.image_mut(), req.channels, req.groups, req.model)?; + // Settings ride in the same file. An empty profile leaves the radio's + // settings exactly as its file has them. + let mut notes = Vec::new(); + if let Some(json) = req.profile_settings { + let parsed: serde_json::Value = serde_json::from_str(json) + .map_err(|e| format!("This profile's settings are not valid JSON: {e}"))?; + if let Some(map) = parsed.as_object() { + notes = settings::apply_settings(icf.image_mut(), map).1; + } + } + Ok((written, notes)) + }) + } + + fn as_card_settings_writer(&self) -> Option<&dyn CardSettingsWriter> { + Some(self) + } + + /// A folder means "make me a new file in here", named the way the radio + /// names its own so it sorts into place on the Load Setting screen. + fn resolve_target(&self, path: &str) -> Result { + ID51_CARD.resolve_target(path) + } +} + +impl CardSettingsWriter for IcomId51 { + fn export_settings( + &self, + path: &str, + settings: &serde_json::Map, + ) -> Result { + self.export_settings_noting(path, settings).map(|(n, _)| n) + } + + /// The export's file rules, with only the settings applied. + fn export_settings_noting( + &self, + path: &str, + settings: &serde_json::Map, + ) -> Result<(usize, Vec), String> { + ID51_CARD.patch_icf(path, |icf| { + check_is_an_id51_file(icf)?; + Ok(settings::apply_settings(icf.image_mut(), settings)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::radios::driver::DriverCapabilities; + + /// The card file and nothing else: no cable session is claimed. + #[test] + fn advertises_the_card_file_only() { + let caps = DriverCapabilities::of(&DRIVER); + assert!(caps.export && caps.write_card_settings); + assert!(!caps.download_image && !caps.program_image && !caps.read_settings && !caps.write_settings); + } + + #[test] + fn identify_explains_the_card_path_instead_of_opening_a_port() { + let Err(err) = DRIVER.identify("/dev/cu.nonesuch") else { + panic!("identify claimed to reach a radio over a cable"); + }; + assert!(err.contains("microSD"), "{err}"); + } +} diff --git a/src-tauri/src/radios/icom_id51/real_file.rs b/src-tauri/src/radios/icom_id51/real_file.rs new file mode 100644 index 0000000..b423f60 --- /dev/null +++ b/src-tauri/src/radios/icom_id51/real_file.rs @@ -0,0 +1,221 @@ +//! The gates, against the one file Tim's radio wrote +//! (`scratchpad/icom_id51/id51_01_base.icf`, gitignored — it is his radio). +//! `#[ignore]`d for CI; running one without the file FAILS rather than passing +//! having asserted nothing (#89). +//! +//! ```sh +//! cargo test --lib icom_id51::real_file -- --ignored --nocapture +//! ``` + +use crate::commands::export::ExpandedChannel; +use crate::models::Channel; +use crate::radios::icom_id52::icf::IcfFile; +use crate::radios::icom_id52::memory::{DTCS_CODES, TONES_DHZ}; + +use super::layout::{record_offset, BANK_TABLE, CHANNEL_COUNT, IMAGE_LEN, REC_LEN}; +use super::memory::{encode_channel, is_used, read_bank, read_record, read_skip, store, Record, ScanSkip}; + +fn file() -> (String, IcfFile) { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../scratchpad/icom_id51/id51_01_base.icf"); + let text = std::fs::read_to_string(path).unwrap_or_else(|e| { + panic!("this test needs the radio's own file at {path} ({e}); scratchpad/ is gitignored") + }); + let icf = IcfFile::parse(&text).expect("the radio's file parses and its #CD verifies"); + (text, icf) +} + +/// The envelope: the radio's model, revision and length, its checksum +/// reproduced, and an untouched file re-rendered byte for byte. +#[test] +#[ignore = "needs scratchpad/icom_id51/id51_01_base.icf"] +fn the_radios_file_round_trips_byte_for_byte() { + let (text, icf) = file(); + super::check_is_an_id51_file(&icf).unwrap(); + assert_eq!(icf.image().len(), IMAGE_LEN); + assert_eq!(icf.render(), text); +} + +/// Every one of the 500 records — used or not — decodes and re-encodes to the +/// same 49 bytes. +#[test] +#[ignore = "needs scratchpad/icom_id51/id51_01_base.icf"] +fn every_record_re_encodes_byte_identically() { + let (_, icf) = file(); + let image = icf.image(); + for slot in 0..CHANNEL_COUNT { + let at = record_offset(slot); + let raw = &image[at..at + REC_LEN]; + assert_eq!(Record::decode(raw).encode(), raw, "record {slot}"); + } +} + +/// Storing each used memory back through `store` reproduces the radio's bank +/// entries, multiplier bits included. A skipped memory differs in its skip bits +/// only (`store` is for new channels), and memory 58's `1F FF` — the radio's +/// own form for a memory it was never asked to bank — comes back `1F 00`. +#[test] +#[ignore = "needs scratchpad/icom_id51/id51_01_base.icf"] +fn storing_the_radios_own_memories_reproduces_its_tables() { + let (_, icf) = file(); + let image = icf.image().to_vec(); + let mut rebuilt = image.clone(); + let mut n = 0; + for slot in (0..CHANNEL_COUNT).filter(|&s| is_used(&image, s)) { + store(&mut rebuilt, slot, &read_record(&image, slot), read_bank(&image, slot)); + let e = BANK_TABLE + 2 * slot; + if image[e + 1] == 0xFF && image[e] & 0x1F == 0x1F { + assert_eq!(rebuilt[e..e + 2], [image[e], 0], "memory {slot}"); + } else { + assert_eq!(rebuilt[e..e + 2], image[e..e + 2], "memory {slot}: bank entry"); + } + n += 1; + } + let skipped = (0..CHANNEL_COUNT) + .filter(|&s| is_used(&image, s) && read_skip(&image, s) != ScanSkip::None) + .count(); + let differing: Vec = (0..IMAGE_LEN).filter(|&i| image[i] != rebuilt[i]).collect(); + // Skip bits share bitmap bytes, so a count of bytes is at most the number of + // skip bits cleared, plus memory 58's index byte. + assert!(differing.iter().all(|&i| (0x6A86..0x6B40).contains(&i) || (BANK_TABLE..BANK_TABLE + 1000).contains(&i))); + assert_eq!(n, 87); + println!("{n} memories re-stored; {} bytes differ ({skipped} skipped memories)", differing.len()); +} + +/// Eight 7-bit characters packed into 7 bytes, MSB first — the inverse of the +/// ID-52's `pack_call`. +fn unpack_call(p: &[u8; 7]) -> String { + let v = p.iter().fold(0u64, |acc, &b| (acc << 8) | u64::from(b)); + (0..8).map(|i| char::from(((v >> (7 * (7 - i))) & 0x7F) as u8)).collect() +} + +fn tone_hz(idx: u8) -> f64 { + f64::from(TONES_DHZ[usize::from(idx)]) / 10.0 +} + +/// The app channel this memory would be, as an operator would have entered it. +fn channel_of(rec: &Record, id: i64) -> Channel { + let rx = rec.rx_hz() / 1e6; + let off = rec.offset_hz() / 1e6; + let mut c = Channel { + id, + rx_freq: rx, + offset: Some(off), + duplex: Some(match rec.duplex { 1 => "-", 2 => "+", _ => "" }.into()), + dcs_polarity: ["NN", "NR", "RN", "RR"][usize::from(rec.polarity & 3)].into(), + ..Default::default() + }; + c.mode = Some(match rec.mode { 1 => "NFM", 3 => "AM", 5 => "DV", _ => "FM" }.into()); + match rec.squelch { + 1 => { + c.tone_mode = Some("Tone".into()); + c.ctcss_uplink = Some(tone_hz(rec.rtone)); + } + 3 => { + c.tone_mode = Some("TSQL".into()); + c.ctcss_uplink = Some(tone_hz(rec.ctone)); + c.ctcss_downlink = Some(tone_hz(rec.ctone)); + } + 5 => { + c.tone_mode = Some("DTCS".into()); + c.dcs_code = Some(format!("{:03}", DTCS_CODES[usize::from(rec.dtcs)])); + } + _ => {} + } + if rec.mode == 5 { + let un = |p: &[u8; 7]| unpack_call(p); + c.dstar_capable = true; + c.dstar_ur_call = Some(un(&rec.ur)); + c.dstar_rpt1 = Some(un(&rec.rpt1)); + c.dstar_rpt2 = Some(un(&rec.rpt2)); + } + c +} + +/// ★ The step-3 gate: each of the radio's 87 memories, turned into the channel +/// the app would hold and encoded, gives back the radio's own bytes in every +/// field the radio reads. +/// +/// The frequency is compared as a VALUE, and as bytes only where the memory was +/// made on the radio's front panel (bank entry `1F FF`: memories 58 and 82) — +/// those are the radio's own encoding choice, which the encoder must match. The +/// other memories came from a tool that preferred 6.25 kHz units; CS-51PLUS and +/// the front panel prefer 5 kHz, and both decode the same. +/// +/// Not compared, because the radio ignores them or the operator chose them: +/// the tuning step (two memories set to 25 kHz by hand; new ones get 5 kHz), +/// the tone field the squelch type does not use, and the tone fields of a DV +/// memory (the radio leaves its 94.8 there; a new one gets 88.5). +#[test] +#[ignore = "needs scratchpad/icom_id51/id51_01_base.icf"] +fn the_radios_memories_re_encode_from_the_channels_they_describe() { + let (_, icf) = file(); + let image = icf.image(); + let mut checked = 0; + let mut front_panel = 0; + for slot in (0..CHANNEL_COUNT).filter(|&s| is_used(image, s)) { + let radio = read_record(image, slot); + let ec = ExpandedChannel { + channel: channel_of(&radio, slot as i64), + tg_label: None, + timeslot: None, + tg_number: None, + tg_call_type: None, + tg_inline: false, + }; + let ours = encode_channel(&ec, &radio.name()).unwrap_or_else(|e| panic!("memory {slot}: {e}")); + let ctx = format!("memory {slot} {:?} {:.5}", radio.name(), radio.rx_hz() / 1e6); + assert!((ours.rx_hz() - radio.rx_hz()).abs() < 1.0, "{ctx}: frequency"); + assert!((ours.offset_hz() - radio.offset_hz()).abs() < 1.0, "{ctx}: offset"); + assert_eq!(ours.mult1, ours.mult2, "{ctx}: one multiplier"); + let entry = BANK_TABLE + 2 * slot; + if image[entry + 1] == 0xFF { + assert_eq!((ours.mult1, ours.freq_units, ours.offset_units), (radio.mult1, radio.freq_units, radio.offset_units), "{ctx}: the front panel's own encoding"); + front_panel += 1; + } + assert_eq!((ours.mode, ours.duplex, ours.squelch), (radio.mode, radio.duplex, radio.squelch), "{ctx}: mode/duplex/squelch"); + assert_eq!(ours.name, radio.name, "{ctx}: name"); + assert_eq!((ours.byte9, ours.unknown1, ours.unknown2), (radio.byte9, radio.unknown1, radio.unknown2), "{ctx}: unknowns"); + match radio.squelch { + 1 => assert_eq!(ours.rtone, radio.rtone, "{ctx}: repeater tone"), + 3 => assert_eq!(ours.ctone, radio.ctone, "{ctx}: TSQL tone"), + 5 => assert_eq!((ours.dtcs, ours.polarity), (radio.dtcs, radio.polarity), "{ctx}: DTCS"), + _ => {} + } + assert_eq!((ours.ur, ours.rpt1, ours.rpt2, ours.dv_code), (radio.ur, radio.rpt1, radio.rpt2, radio.dv_code), "{ctx}: D-STAR"); + checked += 1; + } + assert_eq!(checked, 87); + assert!(front_panel >= 1, "no front-panel memory to hold the multiplier rule against"); + println!("{checked} memories re-encoded from their channels, every live field equal; {front_panel} front-panel memories byte-equal in frequency"); +} + +/// Programming the radio with the settings just read off it changes nothing: +/// all 241 fields plus the manual position decode and apply back with zero +/// bytes moved. +#[test] +#[ignore = "needs scratchpad/icom_id51/id51_01_base.icf"] +fn applying_the_settings_read_off_the_radio_changes_nothing() { + let (_, icf) = file(); + let image = icf.image().to_vec(); + let decoded = super::settings::decode_settings(&image); + let mut again = image.clone(); + let (n, notes) = super::settings::apply_settings(&mut again, decoded.as_object().unwrap()); + let moved: Vec = (0..IMAGE_LEN) + .filter(|&i| image[i] != again[i]) + .map(|i| format!("{i:#x}:{:02x}->{:02x}", image[i], again[i])) + .collect(); + assert!(moved.is_empty(), "applying its own settings moved {moved:?} (notes {notes:?})"); + assert!(notes.is_empty(), "{notes:?}"); + println!("{n} settings re-applied, zero bytes moved"); +} + +/// Print every decoded setting — the sheet Tim reads his radio's menus against. +#[test] +#[ignore = "needs scratchpad/icom_id51/id51_01_base.icf"] +fn dump_decoded_settings() { + let (_, icf) = file(); + let v = super::settings::decode_settings(icf.image()); + for (k, val) in v.as_object().unwrap() { + println!("{k} = {val}"); + } +} diff --git a/src-tauri/src/radios/icom_id51/settings.rs b/src-tauri/src/radios/icom_id51/settings.rs new file mode 100644 index 0000000..b32511f --- /dev/null +++ b/src-tauri/src/radios/icom_id51/settings.rs @@ -0,0 +1,189 @@ +//! ID-51 Plus MENU settings: the field table, read out of the radio's own +//! `.icf` and written back into one. +//! +//! The byte rules are Icom's — big-endian integers, space- or FF-padded text, +//! off = 0 / on = 1 — so the encoder is the ID-52's, and the extras the table cannot express (the +//! 12-byte manual-position record, the MY call sign layout, the sentence +//! limits) are the ID-5100's, shared through its [`ApplyRules`]. Only the +//! table is this radio's. +//! +//! ## Where the table came from +//! +//! GENERATED by `scratchpad/icom_id51/gen_table.py` from `MEASURED.md`. Every +//! address was measured by driving Icom's CS-51PLUS inside a VM — move one +//! control, save, diff — and every label read off CS-51PLUS's own dropdown. The +//! instrument was validated first: two untouched CS saves are byte-identical, +//! and opening the radio's own file and saving it changes no setting byte. +//! Each pass's final state was read back off CS's screen against the decode, +//! which caught nothing wrong across ~200 fields. + +use serde_json::{Map, Value}; + +use crate::radios::icom_id52::settings::{SF, SK}; +use crate::radios::icom_id5100::settings::{apply_with, decode_with, ApplyRules, Limit}; + +include!("id51_settings_table.rs"); + +/// The GPS Manual Position record — the ID-5100's 12-byte layout exactly, +/// measured here by typing 40°35.12'N 105°04.56'W into CS-51PLUS (g015-g018): +/// `28 89 30 'N' 69 11 D0 'W'`, then altitude as i32 BE tenths of a foot, +/// `7FFFFFFF` = blank. +const POSITIONS: &[(&str, usize)] = &[("gps-manual-position", 0x1AB6C)]; + +/// The sentence limits CS-5100 enforces on the ID-5100 (at most 4 NMEA +/// sentences, at least 1 logger sentence), kept here as the conservative rule. +/// ⚠ NOT measured on the ID-51: every CS-51PLUS sentence pass stayed inside +/// them on purpose, so neither the refusal nor its absence has been seen. A +/// profile outside them leaves that group as the radio has it, and the export +/// report says so. +const LIMITS: &[Limit] = &[ + ( + "GPS TX (NMEA) sentences", + &[ + "gps-tx-nmea-sentence-rmc", + "gps-tx-nmea-sentence-gga", + "gps-tx-nmea-sentence-gll", + "gps-tx-nmea-sentence-gsa", + "gps-tx-nmea-sentence-vtg", + "gps-tx-nmea-sentence-gsv", + ], + 0, + 4, + ), + ( + "GPS Logger record sentences", + &[ + "gps-logger-record-sentence-rmc", + "gps-logger-record-sentence-gga", + "gps-logger-record-sentence-vtg", + "gps-logger-record-sentence-gsa", + ], + 1, + 4, + ), +]; + +pub(crate) const ID51_RULES: ApplyRules = ApplyRules { + radio: "ID-51", + fields: ID51_SETTINGS_FIELDS, + positions: POSITIONS, + limits: LIMITS, + // CS-51PLUS's MY call layout for a suffix is unmeasured (m010 typed a plain + // call): store what the operator typed, uppercased. + call_sign_layout: false, +}; + +/// Every setting the table knows, decoded out of the radio's `.icf` image and +/// shaped like the profile form. +pub(crate) fn decode_settings(image: &[u8]) -> Value { + decode_with(&ID51_RULES, image) +} + +/// Write a profile's settings into the image. Returns how many were applied +/// and a note for each one that could not be (an unknown key, a refused sentence +/// combination, an unparseable position). A blank field means "leave alone". +pub(crate) fn apply_settings(image: &mut [u8], settings: &Map) -> (usize, Vec) { + apply_with(&ID51_RULES, image, &Value::Object(settings.clone())) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Whole bytes a field owns; bit fields own none (they share their byte). + fn len(f: &SF) -> usize { + match &f.kind { + SK::BoolBit { .. } | SK::EnumBits { .. } => 0, + _ => crate::radios::icom_id52::settings::field_end(f) - f.byte as usize, + } + } + + /// No two whole-byte fields claim the same byte, and no two bit fields the + /// same bit — a misread pass would show up as a collision. + #[test] + fn no_two_settings_share_a_byte_or_a_bit() { + let mut bytes = std::collections::HashMap::new(); + let mut bits = std::collections::HashMap::new(); + for f in ID51_SETTINGS_FIELDS { + for b in f.byte as usize..f.byte as usize + len(f) { + if let Some(o) = bytes.insert(b, f.key) { + panic!("{} and {o} both claim {b:#x}", f.key); + } + } + let span = match &f.kind { + SK::BoolBit { shift } => Some((*shift, 1)), + SK::EnumBits { shift, bits, .. } => Some((*shift, *bits)), + _ => None, + }; + if let Some((shift, n)) = span { + assert!(!bytes.contains_key(&(f.byte as usize)), "{} shares a whole-byte field's byte", f.key); + for bit in shift..shift + n { + if let Some(o) = bits.insert((f.byte, bit), f.key) { + panic!("{} and {o} both claim bit {bit} of {:#x}", f.key, f.byte); + } + } + } + } + // Bit fields sharing a byte with a whole-byte field would be overwritten. + for (byte, _) in bits.keys() { + assert!(!bytes.contains_key(&(*byte as usize)), "bit field byte {byte:#x} is also a whole-byte field"); + } + } + + /// The form and the table come out of one generator run; this holds them + /// together, plus the three position fields the codec adds. + #[test] + fn schema_and_table_cover_the_same_keys() { + let schema: Vec = serde_json::from_str(crate::seed::ID51_SETTINGS_SCHEMA).unwrap(); + let mut form: Vec<&str> = schema + .iter() + .filter(|f| f["type"] != "section") + .map(|f| f["key"].as_str().unwrap()) + .collect(); + let mut table: Vec = ID51_SETTINGS_FIELDS.iter().map(|f| f.key.to_string()).collect(); + for (k, _) in POSITIONS { + for s in ["latitude", "longitude", "altitude-m"] { + table.push(format!("{k}-{s}")); + } + } + form.sort_unstable(); + table.sort_unstable(); + assert_eq!(form, table); + } + + /// Every field sits in the settings block or the two text tables, inside + /// the image. + #[test] + fn every_field_is_inside_the_image() { + for f in ID51_SETTINGS_FIELDS { + assert!(f.byte as usize + len(f).max(1) <= super::super::layout::IMAGE_LEN, "{}", f.key); + } + } + + /// A MY call sign is stored as typed (uppercased) — not rearranged by the + /// ID-5100's measured suffix rule, which nobody has checked on this radio. + #[test] + fn my_call_signs_are_stored_as_typed() { + let mut image = vec![0u8; super::super::layout::IMAGE_LEN]; + let mut m = Map::new(); + m.insert("my-station-my-call-sign-m01".into(), Value::from("ww8l-7")); + let (n, notes) = apply_settings(&mut image, &m); + assert_eq!((n, notes.len()), (1, 0)); + assert_eq!(&image[0x1519E..0x151A6], b"WW8L-7 "); + } + + /// The bit fields that share `0x1AB34` are read-modify-written: setting + /// ATT(AM) leaves ATT(AIR), ATT(FM) and the unidentified low nibble alone. + #[test] + fn att_am_is_written_inside_its_two_bits() { + let mut image = vec![0u8; super::super::layout::IMAGE_LEN]; + image[0x1AB34] = 0b1100_1111; // AIR on, FM on, AM OFF, nibble F + let mut m = Map::new(); + m.insert("bc-radio-att-am".into(), Value::from("50dB")); + let (n, notes) = apply_settings(&mut image, &m); + assert_eq!((n, notes.len()), (1, 0)); + assert_eq!(image[0x1AB34], 0b1111_1111); + assert_eq!(decode_settings(&image)["bc-radio-att-am"], "50dB"); + assert_eq!(decode_settings(&image)["others-att-air"], true); + } +} diff --git a/src-tauri/src/radios/icom_id5100/settings.rs b/src-tauri/src/radios/icom_id5100/settings.rs index 42f028b..86ccf30 100644 --- a/src-tauri/src/radios/icom_id5100/settings.rs +++ b/src-tauri/src/radios/icom_id5100/settings.rs @@ -35,9 +35,42 @@ include!("id5100_settings_table.rs"); /// Every setting the table knows, decoded out of a clone image and shaped like /// the profile form. pub(crate) fn decode(image: &[u8]) -> Value { - let mut v = decode_settings_with(ID5100_SETTINGS_FIELDS, image); + decode_with(&ID5100_RULES, image) +} + +/// What a radio's settings write needs beyond its field table: the 12-byte +/// position records the table cannot express, and the checkbox groups whose +/// combination the radio restricts. The ID-51 (#50) shares this codec and these +/// helpers with its own rules — its manual position is the same 12-byte layout. +pub(crate) struct ApplyRules { + /// The radio's name as the operator knows it, for notes. + pub radio: &'static str, + pub fields: &'static [SF], + pub positions: &'static [(&'static str, usize)], + pub limits: &'static [Limit], + /// Rewrite MY call signs into CS-5100's measured layout (suffix in the 8th + /// column, `-` and stray characters to spaces). Only the ID-5100 measured + /// it; a radio that has not stores the call as typed, uppercased. + pub call_sign_layout: bool, +} + +/// `(group name, keys, min ticked, max ticked)`. +pub(crate) type Limit = (&'static str, &'static [&'static str], usize, usize); + +pub(crate) const ID5100_RULES: ApplyRules = ApplyRules { + radio: "ID-5100", + fields: ID5100_SETTINGS_FIELDS, + positions: POSITIONS, + limits: LIMITS, + call_sign_layout: true, +}; + +/// [`decode`] for any radio's rules: the table, plus each position record +/// as decimal-degree latitude/longitude and an altitude in metres. +pub(crate) fn decode_with(rules: &ApplyRules, image: &[u8]) -> Value { + let mut v = decode_settings_with(rules.fields, image); if let Some(map) = v.as_object_mut() { - for (key, at) in POSITIONS { + for (key, at) in rules.positions { let (lat, lon, alt) = read_position(image, *at); map.insert(format!("{key}-latitude"), Value::String(lat)); map.insert(format!("{key}-longitude"), Value::String(lon)); @@ -98,9 +131,14 @@ fn encode_coord(v: f64, max: f64, pos: u8, neg: u8) -> Option<[u8; 4]> { /// Apply the position keys present in `applicable`, removing them so the /// table writer never sees them. Returns how many fields were applied. -fn apply_positions(image: &mut [u8], applicable: &mut Map, notes: &mut Vec) -> usize { +fn apply_positions( + positions: &[(&str, usize)], + image: &mut [u8], + applicable: &mut Map, + notes: &mut Vec, +) -> usize { let mut n = 0; - for (key, at) in POSITIONS { + for (key, at) in positions { for (suffix, off, max, pos, neg) in [("latitude", 0usize, 90.0, b'N', b'S'), ("longitude", 4, 180.0, b'E', b'W')] { let k = format!("{key}-{suffix}"); let Some(v) = applicable.remove(&k) else { continue }; @@ -159,20 +197,30 @@ fn apply_positions(image: &mut [u8], applicable: &mut Map, notes: /// many fields were applied, and a note for every key the profile carried that /// this table cannot write — reported, not silently dropped. pub(crate) fn apply(image: &mut [u8], settings: &Value) -> (usize, Vec) { + apply_with(&ID5100_RULES, image, settings) +} + +/// [`apply`] for any radio's rules. +pub(crate) fn apply_with(rules: &ApplyRules, image: &mut [u8], settings: &Value) -> (usize, Vec) { let Some(map) = settings.as_object() else { return (0, vec!["the profile's settings were not an object; none were written".into()]); }; - let position_keys: Vec = POSITIONS + let position_keys: Vec = rules + .positions .iter() .flat_map(|(k, _)| ["latitude", "longitude", "altitude-m"].map(|s| format!("{k}-{s}"))) .collect(); - let mut known: std::collections::HashSet<&str> = - ID5100_SETTINGS_FIELDS.iter().map(|f| f.key).collect(); + let mut known: std::collections::HashSet<&str> = rules.fields.iter().map(|f| f.key).collect(); known.extend(position_keys.iter().map(String::as_str)); let notes: Vec = map .keys() .filter(|k| !known.contains(k.as_str())) - .map(|k| format!("setting \"{k}\" is not one the ID-5100 driver can write; left as the radio had it")) + .map(|k| { + format!( + "setting \"{k}\" is not one the {} driver can write; left as the radio had it", + rules.radio + ) + }) .collect(); let mut notes: Vec = notes; let mut applicable: Map = map @@ -180,10 +228,14 @@ pub(crate) fn apply(image: &mut [u8], settings: &Value) -> (usize, Vec) .filter(|(k, v)| known.contains(k.as_str()) && !v.is_null()) .map(|(k, v)| (k.clone(), v.clone())) .collect(); - normalize_call_signs(&mut applicable); - enforce_limits(image, &mut applicable, &mut notes); - let positions = apply_positions(image, &mut applicable, &mut notes); - (positions + apply_settings_with(ID5100_SETTINGS_FIELDS, image, &applicable), notes) + if rules.call_sign_layout { + normalize_call_signs(&mut applicable); + } else { + uppercase_call_signs(&mut applicable); + } + enforce_limits(rules, image, &mut applicable, &mut notes); + let positions = apply_positions(rules.positions, image, &mut applicable, &mut notes); + (positions + apply_settings_with(rules.fields, image, &applicable), notes) } /// My Call Sign entries the way CS-5100 stores them (textpass, 2026-10-02): @@ -220,12 +272,25 @@ fn normalize_call_signs(applicable: &mut Map) { } } +/// MY call signs and notes uppercased and nothing else: what a radio gets when +/// its own suffix layout has not been measured (the ID-51, #50 review). +fn uppercase_call_signs(applicable: &mut Map) { + for (k, v) in applicable.iter_mut() { + if !k.starts_with("my-station-my-call-sign-m") || k.ends_with("-in-use") { + continue; + } + if let Some(s) = v.as_str() { + *v = Value::String(s.to_ascii_uppercase()); + } + } +} + /// Groups of checkboxes whose COMBINATION CS-5100 restricts: it greys out OK /// on a fifth NMEA sentence and on an empty logger sentence list. A value the /// radio would refuse is not a small error here — it rejects the whole clone /// and factory-resets (FINDINGS, 2026-10-01) — so a profile that would end in /// a refused combination leaves that whole group exactly as the radio has it. -const LIMITS: &[(&str, &[&str], usize, usize)] = &[ +const LIMITS: &[Limit] = &[ ( "GPS TX (NMEA) sentences", &[ @@ -252,9 +317,9 @@ const LIMITS: &[(&str, &[&str], usize, usize)] = &[ ), ]; -fn enforce_limits(image: &[u8], applicable: &mut Map, notes: &mut Vec) { - let current = decode(image); - for (name, keys, min, max) in LIMITS { +fn enforce_limits(rules: &ApplyRules, image: &[u8], applicable: &mut Map, notes: &mut Vec) { + let current = decode_with(rules, image); + for (name, keys, min, max) in rules.limits { if !keys.iter().any(|k| applicable.contains_key(*k)) { continue; } @@ -273,9 +338,10 @@ fn enforce_limits(image: &[u8], applicable: &mut Map, notes: &mut applicable.remove(*k); } notes.push(format!( - "{name}: the profile ticks {ticked}, and the ID-5100 allows {min}-{max}. \ + "{name}: the profile ticks {ticked}, and the {} allows {min}-{max}. \ Left as the radio has them — the radio rejects a clone carrying a \ - combination it does not allow, and resets itself." + combination it does not allow, and resets itself.", + rules.radio )); } } @@ -371,7 +437,7 @@ mod tests { SK::Text { len } | SK::TextFill { len, .. } => *len as usize, SK::Uint { width } | SK::Int { width } | SK::Enum { width, .. } | SK::IntEnum { width, .. } => *width as usize, SK::Bool => 1, - SK::BoolBit { .. } => 0, + SK::BoolBit { .. } | SK::EnumBits { .. } => 0, }; for b in f.byte as usize..f.byte as usize + len { if let Some(other) = seen.insert(b, f.key) { diff --git a/src-tauri/src/radios/icom_id52/memory.rs b/src-tauri/src/radios/icom_id52/memory.rs index d4a8ad2..6733eab 100644 --- a/src-tauri/src/radios/icom_id52/memory.rs +++ b/src-tauri/src/radios/icom_id52/memory.rs @@ -317,7 +317,7 @@ impl CodeplugExporter for IcomId52 { if path.to_ascii_lowercase().ends_with(".csv") { return super::memory_csv::write_csv(path, req); } - patch_icf(path, |icf| { + ID52_CARD.patch_icf(path, |icf| { let written = write_codeplug(icf, req.channels, req.groups, req.model)?; // Settings ride in the same file as the memories, so a codeplug // export is also a settings write. Nothing happens if the profile @@ -340,16 +340,7 @@ impl CodeplugExporter for IcomId52 { /// A folder means "make me a new file in here", named the way the radio /// names its own so it sorts into place on the Load Setting screen. fn resolve_target(&self, path: &str) -> Result { - if !std::path::Path::new(path).is_dir() { - return Ok(path.to_string()); - } - // Prove there is something to patch before naming the output: a folder - // with no readable settings file cannot produce one, and finding that - // out now beats reporting a file name and then failing. - newest_settings_file(std::path::Path::new(path))?; - Ok(next_settings_file(std::path::Path::new(path)) - .to_string_lossy() - .into_owned()) + ID52_CARD.resolve_target(path) } } @@ -363,107 +354,18 @@ impl CardSettingsWriter for IcomId52 { if path.to_ascii_lowercase().ends_with(".csv") { return Err("A Memory CH .csv carries no settings — pick the .icf.".into()); } - patch_icf(path, |icf| Ok(super::settings::apply_settings(icf.image_mut(), settings))) - } -} - -/// Every `.icf` in a folder that this driver can actually patch, oldest first. -/// The radio's own names sort chronologically, which is what makes "newest" -/// mean anything. -fn settings_files(dir: &std::path::Path) -> Vec { - let Ok(entries) = std::fs::read_dir(dir) else { - return Vec::new(); - }; - let mut out: Vec = entries - .flatten() - .map(|e| e.path()) - .filter(|p| { - p.extension().is_some_and(|e| e.eq_ignore_ascii_case("icf")) - && std::fs::read_to_string(p).is_ok_and(|t| { - IcfFile::parse(&t).is_ok_and(|f| super::check_is_an_id52_file(&f).is_ok()) - }) - }) - .collect(); - out.sort(); - out -} - -/// The template a new file is built from: the most recent settings file the -/// radio wrote to this card. Everything the codeplug does not describe is -/// inherited from it, so the newest is the one that reflects the radio best. -fn newest_settings_file(dir: &std::path::Path) -> Result { - settings_files(dir) - .last() - .map(|p| p.to_string_lossy().into_owned()) - .ok_or_else(|| { - format!( - "No ID-52 settings file to work from in {}. On the radio, save one with SET > SD Card > Save Setting, then try again.", - dir.display() - ) - }) -} - -/// The radio's own naming scheme, `SetYYYYMMDD_NN.icf`, with today's date and -/// the first free number. Names longer than 23 characters are invisible to the -/// radio's file picker (Advanced Manual p. 2-7); this one is 18. -fn next_settings_file(dir: &std::path::Path) -> std::path::PathBuf { - let today = chrono::Local::now().format("%Y%m%d").to_string(); - for n in 1..=99 { - let candidate = dir.join(format!("Set{today}_{n:02}.icf")); - if !candidate.exists() { - return candidate; - } + ID52_CARD.patch_icf(path, |icf| Ok(super::settings::apply_settings(icf.image_mut(), settings))) } - dir.join(format!("Set{today}_99.icf")) } -/// Patch the operator's own `.icf` in place — memories, groups, and the radio -/// profile's settings — and keep their untouched original beside it. -/// -/// In place, because the radio reads a file it wrote itself and everything this -/// codeplug does not describe (the repeater list, DV call signs, Bluetooth -/// pairings) has to survive the round trip. `.orig` is written once and never -/// overwritten, so a second export cannot clobber the only pristine copy. -/// The `.icf` file rule, shared by the export and the settings-only write. -/// -/// A target that does not exist yet is a NEW file on the card, and the template -/// is the newest settings file sitting beside it. A target that does exist is -/// the operator pointing at one file and meaning it: it is patched in place, -/// with the untouched original kept once as `.orig`. -fn patch_icf( - path: &str, - patch: impl FnOnce(&mut IcfFile) -> Result, -) -> Result { - let template = if std::path::Path::new(path).is_file() { - path.to_string() - } else { - let dir = std::path::Path::new(path) - .parent() - .ok_or("There is no folder to write into.")?; - newest_settings_file(dir)? - }; - let text = std::fs::read_to_string(&template).map_err(|e| { - format!( - "Could not read {template}: {e}. Pick a settings file the radio saved for \ - itself with SET > SD Card > Save Setting — they live in ID-52/Setting/." - ) - })?; - let mut icf = IcfFile::parse(&text)?; - let count = patch(&mut icf)?; - - // Only when we are overwriting the operator's file. Writing a NEW one leaves - // theirs untouched by construction, so a `.orig` would be a copy of a file - // nothing has modified. - if template == path { - let orig = format!("{path}.orig"); - if !std::path::Path::new(&orig).exists() { - std::fs::write(&orig, &text) - .map_err(|e| format!("Could not save the original as {orig}: {e}"))?; - } - } - std::fs::write(path, icf.render()).map_err(|e| format!("Could not write {path}: {e}"))?; - Ok(count) -} +/// The ID-52's card folder, for the shared `.icf` file rules +/// ([`crate::radios::icom_card`]). +pub(crate) const ID52_CARD: crate::radios::icom_card::IcfCard = crate::radios::icom_card::IcfCard { + radio: "ID-52", + folder: "ID-52/Setting/", + save_menu: "SET > SD Card > Save Setting", + is_ours: |f| super::check_is_an_id52_file(f).is_ok(), +}; /// Encode one memory. Field offsets are the measured ones; see FINDINGS.md. fn encode(rec: &mut [u8], ec: &ExpandedChannel, name: &str) { diff --git a/src-tauri/src/radios/icom_id52/settings.rs b/src-tauri/src/radios/icom_id52/settings.rs index 323d408..076b727 100644 --- a/src-tauri/src/radios/icom_id52/settings.rs +++ b/src-tauri/src/radios/icom_id52/settings.rs @@ -79,6 +79,15 @@ pub(crate) enum SK { /// Single bit at `shift` within a byte, read-modify-written so the bits /// sharing that byte survive. Only `0x03CAA3` needs this so far. BoolBit { shift: u8 }, + /// An option list stored in `bits` bits at `shift` within one byte, read- + /// modify-written like [`SK::BoolBit`]. The ID-51 (#50) keeps ATT(AM) as a + /// 2-bit field (bits 5-4 of `0x1AB34`) beside two single-bit flags and an + /// unidentified nibble, so the byte cannot be assigned whole. + EnumBits { + shift: u8, + bits: u8, + labels: &'static [(u32, &'static str)], + }, /// Enumerated whole-value mapped to a label. Enum { width: u8, @@ -140,6 +149,9 @@ fn decode_field(image: &[u8], f: &SF) -> Value { SK::Int { width } => Value::from(read_int(image, at, *width)), SK::Bool => Value::Bool(image[at] != 0), SK::BoolBit { shift } => Value::Bool((image[at] >> shift) & 1 == 1), + SK::EnumBits { shift, bits, labels } => { + label_for(labels, u32::from((image[at] >> shift) & bit_mask(*bits))) + } SK::Enum { width, labels } => label_for(labels, read_uint(image, at, *width)), SK::IntEnum { width, labels } => { let raw = read_int(image, at, *width); @@ -191,19 +203,24 @@ pub(crate) fn decode_settings_with(fields: &[SF], image: &[u8]) -> Value { } /// Last byte a field touches, exclusive — used to keep a short image from -/// panicking a decode. -fn field_end(f: &SF) -> usize { +/// panicking a decode, and by the drivers' overlap tests. +pub(crate) fn field_end(f: &SF) -> usize { let at = f.byte as usize; at + match &f.kind { SK::Uint { width } | SK::Int { width } | SK::Enum { width, .. } | SK::IntEnum { width, .. } => *width as usize, - SK::Bool | SK::BoolBit { .. } => 1, + SK::Bool | SK::BoolBit { .. } | SK::EnumBits { .. } => 1, SK::Text { len } | SK::TextFill { len, .. } => *len as usize, } } +/// The low `bits` bits set, for an [`SK::EnumBits`] field. +fn bit_mask(bits: u8) -> u8 { + if bits >= 8 { 0xFF } else { (1u8 << bits) - 1 } +} + /// Write a profile's settings into a decoded `.icf` image, returning how many /// fields were applied. Keys the table does not know, and values of the wrong /// shape, are skipped rather than guessed at — a profile saved against an older @@ -277,6 +294,16 @@ pub(crate) fn apply_settings_with( } None => false, }, + SK::EnumBits { shift, bits, labels } => match raw_for(labels, v) { + // A value wider than the field is refused, not truncated into + // the neighbouring bits. + Some(raw) if raw <= u32::from(bit_mask(*bits)) => { + let mask = bit_mask(*bits) << shift; + image[at] = (image[at] & !mask) | ((raw as u8) << shift); + true + } + _ => false, + }, SK::Enum { width, labels } => match raw_for(labels, v) { Some(raw) => { write_uint(image, at, *width, raw); @@ -736,7 +763,7 @@ mod tests { let v = Value::from((n as i64).wrapping_mul(97) % span - span / 2); (v.clone(), v) } - SK::Enum { labels, .. } => { + SK::Enum { labels, .. } | SK::EnumBits { labels, .. } => { if labels.is_empty() { return None; } @@ -929,6 +956,7 @@ mod tests { SK::Int { width } => SK::Int { width: *width }, SK::Bool => SK::Bool, SK::BoolBit { shift } => SK::BoolBit { shift: *shift }, + SK::EnumBits { shift, bits, labels } => SK::EnumBits { shift: *shift, bits: *bits, labels }, SK::Enum { width, labels } => SK::Enum { width: *width, labels, diff --git a/src-tauri/src/radios/mod.rs b/src-tauri/src/radios/mod.rs index 3252e84..348232d 100644 --- a/src-tauri/src/radios/mod.rs +++ b/src-tauri/src/radios/mod.rs @@ -39,6 +39,8 @@ mod card_settings_write; pub(crate) mod driver; #[cfg(test)] pub(crate) mod fake_port; +pub(crate) mod icom_card; +pub(crate) mod icom_id51; pub(crate) mod icom_id52; pub(crate) mod icom_id5100; pub(crate) mod kenwood_thd72; diff --git a/src-tauri/src/radios/registry.rs b/src-tauri/src/radios/registry.rs index 9e7ee43..2d76c3e 100644 --- a/src-tauri/src/radios/registry.rs +++ b/src-tauri/src/radios/registry.rs @@ -23,13 +23,14 @@ 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; 15] = [ +static DRIVERS: [&dyn RadioDriver; 16] = [ &super::baofeng_uv5r::DRIVER, &super::binteradio_bt9000::DRIVER, &super::tidradio_tdh3::DRIVER, &super::anytone_atd890uv::DRIVER, &super::anytone_atd578uv::DRIVER, &super::yaesu_ft5d::DRIVER, + &super::icom_id51::DRIVER, &super::icom_id52::DRIVER, &super::kenwood_thd75::DRIVER, &super::kenwood_thd72::DRIVER, @@ -88,6 +89,7 @@ mod tests { "anytone_atd890uv", "anytone_atd578uv", "yaesu_ft5d", + "icom_id51", "icom_id52", "kenwood_thd75", "kenwood_thd72", @@ -131,6 +133,8 @@ mod tests { "yaesu_ftm300" => (false, false), // FTM-510 (#151): the FTM-300's sibling, programmed the same way. "yaesu_ftm510" => (false, false), + // ID-51 Plus (#50): the ID-52's arrangement, settings in its `.icf`. + "icom_id51" => (false, false), // TH-D72: reads and writes its 19 menu parameters over the // `MU` ASCII command — no clone session involved, which is why // it claims both halves where the card radios claim neither. diff --git a/src-tauri/src/radios/settings_bounds.rs b/src-tauri/src/radios/settings_bounds.rs index 21fee67..d57351f 100644 --- a/src-tauri/src/radios/settings_bounds.rs +++ b/src-tauri/src/radios/settings_bounds.rs @@ -130,6 +130,19 @@ pub(crate) fn note_line(notes: &[String]) -> Option { )) } +/// [`note_line`] with the driver's own notes after it — the settings its +/// encoder declined to write, each with its reason. `None` when there are +/// neither. +pub(crate) fn note_line_with(range: &[String], driver: &[String]) -> Option { + let driver_line = (!driver.is_empty()).then(|| format!("Not written: {}.", driver.join("; "))); + match (note_line(range), driver_line) { + (None, None) => None, + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (Some(a), Some(b)) => Some(format!("{a} {b}")), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index 049565f..b8c6a73 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -205,6 +205,12 @@ pub const TMD710_SETTINGS_SCHEMA: &str = include_str!("tmd710_settings_schema.js /// read off the grid. `radios/icom_id5100/settings.rs` asserts the two agree. pub const ID5100_SETTINGS_SCHEMA: &str = include_str!("id5100_settings_schema.json"); +/// GENERATED with the Rust field table (`radios/icom_id51/id51_settings_table.rs`) +/// by `scratchpad/icom_id51/gen_table.py`, from CS-51PLUS measurements: every +/// address moved by one control in CS-51PLUS and diffed, every label read off +/// its dropdown. `radios/icom_id51/settings.rs` asserts the two agree. +pub const ID51_SETTINGS_SCHEMA: &str = include_str!("id51_settings_schema.json"); + /// GENERATED with the Rust field table (`radios/tyt_md380/md380_settings_table.rs`) /// by `scratchpad/tyt_md380/gen_md380_settings.py` from that folder's /// `MEASURED.md`. `radios/tyt_md380/settings.rs` asserts the two agree. @@ -898,6 +904,61 @@ fn models() -> Vec { non_channel_settings_schema: ID52_SETTINGS_SCHEMA, }, // -------------------------------------------------------- + // Icom ID-51 Plus (and 50th Anniversary) — D-STAR + analog + // FM dual-band HT, 500 memories in 26 named banks, 16-char + // names, 5 W. Issue #50. Programmed from its own microSD + // card, the ID-52's arrangement: `icom_id51_icf` builds a + // new `.icf` from the newest one the radio saved with + // MENU > SD Card > Save Setting, and the radio loads it with + // Load Setting > ALL — memories, banks and every MENU + // setting in one operation. See radios/icom_id51/. + // NOTES: (a) tx_bands are the USA version's (Advanced + // Manual p. 19-1: TX 144-148 / 430-450); rx_bands the A/B + // bands' coverage, 108-174 and 380-479 — the BC radio is a + // separate receiver with its own memories, not a memory + // band. Anything outside is excluded here and refused by + // the build. (b) banks, one per memory, A-Z, 100 each. + // (c) aprs_capable: D-PRS position beaconing; all 8 D-PRS + // menu items are in the schema. + // -------------------------------------------------------- + ModelSeed { + manufacturer: "Icom", + model: "ID-51 Plus", + driver_key: Some("icom_id51"), + programming_ui: Some("generic"), + display_name: "Icom ID-51 Plus", + analog_capable: true, + dmr_capable: false, + dstar_capable: true, + ysf_capable: false, + nxdn_capable: false, + p25_capable: false, + m17_capable: false, + aprs_capable: true, + covers_hf: false, + covers_vhf: true, + covers_uhf: true, + covers_220: false, + covers_900: false, + freq_min: 144.0, + freq_max: 450.0, + tx_bands: Some("[[144.0,148.0],[430.0,450.0]]"), + rx_bands: Some("[[108.0,174.0],[380.0,479.0]]"), + memory_channels: 500, + zones_supported: false, + max_zones: None, + channels_per_zone: None, + scan_lists_supported: false, + max_scan_lists: None, + banks_supported: true, + max_name_length: 16, + export_format: "icom_id51_icf", + connection_type: "microSD", + // 241 settings + the manual position, GENERATED with the Rust table + // by scratchpad/icom_id51/gen_table.py from CS-51PLUS measurements. + non_channel_settings_schema: ID51_SETTINGS_SCHEMA, + }, + // -------------------------------------------------------- // 6. Kenwood TH-D75 — D-STAR + analog FM TRI-band HT, 1000 // memories in 30 groups, 16-char names, 5 W. The A model // (US); the E's TX bands differ. diff --git a/src/components/profiles/ProfileEditor.tsx b/src/components/profiles/ProfileEditor.tsx index ed4d6f9..8cf0dde 100644 --- a/src/components/profiles/ProfileEditor.tsx +++ b/src/components/profiles/ProfileEditor.tsx @@ -236,6 +236,7 @@ const CARD_SETTINGS_READERS: Record< (path: string) => Promise > = { yaesu_ft5d_sd: api.readFt5dSettingsFromBackup, + icom_id51_icf: api.readId51SettingsFromCard, icom_id52_icf: api.readId52SettingsFromCard, kenwood_thd75_sd: api.readThd75SettingsFromCard, yaesu_ftm300_sd: api.readFtm300SettingsFromBackup, diff --git a/src/lib/api.ts b/src/lib/api.ts index 3ebbd8b..c37c149 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -393,6 +393,9 @@ export const api = { // decoded shape again. See radios/icom_id52/settings.rs. readId52SettingsFromCard: (path: string) => invoke("read_id52_settings_from_card", { path }), + // The ID-51 Plus's `.icf`; the ID-52's arrangement. See radios/icom_id51/settings.rs. + readId51SettingsFromCard: (path: string) => + invoke("read_id51_settings_from_card", { path }), // The TH-D75's settings live in the `.d75` backup it writes to its microSD // card. Same decoded shape again. See radios/kenwood_thd75/settings.rs. readThd75SettingsFromCard: (path: string) => diff --git a/src/lib/radioProgramming.ts b/src/lib/radioProgramming.ts index 43889ee..0bd2b11 100644 --- a/src/lib/radioProgramming.ts +++ b/src/lib/radioProgramming.ts @@ -173,6 +173,26 @@ const MEDIA_WRITES: Record = { carries: "your D-STAR and APRS call signs, your GPS and Bluetooth settings, and the rest of the radio's own configuration", }, + // The ID-51 Plus (#50): the ID-52's arrangement — one `.icf` carries the + // memories, banks and every MENU setting, and Load Setting → ALL restores + // them together. Menu paths are the ID-51A/E PLUS Advanced Manual's (p. 2-5). + icom_id51_icf: { + action: "Write to microSD…", + pickTitle: + "Select a settings file from ID-51/Setting/ on the radio’s microSD card — it will be rewritten in place", + filterName: "ID-51 settings file", + extensions: ["icf"], + before: + "On the radio, save its settings to the card: MENU → SD Card → Save Setting → <>. Then put the card in this computer and pick that file from ID-51/Setting/ below.", + after: + "Put the card back in the radio and load it: MENU → SD Card → Load Setting → pick the file → ALL. That restores the memories and the settings together.", + found: + "It already holds a settings file, so there is nothing to pick — just check it is current (the radio writes one with MENU → SD Card → Save Setting).", + preserves: + "Channel lists become banks A–Z (100 channels each). Everything else in the file — the repeater list, your call signs, call channels, scan edges, GPS and BC radio memories — is carried across untouched. Writing to the card creates a NEW settings file named the way the radio names its own, built from the newest one there, so nothing you saved is modified at all.", + carries: + "your D-STAR call signs, your D-PRS (APRS) and GPS settings, and the rest of the radio's own configuration", + }, // The third card radio. Like the ID-52 it keeps everything in one file the // radio writes and reads back, and like the ID-52 the card can stay in the // radio — USB-C mounts it as a drive.