diff --git a/README.md b/README.md index 0d89713..4f45c36 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ card — or exported as CSV for tools that expect it. | **AnyTone AT-D890UV** | DMR + Analog | VHF / UHF | Direct USB — channels, zones, scan lists, settings, call-sign DB | 4000 channels; full DMR: zones, talkgroups, 308k-entry caller-ID database | | **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 | | **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 | | **Kenwood TH-D75** | D-STAR + APRS + Analog | VHF / 1.25 m / UHF TX, 0.1–524 MHz RX | microSD — patches the radio's own `.d75` file | 1000 memories in 30 groups; memories and menu settings, including the APRS setup | | **Kenwood TM-D710** | APRS + Analog | 2 m / 70 cm TX, 118–524 MHz RX | Serial cable — read, write, settings | 1000 memories; 95 settings across **two transports** — 35 over the radio's `MU` command and 60 more, including the APRS position, status texts and station icon, out of the settings image `MU` cannot reach. Programmed live, one memory at a time over the COM port on the rear of the **operation panel** (not the main unit), so there is no image file and a backup is a transcript of the radio's own lines | @@ -60,7 +61,6 @@ settings together, then verify on the actual radio before shipping. | **AnyTone AT-D578UV** | DMR + Analog mobile | [#47](https://github.com/ww8l/codeplug-magic/issues/47) | | **AnyTone AT-D868UV** | DMR + Analog handheld | [#51](https://github.com/ww8l/codeplug-magic/issues/51) | | **Icom ID-51** | D-STAR + Analog handheld | [#50](https://github.com/ww8l/codeplug-magic/issues/50) | -| **Icom ID-5100** | D-STAR + Analog mobile | [#49](https://github.com/ww8l/codeplug-magic/issues/49) | | **Icom IC-9100** | HF / VHF / UHF base | [#45](https://github.com/ww8l/codeplug-magic/issues/45) | | **Icom IC-7610** | HF / 6 m SDR base | [#46](https://github.com/ww8l/codeplug-magic/issues/46) | | **Quansheng UV-K5** | Analog handheld | [#44](https://github.com/ww8l/codeplug-magic/issues/44) | diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index f5a03c4..195d2ac 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -790,6 +790,56 @@ pub(crate) async fn resolve_codeplug_zone_slots( Ok((model, ZonedCodeplug { slots, zones, warnings, refused })) } +/// One channel list as a bank of the memories it resolved to: the unit a clone +/// radio with NAMED banks of arbitrary memories writes (the ID-5100's A-Z, #49). +/// +/// Contrast [`ProgrammedZone`]: there, position is membership and the resolver +/// decides where channels land. Here the channels are already placed by the flat +/// resolver and a bank only *names* some of them, so this is computed after +/// [`resolve_codeplug_slots`] and never moves a slot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SlotBank { + pub name: String, + /// Memory slots in the list's own order. + pub slots: Vec, +} + +/// Which slots each channel list owns, in codeplug list order. +/// +/// A memory carries ONE bank on the radios this serves, so a channel in two +/// lists belongs to the first — the same rule [`codeplug_channels`] uses to +/// place it, and the reason the two agree. A list whose every channel was +/// claimed earlier, or excluded for this radio, comes back empty rather than +/// dropped, so the driver can still say which list got nothing. +pub(crate) fn banks_for_slots(groups: &[CodeplugGroup], slots: &[SlotChannel]) -> Vec { + let mut by_id: HashMap> = HashMap::new(); + for s in slots { + by_id.entry(s.channel.id).or_default().push(s.slot); + } + let mut claimed: HashSet = HashSet::new(); + groups + .iter() + .map(|g| SlotBank { + name: g.list_name.clone(), + slots: g + .channels + .iter() + .filter(|c| claimed.insert(c.id)) + .flat_map(|c| by_id.get(&c.id).cloned().unwrap_or_default()) + .collect(), + }) + .collect() +} + +pub(crate) async fn resolve_codeplug_banks( + pool: &sqlx::SqlitePool, + codeplug_id: i64, + slots: &[SlotChannel], +) -> Result, String> { + let groups = resolve_codeplug_groups(pool, codeplug_id).await?; + Ok(banks_for_slots(&groups, slots)) +} + /// A fixed-zone radio's memory geometry. /// /// `memories` is here because the last zone can be SHORT: the BT-9000 holds 960 @@ -2190,6 +2240,52 @@ mod tests { /// absolute TRANSMIT FREQUENCY, not a shift. Writing the magnitude there /// gave a 33 cm pair (927.5 RX / 902.5 TX) the row `split, 25.000000` — a /// memory that transmits on 25 MHz. + #[test] + fn banks_follow_list_order_and_a_shared_channel_goes_to_the_first() { + let ch = |id: i64| Channel { + id, + ..Default::default() + }; + let groups = vec![ + CodeplugGroup { + list_id: 1, + list_name: "Local".into(), + channels: vec![ch(10), ch(11), ch(12)], + }, + CodeplugGroup { + list_id: 2, + list_name: "Travel".into(), + // 11 is shared with Local; 99 was excluded for this radio. + channels: vec![ch(13), ch(11), ch(99)], + }, + CodeplugGroup { + list_id: 3, + list_name: "Dup".into(), + channels: vec![ch(12)], + }, + ]; + // What the flat resolver produces for these lists: list order, first + // occurrence wins, excluded channel absent. + let slots: Vec = [10, 11, 12, 13] + .iter() + .enumerate() + .map(|(slot, &id)| SlotChannel { + slot, + name: String::new(), + channel: ch(id), + }) + .collect(); + let banks = banks_for_slots(&groups, &slots); + assert_eq!( + banks, + vec![ + SlotBank { name: "Local".into(), slots: vec![0, 1, 2] }, + SlotBank { name: "Travel".into(), slots: vec![3] }, + SlotBank { name: "Dup".into(), slots: vec![] }, + ] + ); + } + #[test] fn a_chirp_split_row_carries_the_transmit_frequency() { let split = ExpandedChannel { diff --git a/src-tauri/src/commands/program.rs b/src-tauri/src/commands/program.rs index 852e77a..ddf1837 100644 --- a/src-tauri/src/commands/program.rs +++ b/src-tauri/src/commands/program.rs @@ -997,6 +997,10 @@ pub async fn program_radio( .flatten(); let schema = model.non_channel_settings_schema.clone(); + // The channel lists as named banks of the slots just placed. Read only + // by a radio whose banks name arbitrary memories (the ID-5100). + let banks = export::resolve_codeplug_banks(&state.pool, codeplug_id, &slots).await?; + // Label the pre-write backup with the codeplug name so several // codeplugs for one radio stay distinguishable when restoring. let label: String = sqlx::query_scalar("SELECT name FROM codeplugs WHERE id = ?1") @@ -1026,6 +1030,7 @@ pub async fn program_radio( let req = ImageProgramRequest { model: &model, channels: &slots, + banks: &banks, settings: settings.as_ref().map(|(v, s)| (v, s.as_str())), backup_dir: &backup_dir, label: &label, @@ -1075,6 +1080,7 @@ fn program_report_to_generic(r: ProgramReport) -> CodeplugProgramReport { settings_written: None, zones_written: r.zones_written, zones_cleared: r.zones_cleared, + banks_written: 0, scan_lists_written: r.scan_lists_written, scan_lists_cleared: r.scan_lists_cleared, contacts_written: r.contacts_written, diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 5b9510b..7f5f6be 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -60,18 +60,19 @@ mod tests { // Models are reintroduced one at a time (migration 0005 trimmed the // original set): currently the Baofeng UV-5R, TIDRADIO TD-H3, AnyTone // AT-D890UV, Yaesu FT5D, Icom ID-52, Kenwood TH-D75, Kenwood TH-D72 - // the Binteradio BT-9000 and Kenwood TM-D710. (0015 removed the Vero - // VR-N76 placeholder.) None of the last four has a migration of its - // own — seeding INSERTs new (manufacturer, model) rows, so a new model - // reaches existing databases on the next startup without one. + // the Binteradio BT-9000, Kenwood TM-D710 and Icom ID-5100. (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 + // without one. let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM radio_models") .fetch_one(&pool) .await .unwrap(); assert_eq!( - count.0, 9, - "expected the UV-5R, TD-H3, AT-D890UV, FT5D, ID-52, TH-D75, TH-D72, BT-9000 \ - and TM-D710 seeded models" + count.0, 10, + "expected the UV-5R, TD-H3, AT-D890UV, FT5D, ID-52, TH-D75, TH-D72, BT-9000, \ + TM-D710 and ID-5100 seeded models" ); let models: Vec<(String,)> = @@ -83,8 +84,8 @@ mod tests { assert_eq!( names, vec![ - "AT-D890UV", "BT-9000", "FT5D", "ID-52", "TD-H3", "TH-D72", "TH-D75", "TM-D710", - "UV-5R" + "AT-D890UV", "BT-9000", "FT5D", "ID-5100", "ID-52", "TD-H3", "TH-D72", "TH-D75", + "TM-D710", "UV-5R" ] ); @@ -94,7 +95,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count2.0, 9, "seeding should be idempotent"); + assert_eq!(count2.0, 10, "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/id5100_settings_schema.json b/src-tauri/src/id5100_settings_schema.json new file mode 100644 index 0000000..e24471c --- /dev/null +++ b/src-tauri/src/id5100_settings_schema.json @@ -0,0 +1,3747 @@ +[ + { + "key": "section-set-mode", + "label": "Set Mode", + "type": "section" + }, + { + "key": "set-mode-squelch-att-select", + "label": "Squelch/ATT Select", + "type": "select", + "options": [ + "OFF", + "S-Meter Squelch", + "ATT" + ] + }, + { + "key": "set-mode-squelch-delay", + "label": "Squelch Delay", + "type": "select", + "options": [ + "Short", + "Long" + ] + }, + { + "key": "set-mode-fan-control", + "label": "Fan Control", + "type": "select", + "options": [ + "Slow", + "Mid", + "Fast", + "Auto" + ] + }, + { + "key": "set-mode-auto-power-off", + "label": "Auto Power OFF", + "type": "select", + "options": [ + "OFF", + "30min", + "60min", + "90min", + "120min" + ] + }, + { + "key": "set-mode-dial-speed-up", + "label": "Dial Speed-UP", + "type": "boolean" + }, + { + "key": "set-mode-one-touch-ptt-remote-mic", + "label": "One-Touch PTT (Remote MIC)", + "type": "boolean" + }, + { + "key": "set-mode-ptt-lock", + "label": "PTT Lock", + "type": "boolean" + }, + { + "key": "set-mode-busy-lockout", + "label": "Busy Lockout", + "type": "boolean" + }, + { + "key": "set-mode-time-out-timer", + "label": "Time-Out Timer", + "type": "select", + "options": [ + "OFF", + "1min", + "3min", + "5min", + "10min", + "15min", + "30min" + ] + }, + { + "key": "set-mode-active-band", + "label": "Active Band", + "type": "select", + "options": [ + "Single", + "ALL" + ] + }, + { + "key": "set-mode-mic-gain", + "label": "MIC Gain", + "type": "select", + "options": [ + "1", + "2", + "3", + "4" + ] + }, + { + "key": "set-mode-touch-operation-sub", + "label": "Touch Operation (Sub)", + "type": "select", + "options": [ + "Main Select", + "Function Select" + ] + }, + { + "key": "set-mode-keyboard-type", + "label": "Keyboard Type", + "type": "select", + "options": [ + "Ten-Key", + "Full Keyboard" + ] + }, + { + "key": "set-mode-data-speed", + "label": "Data Speed", + "type": "select", + "options": [ + "4800bps", + "9600bps" + ] + }, + { + "key": "set-mode-power-off-with-no-controller", + "label": "Power OFF (With No Controller)", + "type": "boolean" + }, + { + "key": "set-mode-auto-repeater", + "label": "Auto Repeater", + "type": "select", + "options": [ + "OFF", + "ON (DUP)", + "ON (DUP,TONE)" + ] + }, + { + "key": "section-remote-mic-key", + "label": "Remote MIC Key", + "type": "section" + }, + { + "key": "remote-mic-key-during-rx-standby-f-1", + "label": "During RX/Standby [F-1]", + "type": "select", + "options": [ + "—", + "Monitor", + "MR (000 CH)", + "MR (001 CH)", + "DR", + "FROM/TO (DR)", + "BAND/BANK", + "SCAN", + "Temporary Skip", + "RX>CS", + "SPEECH", + "MODE", + "LOW", + "DUP", + "PRIO", + "TONE/DSQL", + "MW", + "MUTE", + "Voice TX (T1)", + "DTMF DIRECT TX", + "T-CALL" + ] + }, + { + "key": "remote-mic-key-during-rx-standby-f-2", + "label": "During RX/Standby [F-2]", + "type": "select", + "options": [ + "—", + "Monitor", + "MR (000 CH)", + "MR (001 CH)", + "DR", + "FROM/TO (DR)", + "BAND/BANK", + "SCAN", + "Temporary Skip", + "RX>CS", + "SPEECH", + "MODE", + "LOW", + "DUP", + "PRIO", + "TONE/DSQL", + "MW", + "MUTE", + "Voice TX (T1)", + "DTMF DIRECT TX", + "T-CALL" + ] + }, + { + "key": "remote-mic-key-during-tx-f-1", + "label": "During TX [F-1]", + "type": "select", + "options": [ + "—", + "LOW", + "Voice TX (T1)", + "T-CALL" + ] + }, + { + "key": "remote-mic-key-during-tx-f-2", + "label": "During TX [F-2]", + "type": "select", + "options": [ + "—", + "LOW", + "Voice TX (T1)", + "T-CALL" + ] + }, + { + "key": "section-up-down-mic-key", + "label": "Up/Down MIC Key", + "type": "section" + }, + { + "key": "up-down-mic-key-during-rx-standby-up", + "label": "During RX/Standby [UP]", + "type": "select", + "options": [ + "—", + "UP", + "DOWN", + "VOL UP", + "VOL DOWN", + "SQL UP", + "SQL DOWN", + "Monitor", + "CALL", + "MR (000 CH)", + "MR (001 CH)", + "VFO/MR", + "DR", + "FROM/TO (DR)", + "HOME CH", + "BAND/BANK", + "SCAN", + "Temporary Skip", + "RX>CS", + "SPEECH", + "MAIN/DUAL", + "MODE", + "LOW", + "DUP", + "PRIO", + "TONE/DSQL", + "MW", + "MUTE", + "Voice TX (T1)", + "T-CALL" + ] + }, + { + "key": "up-down-mic-key-during-rx-standby-dn", + "label": "During RX/Standby [DN]", + "type": "select", + "options": [ + "—", + "UP", + "DOWN", + "VOL UP", + "VOL DOWN", + "SQL UP", + "SQL DOWN", + "Monitor", + "CALL", + "MR (000 CH)", + "MR (001 CH)", + "VFO/MR", + "DR", + "FROM/TO (DR)", + "HOME CH", + "BAND/BANK", + "SCAN", + "Temporary Skip", + "RX>CS", + "SPEECH", + "MAIN/DUAL", + "MODE", + "LOW", + "DUP", + "PRIO", + "TONE/DSQL", + "MW", + "MUTE", + "Voice TX (T1)", + "T-CALL" + ] + }, + { + "key": "up-down-mic-key-during-tx-up", + "label": "During TX [UP]", + "type": "select", + "options": [ + "—", + "LOW", + "Voice TX (T1)", + "T-CALL" + ] + }, + { + "key": "up-down-mic-key-during-tx-dn", + "label": "During TX [DN]", + "type": "select", + "options": [ + "—", + "LOW", + "Voice TX (T1)", + "T-CALL" + ] + }, + { + "key": "section-bluetooth", + "label": "Bluetooth", + "type": "section" + }, + { + "key": "bluetooth-bluetooth", + "label": "Bluetooth", + "type": "boolean" + }, + { + "key": "bluetooth-auto-connect", + "label": "Auto Connect", + "type": "boolean" + }, + { + "key": "section-bluetooth-headset-set", + "label": "Bluetooth - Headset Set", + "type": "section" + }, + { + "key": "bluetooth-headset-set-af-output", + "label": "AF Output", + "type": "select", + "options": [ + "Headset Only", + "Headset & Speaker" + ] + }, + { + "key": "bluetooth-headset-set-headset-function-select", + "label": "Headset Function Select", + "type": "select", + "options": [ + "Normal", + "Microphone", + "PTT" + ] + }, + { + "key": "bluetooth-headset-set-vox", + "label": "VOX", + "type": "boolean" + }, + { + "key": "bluetooth-headset-set-vox-level", + "label": "VOX Level", + "type": "select", + "options": [ + "OFF", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10" + ] + }, + { + "key": "bluetooth-headset-set-vox-delay", + "label": "VOX Delay", + "type": "select", + "options": [ + "0.5sec", + "1.0sec", + "1.5sec", + "2.0sec", + "2.5sec", + "3.0sec" + ] + }, + { + "key": "bluetooth-headset-set-vox-time-out-timer", + "label": "VOX Time-Out Timer", + "type": "select", + "options": [ + "OFF", + "1min", + "2min", + "3min", + "4min", + "5min", + "10min", + "15min" + ] + }, + { + "key": "section-bluetooth-icom-headset", + "label": "Bluetooth - Icom Headset", + "type": "section" + }, + { + "key": "bluetooth-icom-headset-power-save", + "label": "Power Save", + "type": "boolean" + }, + { + "key": "bluetooth-icom-headset-one-touch-ptt", + "label": "One-Touch PTT", + "type": "boolean" + }, + { + "key": "bluetooth-icom-headset-ptt-beep", + "label": "PTT Beep", + "type": "boolean" + }, + { + "key": "bluetooth-icom-headset-custom-key-beep", + "label": "Custom Key Beep", + "type": "boolean" + }, + { + "key": "bluetooth-icom-headset-custom-key-play", + "label": "Custom Key [PLAY]", + "type": "select", + "options": [ + "—", + "UP", + "DOWN", + "VOL UP", + "VOL DOWN", + "SQL UP", + "SQL DOWN", + "Monitor", + "CALL", + "MR (000 CH)", + "MR (001 CH)", + "VFO/MR", + "DR", + "FROM/TO (DR)", + "HOME CH", + "BAND/BANK", + "SCAN", + "Temporary Skip", + "RX>CS", + "SPEECH", + "MAIN/DUAL", + "MODE", + "LOW", + "DUP", + "PRIO", + "TONE/DSQL", + "MW", + "MUTE", + "Voice TX (T1)", + "T-CALL" + ] + }, + { + "key": "bluetooth-icom-headset-custom-key-fwd", + "label": "Custom Key [FWD]", + "type": "select", + "options": [ + "—", + "UP", + "DOWN", + "VOL UP", + "VOL DOWN", + "SQL UP", + "SQL DOWN", + "Monitor", + "CALL", + "MR (000 CH)", + "MR (001 CH)", + "VFO/MR", + "DR", + "FROM/TO (DR)", + "HOME CH", + "BAND/BANK", + "SCAN", + "Temporary Skip", + "RX>CS", + "SPEECH", + "MAIN/DUAL", + "MODE", + "LOW", + "DUP", + "PRIO", + "TONE/DSQL", + "MW", + "MUTE", + "Voice TX (T1)", + "T-CALL" + ] + }, + { + "key": "bluetooth-icom-headset-custom-key-rwd", + "label": "Custom Key [RWD]", + "type": "select", + "options": [ + "—", + "UP", + "DOWN", + "VOL UP", + "VOL DOWN", + "SQL UP", + "SQL DOWN", + "Monitor", + "CALL", + "MR (000 CH)", + "MR (001 CH)", + "VFO/MR", + "DR", + "FROM/TO (DR)", + "HOME CH", + "BAND/BANK", + "SCAN", + "Temporary Skip", + "RX>CS", + "SPEECH", + "MAIN/DUAL", + "MODE", + "LOW", + "DUP", + "PRIO", + "TONE/DSQL", + "MW", + "MUTE", + "Voice TX (T1)", + "T-CALL" + ] + }, + { + "key": "section-bluetooth-data-device", + "label": "Bluetooth - Data Device", + "type": "section" + }, + { + "key": "bluetooth-data-device-serialport-function", + "label": "Serialport Function", + "type": "select", + "options": [ + "CI-V (Echo Back OFF)", + "CI-V (Echo Back ON)", + "DV Data" + ] + }, + { + "key": "section-ci-v", + "label": "CI-V", + "type": "section" + }, + { + "key": "ci-v-ci-v-baud-rate", + "label": "CI-V Baud Rate", + "type": "select", + "options": [ + "4800bps", + "9600bps", + "19200bps", + "Auto" + ] + }, + { + "key": "ci-v-ci-v-transceive", + "label": "CI-V Transceive", + "type": "boolean" + }, + { + "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-bluetooth-remote-transceive-address", + "label": "CI-V Bluetooth -> REMOTE Transceive Address", + "type": "select", + "options": [ + "00", + "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": "section-display", + "label": "Display", + "type": "section" + }, + { + "key": "display-backlight", + "label": "Backlight", + "type": "select", + "options": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8" + ] + }, + { + "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-scroll-speed", + "label": "Scroll Speed", + "type": "select", + "options": [ + "Slow", + "Fast" + ] + }, + { + "key": "display-opening-message", + "label": "Opening Message", + "type": "boolean" + }, + { + "key": "display-voltage-indication-power-on", + "label": "Voltage Indication (Power ON)", + "type": "boolean" + }, + { + "key": "display-clock-voltage-display", + "label": "Clock/Voltage Display", + "type": "select", + "options": [ + "Clock", + "Voltage" + ] + }, + { + "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-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-display-backlight-night-time-setting", + "label": "Display - Backlight Night Time Setting", + "type": "section" + }, + { + "key": "display-backlight-night-time-setting-night-time-setting", + "label": "Night Time Setting", + "type": "boolean" + }, + { + "key": "display-backlight-night-time-setting-brightness", + "label": "Brightness", + "type": "select", + "options": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8" + ] + }, + { + "key": "display-backlight-night-time-setting-night-time-start-hour", + "label": "Night Time Start (hour)", + "type": "integer", + "min": 0, + "max": 23 + }, + { + "key": "display-backlight-night-time-setting-night-time-start-minute", + "label": "Night Time Start (minute)", + "type": "integer", + "min": 0, + "max": 59 + }, + { + "key": "display-backlight-night-time-setting-night-time-end-hour", + "label": "Night Time End (hour)", + "type": "integer", + "min": 0, + "max": 23 + }, + { + "key": "display-backlight-night-time-setting-night-time-end-minute", + "label": "Night Time End (minute)", + "type": "integer", + "min": 0, + "max": 59 + }, + { + "key": "section-display-auto-dimmer-setting", + "label": "Display - Auto Dimmer Setting", + "type": "section" + }, + { + "key": "display-auto-dimmer-setting-auto-dimmer", + "label": "Auto Dimmer", + "type": "select", + "options": [ + "OFF", + "Auto-OFF", + "Auto-1", + "Auto-2", + "Auto-3", + "Auto-4", + "Auto-5", + "Auto-6", + "Auto-7" + ] + }, + { + "key": "display-auto-dimmer-setting-auto-dimmer-timer", + "label": "Auto Dimmer Timer", + "type": "select", + "options": [ + "5sec", + "10sec" + ] + }, + { + "key": "display-auto-dimmer-setting-touch-operation-dimmed", + "label": "Touch Operation (Dimmed)", + "type": "select", + "options": [ + "Brighten", + "Brighten & Action" + ] + }, + { + "key": "display-auto-dimmer-setting-auto-dimmer-cancel-ptt", + "label": "Auto Dimmer Cancel (PTT)", + "type": "boolean" + }, + { + "key": "display-auto-dimmer-setting-auto-dimmer-cancel-dv-rx", + "label": "Auto Dimmer Cancel (DV RX)", + "type": "boolean" + }, + { + "key": "section-display-dv", + "label": "Display (DV)", + "type": "section" + }, + { + "key": "display-dv-rx-call-sign-display", + "label": "RX Call Sign Display", + "type": "select", + "options": [ + "OFF", + "Normal", + "RX Hold", + "Hold" + ] + }, + { + "key": "display-dv-rx-position-indicator", + "label": "RX Position Indicator", + "type": "boolean" + }, + { + "key": "display-dv-rx-position-display", + "label": "RX Position Display", + "type": "select", + "options": [ + "OFF", + "ON (Main/Sub)", + "ON (Main Only)" + ] + }, + { + "key": "display-dv-rx-position-display-timer", + "label": "RX Position Display Timer", + "type": "select", + "options": [ + "5sec", + "10sec", + "15sec", + "30sec", + "Hold" + ] + }, + { + "key": "display-dv-reply-position-display", + "label": "Reply Position Display", + "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", + "label": "Sounds", + "type": "section" + }, + { + "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-main-rx", + "label": "Sub Band Mute (Main RX)", + "type": "select", + "options": [ + "OFF", + "Mute", + "Beep", + "Mute & Beep" + ] + }, + { + "key": "sounds-sub-band-mute-tx", + "label": "Sub Band Mute (TX)", + "type": "boolean" + }, + { + "key": "sounds-scope-af-output", + "label": "Scope AF Output", + "type": "boolean" + }, + { + "key": "sounds-beep-level", + "label": "Beep Level", + "type": "select", + "options": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "key": "section-sounds-dv", + "label": "Sounds (DV)", + "type": "section" + }, + { + "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": "digital-sounds-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" + ] + }, + { + "key": "section-speech", + "label": "SPEECH", + "type": "section" + }, + { + "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-recorder", + "label": "QSO Recorder - Recorder", + "type": "section" + }, + { + "key": "qso-recorder-recorder-rec-mode", + "label": "REC Mode", + "type": "select", + "options": [ + "TX&RX", + "RX Only" + ] + }, + { + "key": "qso-recorder-recorder-rx-rec-condition", + "label": "RX REC Condition", + "type": "select", + "options": [ + "Always", + "Squelch Auto" + ] + }, + { + "key": "qso-recorder-recorder-file-split", + "label": "File Split", + "type": "boolean" + }, + { + "key": "qso-recorder-recorder-ptt-auto-rec", + "label": "PTT Auto REC", + "type": "boolean" + }, + { + "key": "section-qso-recorder-player", + "label": "QSO Recorder - Player", + "type": "section" + }, + { + "key": "qso-recorder-player-skip-time", + "label": "Skip Time", + "type": "select", + "options": [ + "3sec", + "5sec", + "10sec", + "30sec" + ] + }, + { + "key": "section-voice-tx", + "label": "Voice TX", + "type": "section" + }, + { + "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": "voice-tx-mic-select-rec", + "label": "MIC Select(REC)", + "type": "select", + "options": [ + "Hand MIC", + "Bluetooth MIC" + ] + }, + { + "key": "section-qso-rx-history-log", + "label": "QSO/RX History Log", + "type": "section" + }, + { + "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": "section-qso-rx-history-log-csv-format", + "label": "QSO/RX History Log - CSV Format", + "type": "section" + }, + { + "key": "qso-rx-history-log-csv-format-separator-decimal", + "label": "Separator/Decimal", + "type": "select", + "options": [ + "Sep [,] Dec [.]", + "Sep [;] Dec [.]", + "Sep [;] Dec [,]" + ] + }, + { + "key": "qso-rx-history-log-csv-format-date", + "label": "Date", + "type": "select", + "options": [ + "yyyy/mm/dd", + "mm/dd/yyyy", + "dd/mm/yyyy" + ] + }, + { + "key": "section-save", + "label": "Save", + "type": "section" + }, + { + "key": "save-save-form", + "label": "Save Form", + "type": "select", + "options": [ + "Now Ver", + "Old Ver (1.10)", + "Old Ver (1.00 - 1.05)" + ] + }, + { + "key": "section-import-export-csv-format", + "label": "Import/Export - CSV Format", + "type": "section" + }, + { + "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" + }, + { + "key": "others-rf-power-vhf", + "label": "RF Power(VHF)", + "type": "select", + "options": [ + "Low", + "Mid", + "High" + ] + }, + { + "key": "others-rf-power-uhf", + "label": "RF Power(UHF)", + "type": "select", + "options": [ + "Low", + "Mid", + "High" + ] + }, + { + "key": "others-tone-burst", + "label": "Tone Burst", + "type": "boolean" + }, + { + "key": "others-heterodyne-a-band-vhf", + "label": "Heterodyne (A BAND VHF)", + "type": "select", + "options": [ + "Normal", + "Reverse" + ] + }, + { + "key": "others-heterodyne-a-band-uhf", + "label": "Heterodyne (A BAND UHF)", + "type": "select", + "options": [ + "Normal", + "Reverse" + ] + }, + { + "key": "others-heterodyne-b-band-uhf", + "label": "Heterodyne (B BAND UHF)", + "type": "select", + "options": [ + "Normal", + "Reverse" + ] + }, + { + "key": "others-repeater-mode-hang-up-time", + "label": "Hang Up Time", + "type": "boolean" + }, + { + "key": "section-scan", + "label": "Scan", + "type": "section" + }, + { + "key": "scan-pause-timer-a", + "label": "Pause Timer (A)", + "type": "select", + "options": [ + "2sec", + "4sec", + "6sec", + "8sec", + "10sec", + "12sec", + "14sec", + "16sec", + "18sec", + "20sec", + "Hold" + ] + }, + { + "key": "scan-pause-timer-b", + "label": "Pause Timer (B)", + "type": "select", + "options": [ + "2sec", + "4sec", + "6sec", + "8sec", + "10sec", + "12sec", + "14sec", + "16sec", + "18sec", + "20sec", + "Hold" + ] + }, + { + "key": "scan-resume-timer-a", + "label": "Resume Timer (A)", + "type": "select", + "options": [ + "0sec", + "1sec", + "2sec", + "3sec", + "4sec", + "5sec", + "Hold" + ] + }, + { + "key": "scan-resume-timer-b", + "label": "Resume Timer (B)", + "type": "select", + "options": [ + "0sec", + "1sec", + "2sec", + "3sec", + "4sec", + "5sec", + "Hold" + ] + }, + { + "key": "scan-temporary-skip-timer-a", + "label": "Temporary Skip Timer (A)", + "type": "select", + "options": [ + "5min", + "10min", + "15min" + ] + }, + { + "key": "scan-temporary-skip-timer-b", + "label": "Temporary Skip Timer (B)", + "type": "select", + "options": [ + "5min", + "10min", + "15min" + ] + }, + { + "key": "scan-program-skip-scan-a", + "label": "Program Skip Scan (A)", + "type": "boolean" + }, + { + "key": "scan-program-skip-scan-b", + "label": "Program Skip Scan (B)", + "type": "boolean" + }, + { + "key": "section-scan-bank-link-a-band", + "label": "Scan > Bank Link (A band)", + "type": "section" + }, + { + "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-band", + "label": "Scan > Bank Link (B band)", + "type": "section" + }, + { + "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-gps", + "label": "GPS", + "type": "section" + }, + { + "key": "gps-gps-select", + "label": "GPS Select", + "type": "select", + "options": [ + "OFF", + "Internal GPS", + "External GPS", + "Manual" + ] + }, + { + "key": "gps-compass-direction", + "label": "Compass Direction", + "type": "select", + "options": [ + "Heading Up", + "North Up", + "South Up" + ] + }, + { + "key": "gps-gps-time-correct", + "label": "GPS Time Correct", + "type": "select", + "options": [ + "OFF", + "Auto" + ] + }, + { + "key": "gps-gps-out-to-data-jack", + "label": "GPS Out (To DATA Jack)", + "type": "boolean" + }, + { + "key": "gps-gps-position-display-select-main-sub", + "label": "GPS Position Display Select (MAIN/SUB)", + "type": "select", + "options": [ + "Latest (MAIN/SUB)", + "MAIN", + "SUB" + ] + }, + { + "key": "section-gps-alarm", + "label": "GPS Alarm", + "type": "section" + }, + { + "key": "gps-alarm-alarm-area-rx-memory", + "label": "Alarm Area (RX/Memory)", + "type": "select", + "options": [ + "Limited", + "Extended", + "Both" + ] + }, + { + "key": "section-gps-tx", + "label": "GPS TX", + "type": "section" + }, + { + "key": "gps-tx-gps-tx-mode", + "label": "GPS TX Mode", + "type": "select", + "options": [ + "OFF", + "D-PRS", + "NMEA" + ] + }, + { + "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-gps-tx-nmea", + "label": "GPS TX (NMEA)", + "type": "section" + }, + { + "key": "gps-tx-nmea-gps-message", + "label": "GPS Message", + "type": "text", + "max_length": 20 + }, + { + "key": "section-gps-tx-nmea-sentences-at-most-4", + "label": "GPS TX (NMEA) sentences — at most 4", + "type": "section" + }, + { + "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" + }, + { + "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-at-least-1", + "label": "GPS Logger record sentences — at least 1", + "type": "section" + }, + { + "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-gps-manual-position", + "label": "GPS Manual Position", + "type": "section" + }, + { + "key": "gps-manual-position-latitude", + "label": "Latitude", + "type": "text", + "max_length": 11, + "placeholder": "39.758333 (negative = S; blank = leave as is)" + }, + { + "key": "gps-manual-position-longitude", + "label": "Longitude", + "type": "text", + "max_length": 12, + "placeholder": "-105.087500 (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-d-prs-aprs", + "label": "D-PRS (APRS)", + "type": "section" + }, + { + "key": "d-prs-tx-format", + "label": "TX Format", + "type": "select", + "options": [ + "Position", + "Object", + "Item", + "Weather" + ] + }, + { + "key": "d-prs-unproto-address", + "label": "Unproto Address", + "type": "text", + "max_length": 56 + }, + { + "key": "section-d-prs-position-aprs", + "label": "D-PRS Position (APRS)", + "type": "section" + }, + { + "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-ch", + "label": "Comment CH", + "type": "select", + "options": [ + "No.1", + "No.2", + "No.3", + "No.4" + ] + }, + { + "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", + "Power/Height/Gain/Directivity" + ] + }, + { + "key": "d-prs-position-power", + "label": "Power", + "type": "select", + "options": [ + "0W", + "1W", + "4W", + "9W", + "16W", + "25W", + "36W", + "49W", + "64W", + "81W" + ] + }, + { + "key": "d-prs-position-height", + "label": "Height", + "type": "select", + "options": [ + "3m", + "6m", + "12m", + "24m", + "49m", + "98m", + "195m", + "390m", + "780m", + "1561m" + ] + }, + { + "key": "d-prs-position-gain", + "label": "Gain", + "type": "select", + "options": [ + "0dB", + "1dB", + "2dB", + "3dB", + "4dB", + "5dB", + "6dB", + "7dB", + "8dB", + "9dB" + ] + }, + { + "key": "d-prs-position-directivity", + "label": "Directivity", + "type": "select", + "options": [ + "Omni", + "45°NE", + "90°E", + "135°SE", + "180°S", + "225°SW", + "270°W", + "315°NW", + "360°N" + ] + }, + { + "key": "d-prs-position-symbol-no-1", + "label": "Symbol (No.1)", + "type": "text", + "max_length": 2 + }, + { + "key": "d-prs-position-symbol-no-2", + "label": "Symbol (No.2)", + "type": "text", + "max_length": 2 + }, + { + "key": "d-prs-position-symbol-no-3", + "label": "Symbol (No.3)", + "type": "text", + "max_length": 2 + }, + { + "key": "d-prs-position-symbol-no-4", + "label": "Symbol (No.4)", + "type": "text", + "max_length": 2 + }, + { + "key": "d-prs-position-comment-no-1", + "label": "Comment (No.1)", + "type": "text", + "max_length": 43 + }, + { + "key": "d-prs-position-comment-no-2", + "label": "Comment (No.2)", + "type": "text", + "max_length": 43 + }, + { + "key": "d-prs-position-comment-no-3", + "label": "Comment (No.3)", + "type": "text", + "max_length": 43 + }, + { + "key": "d-prs-position-comment-no-4", + "label": "Comment (No.4)", + "type": "text", + "max_length": 43 + }, + { + "key": "section-d-prs-object-aprs", + "label": "D-PRS Object (APRS)", + "type": "section" + }, + { + "key": "d-prs-object-data-type", + "label": "Data Type", + "type": "select", + "options": [ + "Live Object", + "Killed Object" + ] + }, + { + "key": "d-prs-object-data-extension", + "label": "Data Extension", + "type": "select", + "options": [ + "OFF", + "Course/Speed", + "Power/Height/Gain/Directivity" + ] + }, + { + "key": "d-prs-object-power", + "label": "Power", + "type": "select", + "options": [ + "0W", + "1W", + "4W", + "9W", + "16W", + "25W", + "36W", + "49W", + "64W", + "81W" + ] + }, + { + "key": "d-prs-object-height", + "label": "Height", + "type": "select", + "options": [ + "3m", + "6m", + "12m", + "24m", + "49m", + "98m", + "195m", + "390m", + "780m", + "1561m" + ] + }, + { + "key": "d-prs-object-gain", + "label": "Gain", + "type": "select", + "options": [ + "0dB", + "1dB", + "2dB", + "3dB", + "4dB", + "5dB", + "6dB", + "7dB", + "8dB", + "9dB" + ] + }, + { + "key": "d-prs-object-directivity", + "label": "Directivity", + "type": "select", + "options": [ + "Omni", + "45°NE", + "90°E", + "135°SE", + "180°S", + "225°SW", + "270°W", + "315°NW", + "360°N" + ] + }, + { + "key": "d-prs-object-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-object-time-stamp", + "label": "Time Stamp", + "type": "select", + "options": [ + "DHM", + "HMS" + ] + }, + { + "key": "d-prs-object-symbol", + "label": "Symbol", + "type": "text", + "max_length": 2 + }, + { + "key": "d-prs-object-comment", + "label": "Comment", + "type": "text", + "max_length": 43 + }, + { + "key": "d-prs-object-object-name", + "label": "Object Name", + "type": "text", + "max_length": 9 + }, + { + "key": "d-prs-object-course-degrees", + "label": "Course (degrees)", + "type": "integer", + "min": 0, + "max": 360 + }, + { + "key": "d-prs-object-speed-tenths-of-a-knot", + "label": "Speed (tenths of a knot)", + "type": "integer", + "min": 0, + "max": 9999 + }, + { + "key": "section-d-prs-object-position-aprs", + "label": "D-PRS Object position (APRS)", + "type": "section" + }, + { + "key": "d-prs-object-position-latitude", + "label": "Latitude", + "type": "text", + "max_length": 11, + "placeholder": "39.758333 (negative = S; blank = leave as is)" + }, + { + "key": "d-prs-object-position-longitude", + "label": "Longitude", + "type": "text", + "max_length": 12, + "placeholder": "-105.087500 (negative = W; blank = leave as is)" + }, + { + "key": "d-prs-object-position-altitude-m", + "label": "Altitude (m)", + "type": "text", + "max_length": 8, + "placeholder": "blank = leave as the radio has it" + }, + { + "key": "section-d-prs-item-aprs", + "label": "D-PRS Item (APRS)", + "type": "section" + }, + { + "key": "d-prs-item-data-type", + "label": "Data Type", + "type": "select", + "options": [ + "Live Item", + "Killed Item" + ] + }, + { + "key": "d-prs-item-data-extension", + "label": "Data Extension", + "type": "select", + "options": [ + "OFF", + "Course/Speed", + "Power/Height/Gain/Directivity" + ] + }, + { + "key": "d-prs-item-power", + "label": "Power", + "type": "select", + "options": [ + "0W", + "1W", + "4W", + "9W", + "16W", + "25W", + "36W", + "49W", + "64W", + "81W" + ] + }, + { + "key": "d-prs-item-height", + "label": "Height", + "type": "select", + "options": [ + "3m", + "6m", + "12m", + "24m", + "49m", + "98m", + "195m", + "390m", + "780m", + "1561m" + ] + }, + { + "key": "d-prs-item-gain", + "label": "Gain", + "type": "select", + "options": [ + "0dB", + "1dB", + "2dB", + "3dB", + "4dB", + "5dB", + "6dB", + "7dB", + "8dB", + "9dB" + ] + }, + { + "key": "d-prs-item-directivity", + "label": "Directivity", + "type": "select", + "options": [ + "Omni", + "45°NE", + "90°E", + "135°SE", + "180°S", + "225°SW", + "270°W", + "315°NW", + "360°N" + ] + }, + { + "key": "d-prs-item-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-item-symbol", + "label": "Symbol", + "type": "text", + "max_length": 2 + }, + { + "key": "d-prs-item-comment", + "label": "Comment", + "type": "text", + "max_length": 43 + }, + { + "key": "d-prs-item-item-name", + "label": "Item Name", + "type": "text", + "max_length": 9 + }, + { + "key": "d-prs-item-course-degrees", + "label": "Course (degrees)", + "type": "integer", + "min": 0, + "max": 360 + }, + { + "key": "d-prs-item-speed-tenths-of-a-knot", + "label": "Speed (tenths of a knot)", + "type": "integer", + "min": 0, + "max": 9999 + }, + { + "key": "section-d-prs-item-position-aprs", + "label": "D-PRS Item position (APRS)", + "type": "section" + }, + { + "key": "d-prs-item-position-latitude", + "label": "Latitude", + "type": "text", + "max_length": 11, + "placeholder": "39.758333 (negative = S; blank = leave as is)" + }, + { + "key": "d-prs-item-position-longitude", + "label": "Longitude", + "type": "text", + "max_length": 12, + "placeholder": "-105.087500 (negative = W; blank = leave as is)" + }, + { + "key": "d-prs-item-position-altitude-m", + "label": "Altitude (m)", + "type": "text", + "max_length": 8, + "placeholder": "blank = leave as the radio has it" + }, + { + "key": "section-d-prs-weather-aprs", + "label": "D-PRS Weather (APRS)", + "type": "section" + }, + { + "key": "d-prs-weather-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-weather-time-stamp", + "label": "Time Stamp", + "type": "select", + "options": [ + "OFF", + "DHM", + "HMS" + ] + }, + { + "key": "d-prs-weather-symbol", + "label": "Symbol", + "type": "text", + "max_length": 2 + }, + { + "key": "d-prs-weather-comment", + "label": "Comment", + "type": "text", + "max_length": 43 + }, + { + "key": "section-tone-control", + "label": "Tone Control", + "type": "section" + }, + { + "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" + }, + { + "key": "dv-set-mode-auto-reply", + "label": "Auto Reply", + "type": "select", + "options": [ + "OFF", + "ON", + "Voice", + "Position (Main Only)", + "Position (Main/Sub)" + ] + }, + { + "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-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-my-station", + "label": "My Station", + "type": "section" + }, + { + "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-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-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-in-use", + "label": "TX Message in use", + "type": "select", + "options": [ + "1", + "2", + "3", + "4", + "5" + ] + }, + { + "key": "section-dtmf", + "label": "DTMF", + "type": "section" + }, + { + "key": "dtmf-dtmf-speed", + "label": "DTMF Speed", + "type": "select", + "options": [ + "100ms", + "200ms", + "300ms", + "500ms" + ] + }, + { + "key": "section-time-set", + "label": "Time Set", + "type": "section" + }, + { + "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": "my-station-tx-message", + "label": "TX Message", + "type": "boolean" + } +] diff --git a/src-tauri/src/radios/baofeng_uv5r/mod.rs b/src-tauri/src/radios/baofeng_uv5r/mod.rs index ee99d33..d5b7ad4 100644 --- a/src-tauri/src/radios/baofeng_uv5r/mod.rs +++ b/src-tauri/src/radios/baofeng_uv5r/mod.rs @@ -368,6 +368,7 @@ impl ImageProgrammer for BaofengUv5r { // ranges rather than addressed windows. zones_written: 0, zones_cleared: 0, + banks_written: 0, scan_lists_written: 0, scan_lists_cleared: 0, contacts_written: 0, diff --git a/src-tauri/src/radios/binteradio_bt9000/mod.rs b/src-tauri/src/radios/binteradio_bt9000/mod.rs index 692877f..169a97d 100644 --- a/src-tauri/src/radios/binteradio_bt9000/mod.rs +++ b/src-tauri/src/radios/binteradio_bt9000/mod.rs @@ -1125,6 +1125,7 @@ impl ImageProgrammer for BinteradioBt9000 { channels: decode_channels(&image).into_iter().map(decoded_to_sample).collect(), zones_written: 0, zones_cleared: 0, + banks_written: 0, scan_lists_written: 0, scan_lists_cleared: 0, contacts_written: 0, diff --git a/src-tauri/src/radios/driver.rs b/src-tauri/src/radios/driver.rs index dc22f7b..1b344a4 100644 --- a/src-tauri/src/radios/driver.rs +++ b/src-tauri/src/radios/driver.rs @@ -211,6 +211,32 @@ pub(crate) trait ImageProgrammer: Send + Sync { fn carries_profile_settings(&self) -> bool { false } + + /// What the operator has to do at the radio once an upload finishes, if + /// anything. Shown above the Program / Restore buttons and while the write + /// runs, because the driver's read-back waits on it. + /// + /// The ID-5100 is why: after a clone-in it shows a message and sits there + /// until its POWER button is pressed, answering ID queries but stalling + /// any read (measured, #49). Nothing in the protocol can press it. + fn after_write_instruction(&self) -> Option<&'static str> { + None + } + + /// Whether a new profile's settings form must start BLANK rather than + /// seeded with schema defaults — the card radios' rule, for a cable radio + /// in the same position. + /// + /// True when the program patches the profile's settings into an image just + /// read off the radio (`carries_profile_settings`) and the schema carries no + /// defaults of its own: every value the form invented would then be WRITTEN, + /// replacing the operator's real setting. The ID-5100's first review found + /// exactly that — a profile opened and saved without "Download from radio" + /// would have blanked My Call Sign, unlinked every bank and zeroed Beep + /// Level on the next program (#49). + fn profile_starts_blank(&self) -> bool { + false + } } /// Attach the pre-write backup to an error raised DURING the write phase. @@ -253,6 +279,11 @@ pub(crate) struct ImageProgramRequest<'a> { /// profile carries them. `None` writes channels + names only. Passing them /// makes the profile authoritative over every editable setting. pub settings: Option<(&'a serde_json::Value, &'a str)>, + /// The codeplug's channel lists as banks of the slots above, in list order + /// (`export::banks_for_slots`). Only radios with NAMED banks of arbitrary + /// memories read it (the ID-5100); a radio whose groups are positional, or + /// that has none, ignores it. + pub banks: &'a [crate::commands::export::SlotBank], /// Where the mandatory pre-write backup goes; the driver names the file. pub backup_dir: &'a Path, /// Codeplug name, slugged into that filename so several codeplugs for one @@ -278,6 +309,8 @@ pub struct CodeplugProgramReport { pub settings_written: Option, pub zones_written: usize, pub zones_cleared: usize, + /// Named banks written (the ID-5100's A-Z). Zero on radios without them. + pub banks_written: usize, pub scan_lists_written: usize, pub scan_lists_cleared: usize, pub contacts_written: usize, @@ -619,6 +652,10 @@ pub struct DriverCapabilities { pub write_callsign_db: bool, pub export: bool, pub diagnostics: bool, + /// [`ImageProgrammer::after_write_instruction`], for the Program dialog. + pub after_write: Option<&'static str>, + /// [`ImageProgrammer::profile_starts_blank`], for the profile editor. + pub settings_start_blank: bool, } impl DriverCapabilities { @@ -639,6 +676,12 @@ impl DriverCapabilities { write_callsign_db: driver.as_callsign_db_writer().is_some(), export: driver.as_codeplug_exporter().is_some(), diagnostics: driver.as_diagnostics().is_some(), + after_write: driver + .as_image_programmer() + .and_then(ImageProgrammer::after_write_instruction), + settings_start_blank: driver + .as_image_programmer() + .is_some_and(ImageProgrammer::profile_starts_blank), } } } diff --git a/src-tauri/src/radios/icom_id5100/dev_export.rs b/src-tauri/src/radios/icom_id5100/dev_export.rs new file mode 100644 index 0000000..412ef3b --- /dev/null +++ b/src-tauri/src/radios/icom_id5100/dev_export.rs @@ -0,0 +1,112 @@ +//! THROWAWAY (issue #49, Phase 3 gate): a real codeplug from the dev database, +//! resolved by the app's own slot and bank resolvers against the SEEDED ID-5100 +//! row, built into a real clone read of Tim's radio — and counted. +//! +//! Needs the dev SQLite DB and a real read, neither of which is in the repo. +//! The DB is COPIED to a temp file and the app's own seeder is run on the copy, +//! so the ID-5100 row exists without the dev app having restarted and the dev +//! DB itself is never written. Any codeplug's lists can be resolved as an +//! ID-5100 codeplug: the model is taken from the seed, not the codeplug. +//! +//! ```sh +//! CPM_DEV_DB="$HOME/Library/Application Support/com.ww8l.codeplugmagic.dev/codeplug_manager.sqlite3" \ +//! CPM_CODEPLUG=3 CPM_ID5100_BASE=../scratchpad/id5100/id5100_07_rust_read.img \ +//! CPM_ID5100_OUT=../scratchpad/id5100/id5100_dev_export.img \ +//! cargo test --lib icom_id5100::dev_export -- --ignored --nocapture +//! ``` + +use std::collections::HashSet; + +use crate::commands::export::{ + banks_for_slots, exclusion_reason, expand_for_export, expanded_names, resolve_codeplug_groups, + SlotChannel, +}; +use crate::models::RadioModel; + +use super::layout::IMAGE_LEN; +use super::memory::{decode_memories, read_bank}; +use super::program::build_codeplug; + +#[tokio::test] +#[ignore = "needs the dev database and a real ID-5100 read"] +async fn a_real_codeplug_builds_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 base = std::fs::read(std::env::var("CPM_ID5100_BASE").expect("CPM_ID5100_BASE")).unwrap(); + let base = &base[..IMAGE_LEN]; + + let copy = std::env::temp_dir().join("id5100_dev_export.sqlite3"); + std::fs::copy(&db, ©).expect("copy dev db"); + let pool = sqlx::SqlitePool::connect(&format!("sqlite:{}", copy.display())) + .await + .expect("open copy"); + crate::seed::seed_radio_models(&pool).await.expect("seed"); + let model: RadioModel = + sqlx::query_as("SELECT * FROM radio_models WHERE model = 'ID-5100'") + .fetch_one(&pool) + .await + .expect("seeded ID-5100 row"); + + // `resolve_codeplug_slots`, with the model swapped for the ID-5100's. + let groups = resolve_codeplug_groups(&pool, codeplug_id).await.expect("groups"); + let mut seen = 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<_> = expanded + .iter() + .filter(|ec| match exclusion_reason(&ec.channel, &model) { + Some(why) => { + excluded.push(format!(" {:>10.4} {why}", ec.channel.rx_freq)); + false + } + None => true, + }) + .collect(); + let names = expanded_names(included.iter().copied(), &model); + let slots: Vec = included + .into_iter() + .zip(names) + .enumerate() + .map(|(slot, (ec, name))| SlotChannel { slot, name, channel: ec.channel.clone() }) + .collect(); + let banks = banks_for_slots(&groups, &slots); + + println!("{} channels in, {} excluded:", expanded.len(), excluded.len()); + excluded.iter().for_each(|l| println!("{l}")); + for b in &banks { + println!("list {:?}: {} slots", b.name, b.slots.len()); + } + + let built = build_codeplug(&model, &slots, &banks, base).expect("build"); + for w in &built.warnings { + println!("warning: {w}"); + } + let decoded = decode_memories(&built.image); + for m in &decoded { + println!( + "{:4} {:<16} {:>10.4} {:>8} {:<14} {:<4} {:?}", + m.slot, m.name, m.rx_mhz, m.shift, m.tone, m.mode, m.bank + ); + } + // What went in is what came out: every slot, at its frequency, in its bank. + assert_eq!(decoded.len(), slots.len()); + for (sc, m) in slots.iter().zip(&decoded) { + assert_eq!(sc.slot, m.slot); + assert!((sc.channel.rx_freq - m.rx_mhz).abs() < 1e-5, "{} {}", sc.channel.rx_freq, m.rx_mhz); + } + let banked = (0..slots.len()).filter(|&s| read_bank(&built.image, s).is_some()).count(); + println!( + "{} memories written, {banked} banked, {} banks named", + decoded.len(), + built.banks_written + ); + if let Ok(out) = std::env::var("CPM_ID5100_OUT") { + std::fs::write(&out, &built.image).unwrap(); + println!("wrote {out}"); + } +} diff --git a/src-tauri/src/radios/icom_id5100/hw_ladder.rs b/src-tauri/src/radios/icom_id5100/hw_ladder.rs new file mode 100644 index 0000000..27ef3b8 --- /dev/null +++ b/src-tauri/src/radios/icom_id5100/hw_ladder.rs @@ -0,0 +1,194 @@ +//! THROWAWAY (issue #49, Phase 5): the hardware ladder's write steps, run +//! through the driver's own protocol functions. +//! +//! Every step: read the radio and save that read as the backup, build the file +//! to write and ASSERT its property before a byte goes out, upload, read back, +//! and compare. One process per step. +//! +//! - `identity` — upload the read unchanged. Proves the upload path. +//! - `rename` — change one memory's name (`CPM_ID5100_SLOT`, default 0) to +//! `LADDER STEP 2`. Proves a changed image is taken. +//! - `file` — upload `CPM_ID5100_FILE` (a built codeplug, or a backup to +//! restore). Only the memory area and bank tables may differ from the fresh +//! read, or the step refuses. +//! +//! ```sh +//! CPM_ID5100_PORT=/dev/cu.usbserial-RT1SQ1OS CPM_ID5100_STEP=identity \ +//! CPM_ID5100_DIR=../scratchpad/id5100 \ +//! cargo test --lib icom_id5100::hw_ladder -- --ignored --nocapture +//! ``` + +use super::layout::{BANK_NAMES, BANK_NAME_LEN, BANK_COUNT, IMAGE_LEN, NAME_LEN, VOLATILE_BASE}; +use super::memory::{is_used, read_record, store, read_bank}; +use super::protocol::{download, identify, open_port, upload}; + +fn read(port: &str) -> Vec { + let mut p = open_port(port).unwrap(); + identify(&mut *p).unwrap(); + download(&mut *p).unwrap() +} + +#[test] +#[ignore = "WRITES to a real ID-5100 on CPM_ID5100_PORT"] +fn ladder_step() { + let port = std::env::var("CPM_ID5100_PORT").expect("CPM_ID5100_PORT"); + let step = std::env::var("CPM_ID5100_STEP").expect("CPM_ID5100_STEP"); + let dir = std::path::PathBuf::from(std::env::var("CPM_ID5100_DIR").expect("CPM_ID5100_DIR")); + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + + let base = read(&port); + let backup = dir.join(format!("id5100_ladder_{step}_before_{stamp}.img")); + std::fs::write(&backup, &base).unwrap(); + println!("backup: {}", backup.display()); + + let built = match step.as_str() { + "identity" => base.clone(), + "rename" => { + let slot: usize = std::env::var("CPM_ID5100_SLOT").map(|s| s.parse().unwrap()).unwrap_or(0); + assert!(is_used(&base, slot), "memory {slot} is empty; pick a used one"); + let mut rec = read_record(&base, slot); + println!("memory {slot}: {:?} -> \"LADDER STEP 2\"", rec.name()); + rec.name = [b' '; NAME_LEN]; + rec.name[..13].copy_from_slice(b"LADDER STEP 2"); + let mut img = base.clone(); + store(&mut img, slot, &rec, read_bank(&base, slot)); + // The property, asserted before the write: exactly the name bytes + // moved (a skipped memory also loses its skip bit via `store`). + let moved: Vec = (0..IMAGE_LEN).filter(|&i| img[i] != base[i]).collect(); + println!("bytes changed: {}", moved.len()); + assert!(moved.len() <= NAME_LEN + 1 && !moved.is_empty()); + img + } + "bandprobe" => { + // Memories 990-999 in bank Z "BAND PROBE": the manual's coverage + // edges, and one step past each. Built by the driver's own encoder + // — the app's `rx_bands` would never let the outside ones through, + // which is exactly why they have to be asked of the radio directly. + let probes: [(f64, &str, &str); 10] = [ + (118.000, "AM", "P1 118.000 AM"), + (136.990, "AM", "P2 136.990 AM"), + (137.000, "FM", "P3 137.000"), + (174.000, "FM", "P4 174.000"), + (375.000, "FM", "P5 375.000"), + (550.000, "FM", "P6 550.000"), + (117.975, "AM", "X1 117.975 AM"), + (174.005, "FM", "X2 174.005"), + (374.995, "FM", "X3 374.995"), + (550.005, "FM", "X4 550.005"), + ]; + let mut img = base.clone(); + for (i, (f, mode, name)) in probes.iter().enumerate() { + let slot = 990 + i; + assert!(!is_used(&base, slot), "memory {slot} is in use; pick other slots"); + let ec = crate::commands::export::ExpandedChannel { + channel: crate::models::Channel { + rx_freq: *f, + mode: Some((*mode).into()), + dcs_polarity: "NN".into(), + ..Default::default() + }, + tg_label: None, + timeslot: None, + tg_number: None, + tg_call_type: None, + tg_inline: false, + }; + let rec = super::memory::encode_channel(&ec, name).unwrap(); + store(&mut img, slot, &rec, Some((25, i as u8))); + } + super::memory::set_bank_name(&mut img, 25, "BAND PROBE"); + let outside: Vec = (0..IMAGE_LEN) + .filter(|&i| img[i] != base[i] && i >= BANK_NAMES + BANK_COUNT * BANK_NAME_LEN) + .collect(); + assert!(outside.is_empty(), "the probe build touched more than memories"); + println!( + "bytes changed: {}", + (0..IMAGE_LEN).filter(|&i| img[i] != base[i]).count() + ); + img + } + "settings" => { + // A profile from CPM_ID5100_PROFILE (JSON), applied by the same + // `settings::apply` the program path uses. Asserted before the + // write: nothing outside the named fields' bytes moved. + let profile: serde_json::Value = + serde_json::from_str(&std::env::var("CPM_ID5100_PROFILE").expect("CPM_ID5100_PROFILE")).unwrap(); + let mut img = base.clone(); + let (n, notes) = super::settings::apply(&mut img, &profile); + println!("settings applied: {n}, notes: {notes:?}"); + assert!(notes.is_empty()); + let decoded = super::settings::decode(&img); + for k in profile.as_object().unwrap().keys() { + println!(" {k}: {} -> {}", super::settings::decode(&base)[k], decoded[k]); + } + let moved: Vec = (0..IMAGE_LEN).filter(|&i| img[i] != base[i]).collect(); + println!("bytes changed: {} {:x?}", moved.len(), moved); + // Every changed byte belongs to a field the profile named. + let mut owned = std::collections::HashSet::new(); + for k in profile.as_object().unwrap().keys() { + let f = super::settings::ID5100_SETTINGS_FIELDS.iter().find(|f| f.key == k).expect(k); + let len = match &f.kind { + crate::radios::icom_id52::settings::SK::Text { len } + | crate::radios::icom_id52::settings::SK::TextFill { len, .. } => *len as usize, + crate::radios::icom_id52::settings::SK::Uint { width } + | crate::radios::icom_id52::settings::SK::Enum { width, .. } => *width as usize, + _ => 1, + }; + owned.extend(f.byte as usize..f.byte as usize + len); + } + assert!(moved.iter().all(|i| owned.contains(i)), "a byte outside the named fields moved"); + img + } + "file" => { + let f = std::env::var("CPM_ID5100_FILE").expect("CPM_ID5100_FILE"); + let img = std::fs::read(&f).unwrap()[..IMAGE_LEN].to_vec(); + let names_end = BANK_NAMES + BANK_COUNT * BANK_NAME_LEN; + let outside: Vec = (0..IMAGE_LEN) + .filter(|&i| img[i] != base[i] && i >= names_end && i < VOLATILE_BASE) + .collect(); + println!( + "{f}: {} bytes differ from the radio, {} of them outside the memory area", + (0..IMAGE_LEN).filter(|&i| img[i] != base[i]).count(), + outside.len() + ); + if std::env::var("CPM_ID5100_RESTORE").is_err() { + assert!(outside.is_empty(), "refusing: the file changes more than memories"); + } + img + } + other => panic!("unknown step {other}"), + }; + let target = dir.join(format!("id5100_ladder_{step}_written_{stamp}.img")); + std::fs::write(&target, &built).unwrap(); + + // The radio is deaf for ~7 s after the backup read; `reopen` waits it out. + let t = std::time::Instant::now(); + let mut p = super::reopen(&port).unwrap(); + println!("write session opened after {:.1}s", t.elapsed().as_secs_f64()); + let t = std::time::Instant::now(); + upload(&mut *p, &built).unwrap(); + drop(p); + println!("upload confirmed by the radio in {:.1}s", t.elapsed().as_secs_f64()); + + // How soon the radio answers again after a clone-in is not known; the + // driver retries identify. Measure it here. + let t = std::time::Instant::now(); + let after = loop { + std::thread::sleep(std::time::Duration::from_millis(500)); + let attempt = open_port(&port).and_then(|mut p| { + identify(&mut *p)?; + download(&mut *p) + }); + match attempt { + Ok(img) => break img, + Err(e) if t.elapsed().as_secs() < 60 => println!(" not yet ({e})"), + Err(e) => panic!("no read-back within 60 s: {e}"), + } + }; + println!("read back after {:.1}s", t.elapsed().as_secs_f64()); + std::fs::write(dir.join(format!("id5100_ladder_{step}_after_{stamp}.img")), &after).unwrap(); + let low = (0..VOLATILE_BASE).filter(|&i| after[i] != built[i]).count(); + let high = (VOLATILE_BASE..IMAGE_LEN).filter(|&i| after[i] != built[i]).count(); + println!("read-back vs written: {low} bytes differ below {VOLATILE_BASE:#x}, {high} above"); + assert_eq!(low, 0, "the memory area did not read back as written"); +} diff --git a/src-tauri/src/radios/icom_id5100/hw_read.rs b/src-tauri/src/radios/icom_id5100/hw_read.rs new file mode 100644 index 0000000..5023008 --- /dev/null +++ b/src-tauri/src/radios/icom_id5100/hw_read.rs @@ -0,0 +1,32 @@ +//! Hardware check of the Rust clone READ against the real radio. Read-only: +//! identify + clone-out, nothing sent that writes. +//! +//! ```sh +//! CPM_ID5100_PORT=/dev/cu.usbserial-RT1SQ1OS \ +//! CPM_ID5100_OUT=../scratchpad/id5100/id5100_07_rust_read.img \ +//! cargo test --lib icom_id5100::hw_read -- --ignored --nocapture +//! ``` + +use super::layout::{IMAGE_LEN, VOLATILE_BASE}; +use super::protocol::{download, identify, open_port}; + +#[test] +#[ignore = "talks to a real ID-5100 on CPM_ID5100_PORT"] +fn rust_clone_read_matches_the_radio() { + let port = std::env::var("CPM_ID5100_PORT").expect("CPM_ID5100_PORT"); + let out = std::env::var("CPM_ID5100_OUT").expect("CPM_ID5100_OUT"); + let mut p = open_port(&port).unwrap(); + let id = identify(&mut *p).unwrap(); + println!("identify: {} {}", id.matched, id.ident_hex); + let image = download(&mut *p).unwrap(); + assert_eq!(image.len(), IMAGE_LEN); + std::fs::write(&out, &image).unwrap(); + println!("wrote {out}"); + if let Ok(reference) = std::env::var("CPM_ID5100_COMPARE") { + let r = std::fs::read(&reference).unwrap(); + let low = (0..VOLATILE_BASE).filter(|&i| image[i] != r[i]).count(); + let high = (VOLATILE_BASE..IMAGE_LEN).filter(|&i| image[i] != r[i]).count(); + println!("vs {reference}: {low} bytes differ below {VOLATILE_BASE:#x}, {high} above"); + assert_eq!(low, 0); + } +} diff --git a/src-tauri/src/radios/icom_id5100/id5100_settings_table.rs b/src-tauri/src/radios/icom_id5100/id5100_settings_table.rs new file mode 100644 index 0000000..ba4228f --- /dev/null +++ b/src-tauri/src/radios/icom_id5100/id5100_settings_table.rs @@ -0,0 +1,289 @@ +// GENERATED by scratchpad/id5100/vm/gen_table.py from the CS-5100 sweeps and +// option cycles. Every address and every stored value was measured by moving +// one control in CS-5100 and diffing its saved file; every label was read off +// the grid for that value. Do not hand-edit; regenerate. +pub(crate) const ID5100_SETTINGS_FIELDS: &[SF] = &[ + SF { key: "set-mode-squelch-att-select", byte: 0x029AB5, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "S-Meter Squelch"), (2, "ATT")] } }, // Set Mode > Squelch/ATT Select + SF { key: "set-mode-squelch-delay", byte: 0x029AB6, kind: SK::Enum { width: 1, labels: &[(0, "Short"), (1, "Long")] } }, // Set Mode > Squelch Delay + SF { key: "set-mode-fan-control", byte: 0x029AB7, kind: SK::Enum { width: 1, labels: &[(0, "Slow"), (1, "Mid"), (2, "Fast"), (3, "Auto")] } }, // Set Mode > Fan Control + SF { key: "set-mode-auto-power-off", byte: 0x029A9F, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "30min"), (2, "60min"), (3, "90min"), (4, "120min")] } }, // Set Mode > Auto Power OFF + SF { key: "set-mode-dial-speed-up", byte: 0x029AAA, kind: SK::Bool }, // Set Mode > Dial Speed-UP + SF { key: "set-mode-one-touch-ptt-remote-mic", byte: 0x029AA2, kind: SK::Bool }, // Set Mode > One-Touch PTT (Remote MIC) + SF { key: "set-mode-ptt-lock", byte: 0x029AA3, kind: SK::Bool }, // Set Mode > PTT Lock + SF { key: "set-mode-busy-lockout", byte: 0x029AA4, kind: SK::Bool }, // Set Mode > Busy Lockout + SF { key: "set-mode-time-out-timer", byte: 0x029AA5, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "1min"), (2, "3min"), (3, "5min"), (4, "10min"), (5, "15min"), (6, "30min")] } }, // Set Mode > Time-Out Timer + SF { key: "set-mode-active-band", byte: 0x029AA6, kind: SK::Enum { width: 1, labels: &[(0, "Single"), (1, "ALL")] } }, // Set Mode > Active Band + SF { key: "set-mode-mic-gain", byte: 0x029AAF, kind: SK::Enum { width: 1, labels: &[(0, "1"), (1, "2"), (2, "3"), (3, "4")] } }, // Set Mode > MIC Gain + SF { key: "set-mode-touch-operation-sub", byte: 0x029B50, kind: SK::Enum { width: 1, labels: &[(0, "Main Select"), (1, "Function Select")] } }, // Set Mode > Touch Operation (Sub) + SF { key: "set-mode-keyboard-type", byte: 0x029B49, kind: SK::Enum { width: 1, labels: &[(0, "Ten-Key"), (1, "Full Keyboard")] } }, // Set Mode > Keyboard Type + SF { key: "set-mode-data-speed", byte: 0x029AB4, kind: SK::Enum { width: 1, labels: &[(0, "4800bps"), (1, "9600bps")] } }, // Set Mode > Data Speed + SF { key: "set-mode-power-off-with-no-controller", byte: 0x029AB8, kind: SK::Bool }, // Set Mode > Power OFF (With No Controller) + SF { key: "remote-mic-key-during-rx-standby-f-1", byte: 0x029B36, kind: SK::Enum { width: 1, labels: &[(0, "—"), (7, "Monitor"), (9, "MR (000 CH)"), (10, "MR (001 CH)"), (12, "DR"), (13, "FROM/TO (DR)"), (15, "BAND/BANK"), (16, "SCAN"), (17, "Temporary Skip"), (18, "RX>CS"), (19, "SPEECH"), (21, "MODE"), (22, "LOW"), (23, "DUP"), (24, "PRIO"), (25, "TONE/DSQL"), (26, "MW"), (27, "MUTE"), (28, "Voice TX (T1)"), (29, "DTMF DIRECT TX"), (30, "T-CALL")] } }, // Remote MIC Key > During RX/Standby [F-1] + SF { key: "remote-mic-key-during-rx-standby-f-2", byte: 0x029B37, kind: SK::Enum { width: 1, labels: &[(0, "—"), (7, "Monitor"), (9, "MR (000 CH)"), (10, "MR (001 CH)"), (12, "DR"), (13, "FROM/TO (DR)"), (15, "BAND/BANK"), (16, "SCAN"), (17, "Temporary Skip"), (18, "RX>CS"), (19, "SPEECH"), (21, "MODE"), (22, "LOW"), (23, "DUP"), (24, "PRIO"), (25, "TONE/DSQL"), (26, "MW"), (27, "MUTE"), (28, "Voice TX (T1)"), (29, "DTMF DIRECT TX"), (30, "T-CALL")] } }, // Remote MIC Key > During RX/Standby [F-2] + SF { key: "remote-mic-key-during-tx-f-1", byte: 0x029B3D, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "LOW"), (2, "Voice TX (T1)"), (3, "T-CALL")] } }, // Remote MIC Key > During TX [F-1] + SF { key: "remote-mic-key-during-tx-f-2", byte: 0x029B3E, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "LOW"), (2, "Voice TX (T1)"), (3, "T-CALL")] } }, // Remote MIC Key > During TX [F-2] + SF { key: "up-down-mic-key-during-rx-standby-up", byte: 0x029B38, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "UP"), (2, "DOWN"), (3, "VOL UP"), (4, "VOL DOWN"), (5, "SQL UP"), (6, "SQL DOWN"), (7, "Monitor"), (8, "CALL"), (9, "MR (000 CH)"), (10, "MR (001 CH)"), (11, "VFO/MR"), (12, "DR"), (13, "FROM/TO (DR)"), (14, "HOME CH"), (15, "BAND/BANK"), (16, "SCAN"), (17, "Temporary Skip"), (18, "RX>CS"), (19, "SPEECH"), (20, "MAIN/DUAL"), (21, "MODE"), (22, "LOW"), (23, "DUP"), (24, "PRIO"), (25, "TONE/DSQL"), (26, "MW"), (27, "MUTE"), (28, "Voice TX (T1)"), (30, "T-CALL")] } }, // Up/Down MIC Key > During RX/Standby [UP] + SF { key: "up-down-mic-key-during-rx-standby-dn", byte: 0x029B39, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "UP"), (2, "DOWN"), (3, "VOL UP"), (4, "VOL DOWN"), (5, "SQL UP"), (6, "SQL DOWN"), (7, "Monitor"), (8, "CALL"), (9, "MR (000 CH)"), (10, "MR (001 CH)"), (11, "VFO/MR"), (12, "DR"), (13, "FROM/TO (DR)"), (14, "HOME CH"), (15, "BAND/BANK"), (16, "SCAN"), (17, "Temporary Skip"), (18, "RX>CS"), (19, "SPEECH"), (20, "MAIN/DUAL"), (21, "MODE"), (22, "LOW"), (23, "DUP"), (24, "PRIO"), (25, "TONE/DSQL"), (26, "MW"), (27, "MUTE"), (28, "Voice TX (T1)"), (30, "T-CALL")] } }, // Up/Down MIC Key > During RX/Standby [DN] + SF { key: "up-down-mic-key-during-tx-up", byte: 0x029B3F, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "LOW"), (2, "Voice TX (T1)"), (3, "T-CALL")] } }, // Up/Down MIC Key > During TX [UP] + SF { key: "up-down-mic-key-during-tx-dn", byte: 0x029B40, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "LOW"), (2, "Voice TX (T1)"), (3, "T-CALL")] } }, // Up/Down MIC Key > During TX [DN] + SF { key: "bluetooth-bluetooth", byte: 0x029ABD, kind: SK::Bool }, // Bluetooth > Bluetooth + SF { key: "bluetooth-auto-connect", byte: 0x029ABE, kind: SK::Bool }, // Bluetooth > Auto Connect + SF { key: "bluetooth-headset-set-af-output", byte: 0x029ABF, kind: SK::Enum { width: 1, labels: &[(0, "Headset Only"), (1, "Headset & Speaker")] } }, // Bluetooth - Headset Set > AF Output + SF { key: "bluetooth-headset-set-headset-function-select", byte: 0x029B51, kind: SK::Enum { width: 1, labels: &[(0, "Normal"), (1, "Microphone"), (2, "PTT")] } }, // Bluetooth - Headset Set > Headset Function Select + SF { key: "bluetooth-headset-set-vox", byte: 0x029AB9, kind: SK::Bool }, // Bluetooth - Headset Set > VOX + SF { key: "bluetooth-headset-set-vox-level", byte: 0x029ABA, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5"), (6, "6"), (7, "7"), (8, "8"), (9, "9"), (10, "10")] } }, // Bluetooth - Headset Set > VOX Level + SF { key: "bluetooth-headset-set-vox-delay", byte: 0x029ABB, kind: SK::Enum { width: 1, labels: &[(0, "0.5sec"), (1, "1.0sec"), (2, "1.5sec"), (3, "2.0sec"), (4, "2.5sec"), (5, "3.0sec")] } }, // Bluetooth - Headset Set > VOX Delay + SF { key: "bluetooth-headset-set-vox-time-out-timer", byte: 0x029ABC, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "1min"), (2, "2min"), (3, "3min"), (4, "4min"), (5, "5min"), (6, "10min"), (7, "15min")] } }, // Bluetooth - Headset Set > VOX Time-Out Timer + SF { key: "bluetooth-icom-headset-power-save", byte: 0x029AC0, kind: SK::Bool }, // Bluetooth - Icom Headset > Power Save + SF { key: "bluetooth-icom-headset-one-touch-ptt", byte: 0x029AC1, kind: SK::Bool }, // Bluetooth - Icom Headset > One-Touch PTT + SF { key: "bluetooth-icom-headset-ptt-beep", byte: 0x029AC2, kind: SK::Bool }, // Bluetooth - Icom Headset > PTT Beep + SF { key: "bluetooth-icom-headset-custom-key-beep", byte: 0x029AC3, kind: SK::Bool }, // Bluetooth - Icom Headset > Custom Key Beep + SF { key: "bluetooth-icom-headset-custom-key-play", byte: 0x029B3A, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "UP"), (2, "DOWN"), (3, "VOL UP"), (4, "VOL DOWN"), (5, "SQL UP"), (6, "SQL DOWN"), (7, "Monitor"), (8, "CALL"), (9, "MR (000 CH)"), (10, "MR (001 CH)"), (11, "VFO/MR"), (12, "DR"), (13, "FROM/TO (DR)"), (14, "HOME CH"), (15, "BAND/BANK"), (16, "SCAN"), (17, "Temporary Skip"), (18, "RX>CS"), (19, "SPEECH"), (20, "MAIN/DUAL"), (21, "MODE"), (22, "LOW"), (23, "DUP"), (24, "PRIO"), (25, "TONE/DSQL"), (26, "MW"), (27, "MUTE"), (28, "Voice TX (T1)"), (30, "T-CALL")] } }, // Bluetooth - Icom Headset > Custom Key [PLAY] + SF { key: "bluetooth-icom-headset-custom-key-fwd", byte: 0x029B3B, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "UP"), (2, "DOWN"), (3, "VOL UP"), (4, "VOL DOWN"), (5, "SQL UP"), (6, "SQL DOWN"), (7, "Monitor"), (8, "CALL"), (9, "MR (000 CH)"), (10, "MR (001 CH)"), (11, "VFO/MR"), (12, "DR"), (13, "FROM/TO (DR)"), (14, "HOME CH"), (15, "BAND/BANK"), (16, "SCAN"), (17, "Temporary Skip"), (18, "RX>CS"), (19, "SPEECH"), (20, "MAIN/DUAL"), (21, "MODE"), (22, "LOW"), (23, "DUP"), (24, "PRIO"), (25, "TONE/DSQL"), (26, "MW"), (27, "MUTE"), (28, "Voice TX (T1)"), (30, "T-CALL")] } }, // Bluetooth - Icom Headset > Custom Key [FWD] + SF { key: "bluetooth-icom-headset-custom-key-rwd", byte: 0x029B3C, kind: SK::Enum { width: 1, labels: &[(0, "—"), (1, "UP"), (2, "DOWN"), (3, "VOL UP"), (4, "VOL DOWN"), (5, "SQL UP"), (6, "SQL DOWN"), (7, "Monitor"), (8, "CALL"), (9, "MR (000 CH)"), (10, "MR (001 CH)"), (11, "VFO/MR"), (12, "DR"), (13, "FROM/TO (DR)"), (14, "HOME CH"), (15, "BAND/BANK"), (16, "SCAN"), (17, "Temporary Skip"), (18, "RX>CS"), (19, "SPEECH"), (20, "MAIN/DUAL"), (21, "MODE"), (22, "LOW"), (23, "DUP"), (24, "PRIO"), (25, "TONE/DSQL"), (26, "MW"), (27, "MUTE"), (28, "Voice TX (T1)"), (30, "T-CALL")] } }, // Bluetooth - Icom Headset > Custom Key [RWD] + SF { key: "bluetooth-data-device-serialport-function", byte: 0x029AC4, kind: SK::Enum { width: 1, labels: &[(0, "CI-V (Echo Back OFF)"), (1, "CI-V (Echo Back ON)"), (2, "DV Data")] } }, // Bluetooth - Data Device > Serialport Function + SF { key: "ci-v-ci-v-baud-rate", byte: 0x029AB2, kind: SK::Enum { width: 1, labels: &[(2, "4800bps"), (3, "9600bps"), (4, "19200bps"), (5, "Auto")] } }, // CI-V > CI-V Baud Rate + SF { key: "ci-v-ci-v-transceive", byte: 0x029AB3, kind: SK::Bool }, // CI-V > CI-V Transceive + SF { key: "display-backlight", byte: 0x029AE8, kind: SK::Enum { width: 1, labels: &[(0, "1"), (1, "2"), (2, "3"), (3, "4"), (4, "5"), (5, "6"), (6, "7"), (7, "8")] } }, // Display > Backlight + SF { key: "display-lcd-contrast", byte: 0x029AE3, 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")] } }, // Display > LCD Contrast + SF { key: "display-scroll-speed", byte: 0x029AF1, kind: SK::Enum { width: 1, labels: &[(0, "Slow"), (1, "Fast")] } }, // Display > Scroll Speed + SF { key: "display-opening-message", byte: 0x029AF2, kind: SK::Bool }, // Display > Opening Message + SF { key: "display-voltage-indication-power-on", byte: 0x029AE7, kind: SK::Bool }, // Display > Voltage Indication (Power ON) + SF { key: "display-clock-voltage-display", byte: 0x029B55, kind: SK::Enum { width: 1, labels: &[(0, "Clock"), (1, "Voltage")] } }, // Display > Clock/Voltage Display + SF { key: "display-latitude-longitude-units", byte: 0x029AF5, kind: SK::Enum { width: 1, labels: &[(0, "ddd°mm.mm'"), (1, "ddd°mm'ss\"")] } }, // Display > Latitude/Longitude Units + SF { key: "display-altitude-distance-units", byte: 0x029AF6, kind: SK::Enum { width: 1, labels: &[(0, "m"), (1, "ft/ml")] } }, // Display > Altitude/Distance Units + SF { key: "display-speed-units", byte: 0x029AF7, kind: SK::Enum { width: 1, labels: &[(0, "km/h"), (1, "mph"), (2, "knots")] } }, // Display > Speed Units + SF { key: "display-temperature-units", byte: 0x029AF8, kind: SK::Enum { width: 1, labels: &[(0, "°C"), (1, "°F")] } }, // Display > Temperature Units + SF { key: "display-barometric-units", byte: 0x029AF9, kind: SK::Enum { width: 1, labels: &[(0, "hPa"), (1, "mb"), (2, "mmHg"), (3, "inHg")] } }, // Display > Barometric Units + SF { key: "display-rainfall-units", byte: 0x029AFA, kind: SK::Enum { width: 1, labels: &[(0, "mm"), (1, "inch")] } }, // Display > Rainfall Units + SF { key: "display-wind-speed-units", byte: 0x029AFB, kind: SK::Enum { width: 1, labels: &[(0, "m/s"), (1, "mph"), (2, "knots")] } }, // Display > Wind Speed Units + SF { key: "display-system-language", byte: 0x029AF4, kind: SK::Enum { width: 1, labels: &[(0, "English"), (1, "Japanese")] } }, // Display > System Language + SF { key: "display-display-type", byte: 0x029B0A, kind: SK::Enum { width: 1, labels: &[(0, "Freq (Name OFF)"), (1, "Freq"), (2, "Name")] } }, // Display > Display Type + SF { key: "display-backlight-night-time-setting-night-time-setting", byte: 0x029B56, kind: SK::Bool }, // Display - Backlight Night Time Setting > Night Time Setting + SF { key: "display-backlight-night-time-setting-brightness", byte: 0x029B57, kind: SK::Enum { width: 1, labels: &[(0, "1"), (1, "2"), (2, "3"), (3, "4"), (4, "5"), (5, "6"), (6, "7"), (7, "8")] } }, // Display - Backlight Night Time Setting > Brightness + SF { key: "display-auto-dimmer-setting-auto-dimmer", byte: 0x029AE6, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Auto-OFF"), (2, "Auto-1"), (3, "Auto-2"), (4, "Auto-3"), (5, "Auto-4"), (6, "Auto-5"), (7, "Auto-6"), (8, "Auto-7")] } }, // Display - Auto Dimmer Setting > Auto Dimmer + SF { key: "display-auto-dimmer-setting-auto-dimmer-timer", byte: 0x029AE4, kind: SK::Enum { width: 1, labels: &[(0, "5sec"), (1, "10sec")] } }, // Display - Auto Dimmer Setting > Auto Dimmer Timer + SF { key: "display-auto-dimmer-setting-touch-operation-dimmed", byte: 0x029B4F, kind: SK::Enum { width: 1, labels: &[(0, "Brighten"), (1, "Brighten & Action")] } }, // Display - Auto Dimmer Setting > Touch Operation (Dimmed) + SF { key: "display-auto-dimmer-setting-auto-dimmer-cancel-ptt", byte: 0x029B52, kind: SK::Bool }, // Display - Auto Dimmer Setting > Auto Dimmer Cancel (PTT) + SF { key: "display-auto-dimmer-setting-auto-dimmer-cancel-dv-rx", byte: 0x029B53, kind: SK::Bool }, // Display - Auto Dimmer Setting > Auto Dimmer Cancel (DV RX) + SF { key: "sounds-key-touch-beep", byte: 0x029AFD, kind: SK::Bool }, // Sounds > Key-Touch Beep + SF { key: "sounds-home-ch-beep", byte: 0x029B44, kind: SK::Bool }, // Sounds > Home CH Beep + SF { key: "sounds-band-edge-beep", byte: 0x029B00, kind: SK::Bool }, // Sounds > Band Edge Beep + SF { key: "sounds-scan-stop-beep", byte: 0x029AFE, kind: SK::Bool }, // Sounds > Scan Stop Beep + SF { key: "sounds-sub-band-mute-main-rx", byte: 0x029B21, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Mute"), (2, "Beep"), (3, "Mute & Beep")] } }, // Sounds > Sub Band Mute (Main RX) + SF { key: "sounds-sub-band-mute-tx", byte: 0x029B54, kind: SK::Bool }, // Sounds > Sub Band Mute (TX) + SF { key: "sounds-scope-af-output", byte: 0x029B02, kind: SK::Bool }, // Sounds > Scope AF Output + SF { key: "speech-rx-call-sign-speech", byte: 0x029B16, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "ON (Kerchunk)"), (2, "ON (All)")] } }, // SPEECH > RX Call Sign SPEECH + SF { key: "speech-rx-cs-speech", byte: 0x029B17, kind: SK::Bool }, // SPEECH > RX>CS SPEECH + SF { key: "speech-dial-speech", byte: 0x029B18, kind: SK::Bool }, // SPEECH > DIAL SPEECH + SF { key: "speech-mode-speech", byte: 0x029B19, kind: SK::Bool }, // SPEECH > MODE SPEECH + SF { key: "speech-speech-language", byte: 0x029B1A, kind: SK::Enum { width: 1, labels: &[(0, "English"), (1, "Japanese")] } }, // SPEECH > SPEECH Language + SF { key: "speech-alphabet", byte: 0x029B1B, kind: SK::Enum { width: 1, labels: &[(0, "Normal"), (1, "Phonetic Code")] } }, // SPEECH > Alphabet + SF { key: "speech-speech-speed", byte: 0x029B1C, kind: SK::Enum { width: 1, labels: &[(0, "Slow"), (1, "Fast")] } }, // SPEECH > SPEECH Speed + SF { key: "qso-recorder-recorder-rec-mode", byte: 0x029B0E, kind: SK::Enum { width: 1, labels: &[(0, "TX&RX"), (1, "RX Only")] } }, // QSO Recorder - Recorder > REC Mode + SF { key: "qso-recorder-recorder-rx-rec-condition", byte: 0x029B0D, kind: SK::Enum { width: 1, labels: &[(0, "Always"), (1, "Squelch Auto")] } }, // QSO Recorder - Recorder > RX REC Condition + SF { key: "qso-recorder-recorder-file-split", byte: 0x029B0C, kind: SK::Bool }, // QSO Recorder - Recorder > File Split + SF { key: "qso-recorder-recorder-ptt-auto-rec", byte: 0x029B1E, kind: SK::Bool }, // QSO Recorder - Recorder > PTT Auto REC + SF { key: "qso-recorder-player-skip-time", byte: 0x029B1F, kind: SK::Enum { width: 1, labels: &[(0, "3sec"), (1, "5sec"), (2, "10sec"), (3, "30sec")] } }, // QSO Recorder - Player > Skip Time + SF { key: "voice-tx-repeat-time", byte: 0x029B28, 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")] } }, // Voice TX > Repeat Time + SF { key: "voice-tx-tx-monitor", byte: 0x029B29, kind: SK::Bool }, // Voice TX > TX Monitor + SF { key: "voice-tx-mic-select-rec", byte: 0x029B27, kind: SK::Enum { width: 1, labels: &[(0, "Hand MIC"), (1, "Bluetooth MIC")] } }, // Voice TX > MIC Select(REC) + SF { key: "qso-rx-history-log-qso-log", byte: 0x029B2A, kind: SK::Bool }, // QSO/RX History Log > QSO Log + SF { key: "qso-rx-history-log-rx-history-log", byte: 0x029B2B, kind: SK::Bool }, // QSO/RX History Log > RX History Log + SF { key: "qso-rx-history-log-csv-format-separator-decimal", byte: 0x029B2C, kind: SK::Enum { width: 1, labels: &[(0, "Sep [,] Dec [.]"), (1, "Sep [;] Dec [.]"), (2, "Sep [;] Dec [,]")] } }, // QSO/RX History Log - CSV Format > Separator/Decimal + SF { key: "qso-rx-history-log-csv-format-date", byte: 0x029B2D, kind: SK::Enum { width: 1, labels: &[(0, "yyyy/mm/dd"), (1, "mm/dd/yyyy"), (2, "dd/mm/yyyy")] } }, // QSO/RX History Log - CSV Format > Date + SF { key: "save-save-form", byte: 0x029B05, kind: SK::Enum { width: 1, labels: &[(0, "Now Ver"), (1, "Old Ver (1.10)"), (2, "Old Ver (1.00 - 1.05)")] } }, // Save > Save Form + SF { key: "import-export-csv-format-separator-decimal", byte: 0x029B4D, kind: SK::Enum { width: 1, labels: &[(0, "Sep [,] Dec [.]"), (1, "Sep [;] Dec [.]"), (2, "Sep [;] Dec [,]")] } }, // Import/Export - CSV Format > Separator/Decimal + SF { key: "import-export-csv-format-date", byte: 0x029B4E, kind: SK::Enum { width: 1, labels: &[(0, "yyyy/mm/dd"), (1, "mm/dd/yyyy"), (2, "dd/mm/yyyy")] } }, // Import/Export - CSV Format > Date + SF { key: "others-rf-power-vhf", byte: 0x029B66, kind: SK::Enum { width: 1, labels: &[(0, "Low"), (1, "Mid"), (2, "High")] } }, // Others > RF Power(VHF) + SF { key: "others-rf-power-uhf", byte: 0x029B67, kind: SK::Enum { width: 1, labels: &[(0, "Low"), (1, "Mid"), (2, "High")] } }, // Others > RF Power(UHF) + SF { key: "others-tone-burst", byte: 0x029AAE, kind: SK::Bool }, // Others > Tone Burst + SF { key: "others-heterodyne-a-band-vhf", byte: 0x029B45, kind: SK::Enum { width: 1, labels: &[(0, "Normal"), (1, "Reverse")] } }, // Others > Heterodyne (A BAND VHF) + SF { key: "others-heterodyne-a-band-uhf", byte: 0x029B46, kind: SK::Enum { width: 1, labels: &[(0, "Normal"), (1, "Reverse")] } }, // Others > Heterodyne (A BAND UHF) + SF { key: "others-heterodyne-b-band-uhf", byte: 0x029B47, kind: SK::Enum { width: 1, labels: &[(0, "Normal"), (1, "Reverse")] } }, // Others > Heterodyne (B BAND UHF) + SF { key: "scan-pause-timer-a", byte: 0x029AD7, 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")] } }, // Scan > Pause Timer (A) + SF { key: "scan-pause-timer-b", byte: 0x029AD8, 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")] } }, // Scan > Pause Timer (B) + SF { key: "scan-resume-timer-a", byte: 0x029AD9, kind: SK::Enum { width: 1, labels: &[(0, "0sec"), (1, "1sec"), (2, "2sec"), (3, "3sec"), (4, "4sec"), (5, "5sec"), (6, "Hold")] } }, // Scan > Resume Timer (A) + SF { key: "scan-resume-timer-b", byte: 0x029ADA, kind: SK::Enum { width: 1, labels: &[(0, "0sec"), (1, "1sec"), (2, "2sec"), (3, "3sec"), (4, "4sec"), (5, "5sec"), (6, "Hold")] } }, // Scan > Resume Timer (B) + SF { key: "scan-temporary-skip-timer-a", byte: 0x029B22, kind: SK::Enum { width: 1, labels: &[(0, "5min"), (1, "10min"), (2, "15min")] } }, // Scan > Temporary Skip Timer (A) + SF { key: "scan-temporary-skip-timer-b", byte: 0x029B23, kind: SK::Enum { width: 1, labels: &[(0, "5min"), (1, "10min"), (2, "15min")] } }, // Scan > Temporary Skip Timer (B) + SF { key: "scan-program-skip-scan-a", byte: 0x029ADF, kind: SK::Bool }, // Scan > Program Skip Scan (A) + SF { key: "scan-program-skip-scan-b", byte: 0x029AE0, kind: SK::Bool }, // Scan > Program Skip Scan (B) + SF { key: "gps-gps-select", byte: 0x029B04, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Internal GPS"), (2, "External GPS"), (3, "Manual")] } }, // GPS > GPS Select + SF { key: "gps-compass-direction", byte: 0x029B08, kind: SK::Enum { width: 1, labels: &[(0, "Heading Up"), (1, "North Up"), (2, "South Up")] } }, // GPS > Compass Direction + SF { key: "gps-gps-time-correct", byte: 0x029B09, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Auto")] } }, // GPS > GPS Time Correct + SF { key: "gps-gps-out-to-data-jack", byte: 0x029B06, kind: SK::Bool }, // GPS > GPS Out (To DATA Jack) + SF { key: "gps-gps-position-display-select-main-sub", byte: 0x029B4A, kind: SK::Enum { width: 1, labels: &[(0, "Latest (MAIN/SUB)"), (1, "MAIN"), (2, "SUB")] } }, // GPS > GPS Position Display Select (MAIN/SUB) + SF { key: "gps-alarm-alarm-area-rx-memory", byte: 0x029B07, kind: SK::Enum { width: 1, labels: &[(0, "Limited"), (1, "Extended"), (2, "Both")] } }, // GPS Alarm > Alarm Area (RX/Memory) + SF { key: "gps-tx-gps-tx-mode", byte: 0x029ACD, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "D-PRS"), (2, "NMEA")] } }, // GPS TX > GPS TX Mode + SF { key: "gps-tx-gps-auto-tx-timer", byte: 0x029AD4, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "5sec"), (2, "10sec"), (3, "30sec"), (4, "1min"), (5, "3min"), (6, "5min"), (7, "10min"), (8, "30min")] } }, // GPS TX > GPS Auto TX Timer + SF { key: "d-prs-tx-format", byte: 0x029DA5, kind: SK::Enum { width: 1, labels: &[(0, "Position"), (1, "Object"), (2, "Item"), (3, "Weather")] } }, // D-PRS > TX Format + SF { key: "d-prs-position-ssid", byte: 0x029DDE, 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")] } }, // D-PRS Position > SSID + SF { key: "d-prs-position-comment-ch", byte: 0x029DE8, kind: SK::Enum { width: 1, labels: &[(0, "No.1"), (1, "No.2"), (2, "No.3"), (3, "No.4")] } }, // D-PRS Position > Comment CH + SF { key: "d-prs-position-time-stamp", byte: 0x029E9A, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "DHM"), (2, "HMS")] } }, // D-PRS Position > Time Stamp + SF { key: "d-prs-position-altitude", byte: 0x029E9B, kind: SK::Bool }, // D-PRS Position > Altitude + SF { key: "d-prs-position-data-extension", byte: 0x029E95, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Course/Speed"), (2, "Power/Height/Gain/Directivity")] } }, // D-PRS Position > Data Extension + SF { key: "d-prs-position-power", byte: 0x029E96, kind: SK::Enum { width: 1, labels: &[(0, "0W"), (1, "1W"), (2, "4W"), (3, "9W"), (4, "16W"), (5, "25W"), (6, "36W"), (7, "49W"), (8, "64W"), (9, "81W")] } }, // D-PRS Position > Power + SF { key: "d-prs-position-height", byte: 0x029E97, kind: SK::Enum { width: 1, labels: &[(0, "3m"), (1, "6m"), (2, "12m"), (3, "24m"), (4, "49m"), (5, "98m"), (6, "195m"), (7, "390m"), (8, "780m"), (9, "1561m")] } }, // D-PRS Position > Height + SF { key: "d-prs-position-gain", byte: 0x029E98, kind: SK::Enum { width: 1, labels: &[(0, "0dB"), (1, "1dB"), (2, "2dB"), (3, "3dB"), (4, "4dB"), (5, "5dB"), (6, "6dB"), (7, "7dB"), (8, "8dB"), (9, "9dB")] } }, // D-PRS Position > Gain + SF { key: "d-prs-position-directivity", byte: 0x029E99, kind: SK::Enum { width: 1, labels: &[(0, "Omni"), (1, "45°NE"), (2, "90°E"), (3, "135°SE"), (4, "180°S"), (5, "225°SW"), (6, "270°W"), (7, "315°NW"), (8, "360°N")] } }, // D-PRS Position > Directivity + SF { key: "d-prs-object-data-type", byte: 0x029EA5, kind: SK::Enum { width: 1, labels: &[(0, "Live Object"), (1, "Killed Object")] } }, // D-PRS Object > Data Type + SF { key: "d-prs-object-data-extension", byte: 0x029EE3, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Course/Speed"), (2, "Power/Height/Gain/Directivity")] } }, // D-PRS Object > Data Extension + SF { key: "d-prs-object-power", byte: 0x029EE4, kind: SK::Enum { width: 1, labels: &[(0, "0W"), (1, "1W"), (2, "4W"), (3, "9W"), (4, "16W"), (5, "25W"), (6, "36W"), (7, "49W"), (8, "64W"), (9, "81W")] } }, // D-PRS Object > Power + SF { key: "d-prs-object-height", byte: 0x029EE5, kind: SK::Enum { width: 1, labels: &[(0, "3m"), (1, "6m"), (2, "12m"), (3, "24m"), (4, "49m"), (5, "98m"), (6, "195m"), (7, "390m"), (8, "780m"), (9, "1561m")] } }, // D-PRS Object > Height + SF { key: "d-prs-object-gain", byte: 0x029EE6, kind: SK::Enum { width: 1, labels: &[(0, "0dB"), (1, "1dB"), (2, "2dB"), (3, "3dB"), (4, "4dB"), (5, "5dB"), (6, "6dB"), (7, "7dB"), (8, "8dB"), (9, "9dB")] } }, // D-PRS Object > Gain + SF { key: "d-prs-object-directivity", byte: 0x029EE7, kind: SK::Enum { width: 1, labels: &[(0, "Omni"), (1, "45°NE"), (2, "90°E"), (3, "135°SE"), (4, "180°S"), (5, "225°SW"), (6, "270°W"), (7, "315°NW"), (8, "360°N")] } }, // D-PRS Object > Directivity + SF { key: "d-prs-object-ssid", byte: 0x029EE8, 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")] } }, // D-PRS Object > SSID + SF { key: "d-prs-object-time-stamp", byte: 0x029EE9, kind: SK::Enum { width: 1, labels: &[(1, "DHM"), (2, "HMS")] } }, // D-PRS Object > Time Stamp + SF { key: "d-prs-item-data-type", byte: 0x029EF3, kind: SK::Enum { width: 1, labels: &[(0, "Live Item"), (1, "Killed Item")] } }, // D-PRS Item > Data Type + SF { key: "d-prs-item-data-extension", byte: 0x029F31, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Course/Speed"), (2, "Power/Height/Gain/Directivity")] } }, // D-PRS Item > Data Extension + SF { key: "d-prs-item-power", byte: 0x029F32, kind: SK::Enum { width: 1, labels: &[(0, "0W"), (1, "1W"), (2, "4W"), (3, "9W"), (4, "16W"), (5, "25W"), (6, "36W"), (7, "49W"), (8, "64W"), (9, "81W")] } }, // D-PRS Item > Power + SF { key: "d-prs-item-height", byte: 0x029F33, kind: SK::Enum { width: 1, labels: &[(0, "3m"), (1, "6m"), (2, "12m"), (3, "24m"), (4, "49m"), (5, "98m"), (6, "195m"), (7, "390m"), (8, "780m"), (9, "1561m")] } }, // D-PRS Item > Height + SF { key: "d-prs-item-gain", byte: 0x029F34, kind: SK::Enum { width: 1, labels: &[(0, "0dB"), (1, "1dB"), (2, "2dB"), (3, "3dB"), (4, "4dB"), (5, "5dB"), (6, "6dB"), (7, "7dB"), (8, "8dB"), (9, "9dB")] } }, // D-PRS Item > Gain + SF { key: "d-prs-item-directivity", byte: 0x029F35, kind: SK::Enum { width: 1, labels: &[(0, "Omni"), (1, "45°NE"), (2, "90°E"), (3, "135°SE"), (4, "180°S"), (5, "225°SW"), (6, "270°W"), (7, "315°NW"), (8, "360°N")] } }, // D-PRS Item > Directivity + SF { key: "d-prs-item-ssid", byte: 0x029F36, 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")] } }, // D-PRS Item > SSID + SF { key: "d-prs-weather-ssid", byte: 0x029F38, 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")] } }, // D-PRS Weather > SSID + SF { key: "d-prs-weather-time-stamp", byte: 0x029F66, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "DHM"), (2, "HMS")] } }, // D-PRS Weather > Time Stamp + SF { key: "gps-logger-gps-logger", byte: 0x029B0F, kind: SK::Bool }, // GPS Logger > GPS Logger + SF { key: "gps-logger-record-interval", byte: 0x029B10, kind: SK::Enum { width: 1, labels: &[(0, "1sec"), (1, "5sec"), (2, "10sec"), (3, "30sec"), (4, "60sec")] } }, // GPS Logger > Record Interval + SF { key: "tone-control-rx-bass", byte: 0x029B11, kind: SK::Enum { width: 1, labels: &[(0, "Cut"), (1, "Normal"), (2, "Boost")] } }, // Tone Control > RX Bass + SF { key: "tone-control-rx-treble", byte: 0x029B12, kind: SK::Enum { width: 1, labels: &[(0, "Cut"), (1, "Normal"), (2, "Boost")] } }, // Tone Control > RX Treble + SF { key: "tone-control-rx-bass-boost", byte: 0x029B13, kind: SK::Bool }, // Tone Control > RX Bass Boost + SF { key: "tone-control-tx-bass", byte: 0x029B14, kind: SK::Enum { width: 1, labels: &[(0, "Cut"), (1, "Normal"), (2, "Boost")] } }, // Tone Control > TX Bass + SF { key: "tone-control-tx-treble", byte: 0x029B15, kind: SK::Enum { width: 1, labels: &[(0, "Cut"), (1, "Normal"), (2, "Boost")] } }, // Tone Control > TX Treble + SF { key: "dv-set-mode-auto-reply", byte: 0x029AC7, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "ON"), (2, "Voice"), (3, "Position (Main Only)"), (4, "Position (Main/Sub)")] } }, // DV Set Mode > Auto Reply + SF { key: "dv-set-mode-dv-data-tx", byte: 0x029AC8, kind: SK::Enum { width: 1, labels: &[(0, "PTT"), (1, "Auto")] } }, // DV Set Mode > DV Data TX + SF { key: "dv-set-mode-dv-fast-data", byte: 0x029B4B, kind: SK::Bool }, // DV Set Mode > DV Fast Data + SF { key: "dv-set-mode-dv-fast-data-gps-data-speed", byte: 0x029AC6, kind: SK::Enum { width: 1, labels: &[(0, "Slow"), (1, "Fast")] } }, // DV Set Mode > DV Fast Data GPS Data Speed + SF { key: "dv-set-mode-dv-fast-data-tx-delay-ptt", byte: 0x029B4C, 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")] } }, // DV Set Mode > DV Fast Data TX Delay (PTT) + SF { key: "dv-set-mode-digital-monitor", byte: 0x029AC9, kind: SK::Enum { width: 1, labels: &[(0, "Auto"), (1, "Digital"), (2, "Analog")] } }, // DV Set Mode > Digital Monitor + SF { key: "dv-set-mode-digital-repeater-set", byte: 0x029ACA, kind: SK::Bool }, // DV Set Mode > Digital Repeater Set + SF { key: "dv-set-mode-dv-mode-auto-detect", byte: 0x029AD5, kind: SK::Bool }, // DV Set Mode > DV Mode Auto Detect + SF { key: "dv-set-mode-rx-record-rpt", byte: 0x029AD6, kind: SK::Enum { width: 1, labels: &[(0, "ALL"), (1, "Latest Only")] } }, // DV Set Mode > RX Record(RPT) + SF { key: "display-dv-rx-call-sign-display", byte: 0x029AEA, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Normal"), (2, "RX Hold"), (3, "Hold")] } }, // Display > RX Call Sign Display + SF { key: "display-dv-rx-position-indicator", byte: 0x029AED, kind: SK::Bool }, // Display > RX Position Indicator + SF { key: "display-dv-rx-position-display", byte: 0x029AEE, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "ON (Main/Sub)"), (2, "ON (Main Only)")] } }, // Display > RX Position Display + SF { key: "display-dv-rx-position-display-timer", byte: 0x029AEF, kind: SK::Enum { width: 1, labels: &[(0, "5sec"), (1, "10sec"), (2, "15sec"), (3, "30sec"), (4, "Hold")] } }, // Display > RX Position Display Timer + SF { key: "display-dv-reply-position-display", byte: 0x029AEC, kind: SK::Bool }, // Display > Reply Position Display + SF { key: "display-dv-tx-call-sign-display", byte: 0x029AEB, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "Your Call Sign"), (2, "My Call Sign")] } }, // Display > TX Call Sign Display + SF { key: "display-dv-display-type-dr", byte: 0x029B0B, kind: SK::Enum { width: 1, labels: &[(0, "Normal"), (1, "Large")] } }, // Display > Display Type(DR) + SF { key: "display-dv-display-type-rx-history", byte: 0x029AAC, kind: SK::Enum { width: 1, labels: &[(0, "Call Sign"), (1, "Name")] } }, // Display > Display Type(RX History) + SF { key: "sounds-dv-standby-beep", byte: 0x029AFF, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "ON"), (2, "ON (to me:High Tone)"), (3, "ON (to me:Alarm/High Tone)")] } }, // Sounds > Standby Beep + SF { key: "sounds-beep-level", byte: 0x029AFC, 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")] } }, // Sounds > Beep Level (radio: 9 -> 5 -> 3 read back off the screen; factory default 9) + SF { key: "speech-speech-level", byte: 0x029B1D, 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")] } }, // SPEECH > SPEECH Level (factory default 7 between SPEECH Speed 0x29B1C and PTT Auto REC 0x29B1E) + SF { key: "digital-sounds-emr-af-level", byte: 0x029B03, 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")] } }, // Digital Sounds > EMR AF Level (factory default 0x13 = printed default 19) + SF { key: "d-prs-position-symbol-no-1", byte: 0x029DE0, kind: SK::Text { len: 2 } }, // D-PRS Position > Symbol (No.1) (factory "/>" = CS default Car) + SF { key: "d-prs-position-symbol-no-2", byte: 0x029DE2, kind: SK::Text { len: 2 } }, // D-PRS Position > Symbol (No.2) (factory "/v" = CS default Van) + SF { key: "d-prs-position-symbol-no-3", byte: 0x029DE4, kind: SK::Text { len: 2 } }, // D-PRS Position > Symbol (No.3) (factory "/k" = CS default Truck) + SF { key: "d-prs-position-symbol-no-4", byte: 0x029DE6, kind: SK::Text { len: 2 } }, // D-PRS Position > Symbol (No.4) (factory "/-" = CS default House QTH) + SF { key: "d-prs-object-symbol", byte: 0x029EA6, kind: SK::Text { len: 2 } }, // D-PRS Object > Symbol (factory "\Y"; sweep moved 0x29EA6) + SF { key: "d-prs-item-symbol", byte: 0x029EF4, kind: SK::Text { len: 2 } }, // D-PRS Item > Symbol (factory "\Y"; sweep moved 0x29EF4) + SF { key: "d-prs-weather-symbol", byte: 0x029F39, kind: SK::Text { len: 2 } }, // D-PRS Weather > Symbol (factory "/_" = weather station; sweep moved 0x29F3A) + SF { key: "d-prs-unproto-address", byte: 0x029DA6, kind: SK::Text { len: 56 } }, // D-PRS > Unproto Address (typed 20 chars at 0x29DA6; space-padded to SSID byte 0x29DDE; manual: up to 56) + SF { key: "d-prs-position-comment-no-1", byte: 0x029DE9, kind: SK::Text { len: 43 } }, // D-PRS Position > Comment (No.1) (typed 48, kept 43 from 0x29DE9) + SF { key: "d-prs-position-comment-no-2", byte: 0x029E14, kind: SK::Text { len: 43 } }, // D-PRS Position > Comment (No.2) (typed 48, kept 43; stride 43) + SF { key: "d-prs-position-comment-no-3", byte: 0x029E3F, kind: SK::Text { len: 43 } }, // D-PRS Position > Comment (No.3) (typed 48, kept 43; stride 43) + SF { key: "d-prs-position-comment-no-4", byte: 0x029E6A, kind: SK::Text { len: 43 } }, // D-PRS Position > Comment (No.4) (typed 48, kept 43; stride 43) + SF { key: "d-prs-object-comment", byte: 0x029EA8, kind: SK::Text { len: 43 } }, // D-PRS Object > Comment (typed 48, kept 43) + SF { key: "d-prs-item-comment", byte: 0x029EF6, kind: SK::Text { len: 43 } }, // D-PRS Item > Comment (typed 48, kept 43) + SF { key: "d-prs-weather-comment", byte: 0x029F3B, kind: SK::Text { len: 43 } }, // D-PRS Weather > Comment (typed 48, kept 43) + SF { key: "gps-tx-nmea-gps-message", byte: 0x029D91, kind: SK::Text { len: 20 } }, // GPS TX (NMEA) > GPS Message (typed 48, kept 20; manual: up to 20) + SF { key: "my-station-my-call-sign-m01", byte: 0x02215C, kind: SK::Text { len: 8 } }, // My Station > My Call Sign M01 (typed 10 chars into M01 (kept 8 at 0x2215C), M02 at +12; record = call 8 + note 4) + SF { key: "my-station-my-call-sign-m01-note", byte: 0x022164, kind: SK::Text { len: 4 } }, // My Station > My Call Sign M01 note (typed 6 chars into M01 note, kept 4 at 0x22164) + SF { key: "my-station-my-call-sign-m02", byte: 0x022168, kind: SK::Text { len: 8 } }, // My Station > My Call Sign M02 (typed 10 chars into M01 (kept 8 at 0x2215C), M02 at +12; record = call 8 + note 4) + SF { key: "my-station-my-call-sign-m02-note", byte: 0x022170, kind: SK::Text { len: 4 } }, // My Station > My Call Sign M02 note (typed 6 chars into M01 note, kept 4 at 0x22164) + SF { key: "my-station-my-call-sign-m03", byte: 0x022174, kind: SK::Text { len: 8 } }, // My Station > My Call Sign M03 (typed 10 chars into M01 (kept 8 at 0x2215C), M02 at +12; record = call 8 + note 4) + SF { key: "my-station-my-call-sign-m03-note", byte: 0x02217C, kind: SK::Text { len: 4 } }, // My Station > My Call Sign M03 note (typed 6 chars into M01 note, kept 4 at 0x22164) + SF { key: "my-station-my-call-sign-m04", byte: 0x022180, kind: SK::Text { len: 8 } }, // My Station > My Call Sign M04 (typed 10 chars into M01 (kept 8 at 0x2215C), M02 at +12; record = call 8 + note 4) + SF { key: "my-station-my-call-sign-m04-note", byte: 0x022188, kind: SK::Text { len: 4 } }, // My Station > My Call Sign M04 note (typed 6 chars into M01 note, kept 4 at 0x22164) + SF { key: "my-station-my-call-sign-m05", byte: 0x02218C, kind: SK::Text { len: 8 } }, // My Station > My Call Sign M05 (typed 10 chars into M01 (kept 8 at 0x2215C), M02 at +12; record = call 8 + note 4) + SF { key: "my-station-my-call-sign-m05-note", byte: 0x022194, kind: SK::Text { len: 4 } }, // My Station > My Call Sign M05 note (typed 6 chars into M01 note, kept 4 at 0x22164) + SF { key: "my-station-my-call-sign-m06", byte: 0x022198, kind: SK::Text { len: 8 } }, // My Station > My Call Sign M06 (typed 10 chars into M01 (kept 8 at 0x2215C), M02 at +12; record = call 8 + note 4) + SF { key: "my-station-my-call-sign-m06-note", byte: 0x0221A0, kind: SK::Text { len: 4 } }, // My Station > My Call Sign M06 note (typed 6 chars into M01 note, kept 4 at 0x22164) + SF { key: "my-station-tx-message-1", byte: 0x025E62, kind: SK::Text { len: 20 } }, // My Station > TX Message 1 (typed 28 chars into No.1, kept 20 at 0x25E62; No.2 at +20) + SF { key: "my-station-tx-message-2", byte: 0x025E76, kind: SK::Text { len: 20 } }, // My Station > TX Message 2 (typed 28 chars into No.1, kept 20 at 0x25E62; No.2 at +20) + SF { key: "my-station-tx-message-3", byte: 0x025E8A, kind: SK::Text { len: 20 } }, // My Station > TX Message 3 (typed 28 chars into No.1, kept 20 at 0x25E62; No.2 at +20) + SF { key: "my-station-tx-message-4", byte: 0x025E9E, kind: SK::Text { len: 20 } }, // My Station > TX Message 4 (typed 28 chars into No.1, kept 20 at 0x25E62; No.2 at +20) + SF { key: "my-station-tx-message-5", byte: 0x025EB2, kind: SK::Text { len: 20 } }, // My Station > TX Message 5 (typed 28 chars into No.1, kept 20 at 0x25E62; No.2 at +20) + SF { key: "d-prs-object-object-name", byte: 0x029E9C, kind: SK::TextFill { len: 9, fill: 0xFF } }, // D-PRS Object > Object Name (FF-PAD typed 13 kept 9; typed "AB" left 41 42 FF..) + SF { key: "d-prs-item-item-name", byte: 0x029EEA, kind: SK::TextFill { len: 9, fill: 0xFF } }, // D-PRS Item > Item Name (FF-PAD typed 13 kept 9; typed "CD" left 43 44 FF..) + SF { key: "set-mode-auto-repeater", byte: 0x029AA1, kind: SK::Enum { width: 1, labels: &[(0, "OFF"), (1, "ON (DUP)"), (2, "ON (DUP,TONE)")] } }, // Set Mode > Auto Repeater (radio: front panel ON(DUP)->OFF->ON(DUP,TONE) read 01->00->02; not in CS-5100) + SF { key: "dtmf-dtmf-speed", byte: 0x029ADB, kind: SK::Enum { width: 1, labels: &[(0, "100ms"), (1, "200ms"), (2, "300ms"), (3, "500ms")] } }, // DTMF > DTMF Speed (cycled 0->1->2->3->0 on the DTMF Memory page; labels read off each step) + SF { key: "my-station-my-call-sign-in-use", byte: 0x029B65, kind: SK::Enum { width: 1, labels: &[(0, "M01"), (1, "M02"), (2, "M03"), (3, "M04"), (4, "M05"), (5, "M06")] } }, // My Station > My Call Sign in use (Select moved M01->M02: 0x29B65 00->01) + SF { key: "my-station-tx-message-in-use", byte: 0x025EC6, kind: SK::Enum { width: 1, labels: &[(1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5")] } }, // My Station > TX Message in use (default S on No.1 = 01; Select moved to No.3: 0x25EC6 01->03) + SF { key: "ci-v-ci-v-address", byte: 0x029AB1, 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")] } }, // CI-V > CI-V Address (typed 8D: 0x29AB1 8C->8D; manual 01h-DFh, default 8Ch) + SF { key: "ci-v-ci-v-bluetooth-remote-transceive-address", byte: 0x029AC5, kind: SK::Enum { width: 1, labels: &[(0, "00"), (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")] } }, // CI-V > CI-V Bluetooth -> REMOTE Transceive Address (typed A5: 0x29AC5 00->A5; manual 00h-DFh) + SF { key: "time-set-utc-offset", byte: 0x029D83, 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")] } }, // Time Set > UTC Offset (typed -6:00: 0x29D83-84 00 00 -> FE 98 = -360 min; manual -14:00..+14:00 in 0:05) + SF { key: "my-station-tx-message", byte: 0x025EC7, kind: SK::Bool }, // My Station > TX Message ON/OFF (OFF->ON: 0x25EC7 00->01) + SF { key: "others-repeater-mode-hang-up-time", byte: 0x029B77, kind: SK::BoolBit { shift: 5 } }, // Others > Repeater Mode > Hang Up Time (cycled 9F OFF / BF ON) + SF { key: "gps-tx-nmea-sentence-rmc", byte: 0x029ACE, kind: SK::Bool }, // GPS TX (NMEA) > GPS Sentence > RMC (toggled alone: one byte) + SF { key: "gps-tx-nmea-sentence-gga", byte: 0x029ACF, kind: SK::Bool }, // GPS TX (NMEA) > GPS Sentence > GGA (toggled alone: one byte) + SF { key: "gps-tx-nmea-sentence-gll", byte: 0x029AD0, kind: SK::Bool }, // GPS TX (NMEA) > GPS Sentence > GLL (toggled alone: one byte) + SF { key: "gps-tx-nmea-sentence-gsa", byte: 0x029AD1, kind: SK::Bool }, // GPS TX (NMEA) > GPS Sentence > GSA (toggled alone: one byte) + SF { key: "gps-tx-nmea-sentence-vtg", byte: 0x029AD2, kind: SK::Bool }, // GPS TX (NMEA) > GPS Sentence > VTG (toggled alone: one byte) + SF { key: "gps-tx-nmea-sentence-gsv", byte: 0x029AD3, kind: SK::Bool }, // GPS TX (NMEA) > GPS Sentence > GSV (toggled alone: one byte) + SF { key: "gps-logger-record-sentence-rmc", byte: 0x029AAD, kind: SK::BoolBit { shift: 7 } }, // GPS Logger > Record Sentence > RMC (bit 7; F0 -> 70 -> 30 -> 10 -> 00 unticking in order) + SF { key: "gps-logger-record-sentence-gga", byte: 0x029AAD, kind: SK::BoolBit { shift: 6 } }, // GPS Logger > Record Sentence > GGA (bit 6; F0 -> 70 -> 30 -> 10 -> 00 unticking in order) + SF { key: "gps-logger-record-sentence-vtg", byte: 0x029AAD, kind: SK::BoolBit { shift: 5 } }, // GPS Logger > Record Sentence > VTG (bit 5; F0 -> 70 -> 30 -> 10 -> 00 unticking in order) + SF { key: "gps-logger-record-sentence-gsa", byte: 0x029AAD, kind: SK::BoolBit { shift: 4 } }, // GPS Logger > Record Sentence > GSA (bit 4; F0 -> 70 -> 30 -> 10 -> 00 unticking in order) + SF { key: "scan-bank-link-a-a", byte: 0x02A290, kind: SK::BoolBit { shift: 0 } }, // Scan > Bank Link (A) > A + SF { key: "scan-bank-link-a-b", byte: 0x02A290, kind: SK::BoolBit { shift: 1 } }, // Scan > Bank Link (A) > B + SF { key: "scan-bank-link-a-c", byte: 0x02A290, kind: SK::BoolBit { shift: 2 } }, // Scan > Bank Link (A) > C + SF { key: "scan-bank-link-a-d", byte: 0x02A290, kind: SK::BoolBit { shift: 3 } }, // Scan > Bank Link (A) > D + SF { key: "scan-bank-link-a-e", byte: 0x02A290, kind: SK::BoolBit { shift: 4 } }, // Scan > Bank Link (A) > E + SF { key: "scan-bank-link-a-f", byte: 0x02A290, kind: SK::BoolBit { shift: 5 } }, // Scan > Bank Link (A) > F + SF { key: "scan-bank-link-a-g", byte: 0x02A290, kind: SK::BoolBit { shift: 6 } }, // Scan > Bank Link (A) > G + SF { key: "scan-bank-link-a-h", byte: 0x02A290, kind: SK::BoolBit { shift: 7 } }, // Scan > Bank Link (A) > H + SF { key: "scan-bank-link-a-i", byte: 0x02A291, kind: SK::BoolBit { shift: 0 } }, // Scan > Bank Link (A) > I + SF { key: "scan-bank-link-a-j", byte: 0x02A291, kind: SK::BoolBit { shift: 1 } }, // Scan > Bank Link (A) > J + SF { key: "scan-bank-link-a-k", byte: 0x02A291, kind: SK::BoolBit { shift: 2 } }, // Scan > Bank Link (A) > K + SF { key: "scan-bank-link-a-l", byte: 0x02A291, kind: SK::BoolBit { shift: 3 } }, // Scan > Bank Link (A) > L + SF { key: "scan-bank-link-a-m", byte: 0x02A291, kind: SK::BoolBit { shift: 4 } }, // Scan > Bank Link (A) > M + SF { key: "scan-bank-link-a-n", byte: 0x02A291, kind: SK::BoolBit { shift: 5 } }, // Scan > Bank Link (A) > N + SF { key: "scan-bank-link-a-o", byte: 0x02A291, kind: SK::BoolBit { shift: 6 } }, // Scan > Bank Link (A) > O + SF { key: "scan-bank-link-a-p", byte: 0x02A291, kind: SK::BoolBit { shift: 7 } }, // Scan > Bank Link (A) > P + SF { key: "scan-bank-link-a-q", byte: 0x02A292, kind: SK::BoolBit { shift: 0 } }, // Scan > Bank Link (A) > Q + SF { key: "scan-bank-link-a-r", byte: 0x02A292, kind: SK::BoolBit { shift: 1 } }, // Scan > Bank Link (A) > R + SF { key: "scan-bank-link-a-s", byte: 0x02A292, kind: SK::BoolBit { shift: 2 } }, // Scan > Bank Link (A) > S + SF { key: "scan-bank-link-a-t", byte: 0x02A292, kind: SK::BoolBit { shift: 3 } }, // Scan > Bank Link (A) > T + SF { key: "scan-bank-link-a-u", byte: 0x02A292, kind: SK::BoolBit { shift: 4 } }, // Scan > Bank Link (A) > U + SF { key: "scan-bank-link-a-v", byte: 0x02A292, kind: SK::BoolBit { shift: 5 } }, // Scan > Bank Link (A) > V + SF { key: "scan-bank-link-a-w", byte: 0x02A292, kind: SK::BoolBit { shift: 6 } }, // Scan > Bank Link (A) > W + SF { key: "scan-bank-link-a-x", byte: 0x02A292, kind: SK::BoolBit { shift: 7 } }, // Scan > Bank Link (A) > X + SF { key: "scan-bank-link-a-y", byte: 0x02A293, kind: SK::BoolBit { shift: 0 } }, // Scan > Bank Link (A) > Y + SF { key: "scan-bank-link-a-z", byte: 0x02A293, kind: SK::BoolBit { shift: 1 } }, // Scan > Bank Link (A) > Z + SF { key: "scan-bank-link-b-a", byte: 0x02A294, kind: SK::BoolBit { shift: 0 } }, // Scan > Bank Link (B) > A + SF { key: "scan-bank-link-b-b", byte: 0x02A294, kind: SK::BoolBit { shift: 1 } }, // Scan > Bank Link (B) > B + SF { key: "scan-bank-link-b-c", byte: 0x02A294, kind: SK::BoolBit { shift: 2 } }, // Scan > Bank Link (B) > C + SF { key: "scan-bank-link-b-d", byte: 0x02A294, kind: SK::BoolBit { shift: 3 } }, // Scan > Bank Link (B) > D + SF { key: "scan-bank-link-b-e", byte: 0x02A294, kind: SK::BoolBit { shift: 4 } }, // Scan > Bank Link (B) > E + SF { key: "scan-bank-link-b-f", byte: 0x02A294, kind: SK::BoolBit { shift: 5 } }, // Scan > Bank Link (B) > F + SF { key: "scan-bank-link-b-g", byte: 0x02A294, kind: SK::BoolBit { shift: 6 } }, // Scan > Bank Link (B) > G + SF { key: "scan-bank-link-b-h", byte: 0x02A294, kind: SK::BoolBit { shift: 7 } }, // Scan > Bank Link (B) > H + SF { key: "scan-bank-link-b-i", byte: 0x02A295, kind: SK::BoolBit { shift: 0 } }, // Scan > Bank Link (B) > I + SF { key: "scan-bank-link-b-j", byte: 0x02A295, kind: SK::BoolBit { shift: 1 } }, // Scan > Bank Link (B) > J + SF { key: "scan-bank-link-b-k", byte: 0x02A295, kind: SK::BoolBit { shift: 2 } }, // Scan > Bank Link (B) > K + SF { key: "scan-bank-link-b-l", byte: 0x02A295, kind: SK::BoolBit { shift: 3 } }, // Scan > Bank Link (B) > L + SF { key: "scan-bank-link-b-m", byte: 0x02A295, kind: SK::BoolBit { shift: 4 } }, // Scan > Bank Link (B) > M + SF { key: "scan-bank-link-b-n", byte: 0x02A295, kind: SK::BoolBit { shift: 5 } }, // Scan > Bank Link (B) > N + SF { key: "scan-bank-link-b-o", byte: 0x02A295, kind: SK::BoolBit { shift: 6 } }, // Scan > Bank Link (B) > O + SF { key: "scan-bank-link-b-p", byte: 0x02A295, kind: SK::BoolBit { shift: 7 } }, // Scan > Bank Link (B) > P + SF { key: "scan-bank-link-b-q", byte: 0x02A296, kind: SK::BoolBit { shift: 0 } }, // Scan > Bank Link (B) > Q + SF { key: "scan-bank-link-b-r", byte: 0x02A296, kind: SK::BoolBit { shift: 1 } }, // Scan > Bank Link (B) > R + SF { key: "scan-bank-link-b-s", byte: 0x02A296, kind: SK::BoolBit { shift: 2 } }, // Scan > Bank Link (B) > S + SF { key: "scan-bank-link-b-t", byte: 0x02A296, kind: SK::BoolBit { shift: 3 } }, // Scan > Bank Link (B) > T + SF { key: "scan-bank-link-b-u", byte: 0x02A296, kind: SK::BoolBit { shift: 4 } }, // Scan > Bank Link (B) > U + SF { key: "scan-bank-link-b-v", byte: 0x02A296, kind: SK::BoolBit { shift: 5 } }, // Scan > Bank Link (B) > V + SF { key: "scan-bank-link-b-w", byte: 0x02A296, kind: SK::BoolBit { shift: 6 } }, // Scan > Bank Link (B) > W + SF { key: "scan-bank-link-b-x", byte: 0x02A296, kind: SK::BoolBit { shift: 7 } }, // Scan > Bank Link (B) > X + SF { key: "scan-bank-link-b-y", byte: 0x02A297, kind: SK::BoolBit { shift: 0 } }, // Scan > Bank Link (B) > Y + SF { key: "scan-bank-link-b-z", byte: 0x02A297, kind: SK::BoolBit { shift: 1 } }, // Scan > Bank Link (B) > Z + SF { key: "display-backlight-night-time-setting-night-time-start-hour", byte: 0x029B58, kind: SK::Uint { width: 1 } }, // Display - Backlight Night Time Setting > Night Time Start (hour) (0-23; typed 21:15 -> 15 0F) + SF { key: "display-backlight-night-time-setting-night-time-start-minute", byte: 0x029B59, kind: SK::Uint { width: 1 } }, // Display - Backlight Night Time Setting > Night Time Start (minute) (0-59; typed 21:15 -> 15 0F) + SF { key: "display-backlight-night-time-setting-night-time-end-hour", byte: 0x029B5A, kind: SK::Uint { width: 1 } }, // Display - Backlight Night Time Setting > Night Time End (hour) (0-23; typed 07:45 -> 07 2D) + SF { key: "display-backlight-night-time-setting-night-time-end-minute", byte: 0x029B5B, kind: SK::Uint { width: 1 } }, // Display - Backlight Night Time Setting > Night Time End (minute) (0-59; typed 07:45 -> 07 2D) + SF { key: "d-prs-object-course-degrees", byte: 0x029EDF, kind: SK::Uint { width: 2 } }, // D-PRS Object > Course (degrees) (0-360; typed 123 -> 00 7B, 300 -> 01 2C) + SF { key: "d-prs-object-speed-tenths-of-a-knot", byte: 0x029EE1, kind: SK::Uint { width: 2 } }, // D-PRS Object > Speed (tenths of a knot) (0-9999; typed 45 kt -> 01 C2 = 450; shown 83 km/h after Speed Units -> km/h, bytes unchanged) + SF { key: "d-prs-item-course-degrees", byte: 0x029F2D, kind: SK::Uint { width: 2 } }, // D-PRS Item > Course (degrees) (0-360; typed 234 -> 00 EA (record layout = Object, measured)) + SF { key: "d-prs-item-speed-tenths-of-a-knot", byte: 0x029F2F, kind: SK::Uint { width: 2 } }, // D-PRS Item > Speed (tenths of a knot) (0-9999; typed 67 kt -> 02 9E = 670) +]; diff --git a/src-tauri/src/radios/icom_id5100/layout.rs b/src-tauri/src/radios/icom_id5100/layout.rs new file mode 100644 index 0000000..216ba44 --- /dev/null +++ b/src-tauri/src/radios/icom_id5100/layout.rs @@ -0,0 +1,94 @@ +//! Where things are in an ID-5100 clone image. +//! +//! Offsets come from CHIRP's `id5100.py` (which inherits its ID-4100 layout) +//! and were **checked against this radio's own clone reads** before a line of +//! the encoder existed — `scratchpad/id5100/FINDINGS.md`. 41 real memories in +//! two named banks decoded with every field plausible, three of them were read +//! back off the radio's screen, and a front-panel-programmed probe landed in the +//! slot, bitmap bit and fields predicted. Two things CHIRP does not know were +//! found on the way and are documented where they are used: the bank byte's +//! upper bits, and the record byte at `+0x09`. +//! +//! ## One image layout, by firmware +//! +//! The ID-5100 changed its memory layout twice with firmware (CHIRP's MapRev +//! 1/2/3, and the radio's own `SD Card > Save Form` offers "Now Ver", "Old Ver +//! (1.10)" and "Old Ver (1.00-1.05)"). This driver knows **MapRev 3 only** — the +//! current firmware, measured three ways on a radio running CPU 1.21: the ID +//! reply's revision byte is `3`, the clone ends with `Icom Inc.8E`, and the +//! image is exactly [`IMAGE_LEN`] bytes. An older radio is refused at identify +//! with a message saying so, rather than being written at the wrong offsets. +//! +//! ## Map +//! +//! | offset | shape | what | +//! |---|---|---| +//! | `0x0000` | 1004 x 49 | records: 1000 memories, then 4 call channels | +//! | `0xC040` | 125 bytes | "empty" bitmap, LSB-first — bit SET = slot empty | +//! | `0xC0BE` | 125 bytes | skip bitmap | +//! | `0xC13B` | 125 bytes | P-skip bitmap | +//! | `0xC1C0` | 1000 x 2 | `[bank byte][index in bank]` per memory | +//! | `0xC9C0` | 16 chars | an ICF comment field (spaces on this radio) | +//! | `0xC9D0` | 26 x 16 | bank names A-Z, space padded | +//! | `0x23E00+` | | RX history and other state the radio rewrites continuously | + +/// The whole clone image, MapRev 3. +pub(crate) const IMAGE_LEN: usize = 0x2A380; +/// MapRev this driver was measured against. +pub(crate) const MAP_REV: u8 = 3; +/// Model code in the clone ID reply and the clone frames. +pub(crate) const MODEL: [u8; 4] = [0x34, 0x84, 0x00, 0x01]; +/// What the radio sends in CLONE_END for a MapRev 3 image, and what a write has +/// to finish with. CHIRP's table, and what the radio sent on every read here. +pub(crate) const ENDFRAME: &[u8] = b"Icom Inc.8E"; + +pub(crate) const REC_LEN: usize = 49; +/// Programmable memories 0-999. +pub(crate) const CHANNEL_COUNT: usize = 1000; +/// Memories plus the four call channels (144-C0/C1, 430-C0/C1) at 1000-1003. +pub(crate) const RECORD_COUNT: usize = 1004; + +pub(crate) const EMPTY_BITMAP: usize = 0xC040; +pub(crate) const SKIP_BITMAP: usize = 0xC0BE; +pub(crate) const PSKIP_BITMAP: usize = 0xC13B; +pub(crate) const BITMAP_LEN: usize = 125; + +pub(crate) const BANK_TABLE: usize = 0xC1C0; +pub(crate) const BANK_NAMES: usize = 0xC9D0; +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". +pub(crate) const NO_BANK: u8 = 0x1F; + +pub(crate) const NAME_LEN: usize = 16; + +/// What the receiver covers, in MHz — the manual's USA specification, which +/// CHIRP's `valid_bands` repeats. The seed row's `rx_bands` must say the same +/// (`program::tests` holds them together). +/// +/// ★★ This is a SAFETY limit, not a filter. Measured 2026-10-01: a clone +/// carrying memories at and just past these edges (117.975, 174.005, 374.995, +/// 550.005 among them) was REFUSED WHOLE by the radio — it showed an error and +/// came back FACTORY RESET. One bad memory does not get skipped; it costs the +/// operator every memory and setting they had. So the codeplug build refuses +/// anything outside this, whatever reached it. +pub(crate) const RX_COVERAGE: [(f64, f64); 2] = [(118.0, 174.0), (375.0, 550.0)]; + +/// Everything above this is state the radio rewrites on its own between two +/// reads with nothing touched — 282 bytes of it in one ten-minute noise floor, +/// all RX history and last-heard data. None of it is this driver's. +pub(crate) const VOLATILE_BASE: usize = 0x23E00; + +pub(crate) const fn record_offset(slot: usize) -> usize { + slot * REC_LEN +} + +// The tables have to sit where CHIRP puts them and the reads showed them, with +// the record array ending before the first bitmap. A wrong REC_LEN or count +// would overrun into it. +const _: () = assert!(RECORD_COUNT * REC_LEN <= EMPTY_BITMAP); +const _: () = assert!(SKIP_BITMAP + BITMAP_LEN == PSKIP_BITMAP); +const _: () = assert!(BANK_TABLE + CHANNEL_COUNT * 2 <= 0xC9C0); +const _: () = assert!(BANK_NAMES + BANK_COUNT * BANK_NAME_LEN <= VOLATILE_BASE); diff --git a/src-tauri/src/radios/icom_id5100/memory.rs b/src-tauri/src/radios/icom_id5100/memory.rs new file mode 100644 index 0000000..c2e55cf --- /dev/null +++ b/src-tauri/src/radios/icom_id5100/memory.rs @@ -0,0 +1,677 @@ +//! Memory records, flag bitmaps and banks inside an ID-5100 clone image. +//! +//! ## The record, 49 bytes +//! +//! | offset | bits | field | +//! |---|---|---| +//! | `+0x00` | 3·3·18 | `mult1`, `mult2`, frequency in `mult` units (u24 BE) | +//! | `+0x03` | 16 | offset in `mult1` units (u16 BE) | +//! | `+0x05` | 6·6·1·3 | repeater tone, TSQL tone, unknown, mode (u16 BE) | +//! | `+0x07` | 8 | DTCS code index | +//! | `+0x08` | 4·4 | tuning step, unknown | +//! | `+0x09` | 8 | unknown — see [`BYTE9_DEFAULT`] | +//! | `+0x0A` | 4·2·2 | squelch type, duplex, DTCS polarity | +//! | `+0x0B` | 16 chars | name, space padded | +//! | `+0x1B` | 8 | DV code | +//! | `+0x1C` | 3 x 7 | UR, RPT1, RPT2 — 8 chars at 7 bits each | +//! +//! Measured against the radio's own reads, `scratchpad/id5100/FINDINGS.md`: +//! all three multipliers (5 kHz, 6.25 kHz D-STAR repeaters, 8.33 kHz airband), +//! both duplex directions, TONE / TSQL / DTCS, FM / FM-N / AM / DV, the packed +//! call signs, and both skip flags. Every field value this module *writes* for +//! a new channel is one the radio itself stored somewhere in those reads — with +//! two exceptions named where they are made (DTCS polarity order, and the cross +//! squelch types, which are degraded rather than guessed). +//! +//! ## Frequency multiplier +//! +//! `mult` is 0 = 5 kHz, 1 = 6.25 kHz, 2 = 8.33 kHz (25/3 kHz), and the same +//! value is stored three times: `mult1`, `mult2`, and bits 5-6 of the memory's +//! bank byte. The radio chose `mult 0` for every 5 kHz-divisible +//! frequency it was given, including ones also divisible by 6.25 kHz (146.625, +//! 447.275), and `mult 2` for an airband memory on 118.400 even though that is a +//! 5 kHz multiple too. [`multiplier_for`] reproduces both choices. + +use crate::commands::export::ExpandedChannel; +use crate::radios::icom_id52::memory::{ + ascii_field, dtcs_index, hz, pack_call, tone_index, DTCS_CODES, TONES_DHZ, +}; +use crate::radios::icom_id52::memory_csv::{ + call_signs, duplex_and_offset, mode_of, tone_columns, tune_step, +}; + +use super::layout::{ + record_offset, BANK_CAPACITY, BANK_COUNT, BANK_NAMES, BANK_NAME_LEN, BANK_TABLE, BITMAP_LEN, + CHANNEL_COUNT, EMPTY_BITMAP, NAME_LEN, NO_BANK, PSKIP_BITMAP, REC_LEN, SKIP_BITMAP, +}; + +/// Frequency multipliers in Hz, indexed by the stored `mult` value. +const MULT_HZ: [f64; 3] = [5_000.0, 6_250.0, 25_000.0 / 3.0]; + +/// Squelch type (`+0x0A` high nibble). Only the values measured on this radio: +/// OFF, TONE and TSQL from its own memories, DTCS from the front-panel probe. +/// CHIRP lists more (reverse and cross types, 6-11); none of them has been +/// stored by this radio in front of us, so none of them is written. +const SQL_OFF: u8 = 0; +const SQL_TONE: u8 = 1; +const SQL_TSQL: u8 = 3; +const SQL_DTCS: u8 = 5; + +/// Mode (`+0x05` low 3 bits). FM, AM and DV from the radio's own memories, FM-N +/// from the probe flipped FM → FM-N on the front panel (`80 → 81`). +const MODE_FM: u8 = 0; +const MODE_FM_N: u8 = 1; +const MODE_AM: u8 = 3; +const MODE_DV: u8 = 5; + +/// Tuning step (`+0x08` high nibble). `0` is what the radio stored for every +/// 2 m / 70 cm memory it had, including the 6.25 kHz ones. `14` is what it +/// stored on its airband memory — CHIRP does not know that value (its table +/// fills 11-14 with placeholders), and the radio's tuning-step window would not +/// open on a memory to say which of 8.33k / 25k / Auto it is. Reproduced +/// because the radio chose it for exactly this kind of channel, not because its +/// meaning is known. +const STEP_DEFAULT: u8 = 0; +const STEP_AIRBAND: u8 = 14; + +/// Byte `+0x09`. Unknown. `0xE4` in 41 of the radio's 42 used memories, in its +/// empty-slot default record and in both call channels; `0x00` only in the +/// memories stored from the front panel during the probe session. CHIRP calls it +/// `unknown3` and writes zero. `0xE4` is written because it is what 41 memories +/// this radio demonstrably works with carry — a decoded record keeps its own +/// value regardless. +pub(crate) const BYTE9_DEFAULT: u8 = 0xE4; + +/// One memory record, field by field and **lossless**: `encode(decode(b)) == b` +/// for any 49 bytes, including fields nobody understands. That property is the +/// Phase 2 gate, and it is also what lets a record the operator made on the +/// radio pass through a re-program untouched. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Record { + pub mult1: u8, + pub mult2: u8, + pub freq_units: u32, + pub offset_units: u16, + pub rtone: u8, + pub ctone: u8, + pub unknown1: u8, + pub mode: u8, + pub dtcs: u8, + pub step: u8, + pub unknown2: u8, + pub byte9: u8, + pub squelch: u8, + pub duplex: u8, + pub polarity: u8, + pub name: [u8; NAME_LEN], + pub dv_code: u8, + pub ur: [u8; 7], + pub rpt1: [u8; 7], + pub rpt2: [u8; 7], +} + +impl Record { + pub(crate) fn decode(b: &[u8]) -> Record { + assert_eq!(b.len(), REC_LEN, "an ID-5100 record is {REC_LEN} bytes"); + let u24 = u32::from_be_bytes([0, b[0], b[1], b[2]]); + let tones = u16::from_be_bytes([b[5], b[6]]); + let arr7 = |at: usize| -> [u8; 7] { b[at..at + 7].try_into().expect("7 bytes") }; + Record { + mult1: (u24 >> 21) as u8 & 0x07, + mult2: (u24 >> 18) as u8 & 0x07, + freq_units: u24 & 0x3_FFFF, + offset_units: u16::from_be_bytes([b[3], b[4]]), + rtone: (tones >> 10) as u8 & 0x3F, + ctone: (tones >> 4) as u8 & 0x3F, + unknown1: (tones >> 3) as u8 & 0x01, + mode: tones as u8 & 0x07, + dtcs: b[7], + step: b[8] >> 4, + unknown2: b[8] & 0x0F, + byte9: b[9], + squelch: b[10] >> 4, + duplex: (b[10] >> 2) & 0x03, + polarity: b[10] & 0x03, + name: b[11..27].try_into().expect("16 bytes"), + dv_code: b[27], + ur: arr7(28), + rpt1: arr7(35), + rpt2: arr7(42), + } + } + + pub(crate) fn encode(&self) -> [u8; REC_LEN] { + let mut b = [0u8; REC_LEN]; + let u24 = (u32::from(self.mult1 & 7) << 21) + | (u32::from(self.mult2 & 7) << 18) + | (self.freq_units & 0x3_FFFF); + b[0..3].copy_from_slice(&u24.to_be_bytes()[1..]); + b[3..5].copy_from_slice(&self.offset_units.to_be_bytes()); + let tones = (u16::from(self.rtone & 0x3F) << 10) + | (u16::from(self.ctone & 0x3F) << 4) + | (u16::from(self.unknown1 & 1) << 3) + | u16::from(self.mode & 7); + b[5..7].copy_from_slice(&tones.to_be_bytes()); + b[7] = self.dtcs; + b[8] = (self.step << 4) | (self.unknown2 & 0x0F); + b[9] = self.byte9; + b[10] = (self.squelch << 4) | ((self.duplex & 3) << 2) | (self.polarity & 3); + b[11..27].copy_from_slice(&self.name); + b[27] = self.dv_code; + b[28..35].copy_from_slice(&self.ur); + b[35..42].copy_from_slice(&self.rpt1); + b[42..49].copy_from_slice(&self.rpt2); + b + } + + pub(crate) fn rx_hz(&self) -> f64 { + f64::from(self.freq_units) * MULT_HZ.get(usize::from(self.mult1)).copied().unwrap_or(5_000.0) + } + + pub(crate) fn offset_hz(&self) -> f64 { + f64::from(self.offset_units) + * MULT_HZ.get(usize::from(self.mult1)).copied().unwrap_or(5_000.0) + } + + pub(crate) fn name(&self) -> String { + String::from_utf8_lossy(&self.name).trim_end().to_string() + } +} + +/// Which multiplier a frequency is stored in, and how many of them. `None` when +/// the frequency is on none of the radio's three grids. +/// +/// Order matters, and follows what the radio itself chose (module header): +/// airband prefers 8.33 kHz, everything else prefers 5 kHz, then 6.25 kHz. +fn multiplier_for(freq_hz: u32, airband: bool) -> Option<(u8, u32)> { + let order: &[u8] = if airband { &[2, 0, 1] } else { &[0, 1, 2] }; + order.iter().find_map(|&m| { + let step = MULT_HZ[usize::from(m)]; + let units = (f64::from(freq_hz) / step).round(); + // Within 2 Hz: an 8.33 kHz channel arrives as a decimal MHz value that + // cannot be exact (118.008333…), and must still land on its grid point. + ((units * step - f64::from(freq_hz)).abs() < 2.0 && units < f64::from(1u32 << 18)) + .then_some((m, units as u32)) + }) +} + +/// Build a record for an app channel. +/// +/// Errors rather than approximates when the frequency or offset is on none of +/// the radio's grids: a memory written at a frequency the operator did not ask +/// for is worse than a codeplug that refuses to build and says why. +pub(crate) fn encode_channel(ec: &ExpandedChannel, name: &str) -> Result { + let c = &ec.channel; + let rx = hz(c.rx_freq); + let airband = tune_step(ec) == "8.33kHz"; + let (mult, freq_units) = multiplier_for(rx, airband).ok_or_else(|| { + format!("{:.5} MHz is not on the radio's 5, 6.25 or 8.33 kHz channel grid", c.rx_freq) + })?; + + let (dup, offset_mhz) = duplex_and_offset(ec); + let step = MULT_HZ[usize::from(mult)]; + let offset_hz = f64::from(hz(offset_mhz)); + let offset_units = (offset_hz / step).round(); + if (offset_units * step - offset_hz).abs() >= 2.0 || offset_units > f64::from(u16::MAX) { + return Err(format!( + "a {offset_mhz:.4} MHz offset cannot be stored beside a frequency on the {} 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 the radio cannot store in a memory is FM, the + // same choice the ID-52 writer makes. + _ => 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: if airband { STEP_AIRBAND } else { STEP_DEFAULT }, + unknown2: 0, + byte9: BYTE9_DEFAULT, + squelch: SQL_OFF, + duplex, + polarity: 0, + name: name_bytes, + dv_code: 0, + // An analog memory on this radio carries `CQCQCQ` and blank repeaters — + // every one of the 38 analog memories read does. + 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); + 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, + // `TONE(T)/TSQL(R)` and `TONE(T)/DTCS(R)`: the cross types are not + // measured on this radio. Keep the TRANSMIT side right — a memory that + // does not key its repeater is the failure that matters. + _ => 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 two-letter polarity, in CHIRP's order (NN, NR, RN, RR). Only `NN` + // = 0 is measured here; the rest is CHIRP's table and the ID-52's order. + rec.polarity = match c.dcs_polarity.to_uppercase().as_str() { + "NR" => 1, + "RN" => 2, + "RR" => 3, + _ => 0, + }; + Ok(rec) +} + +// ============================================================ +// Image-level: flags and banks +// ============================================================ + +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 authoritative: +/// the radio leaves old record bytes behind in a cleared slot — the probe +/// session wrote near-copies into two slots it left marked empty. +pub(crate) fn is_used(image: &[u8], slot: usize) -> bool { + !bit(image, EMPTY_BITMAP, slot) +} + +pub(crate) fn read_record(image: &[u8], slot: usize) -> Record { + let at = record_offset(slot); + Record::decode(&image[at..at + REC_LEN]) +} + +/// Skip state as the radio shows it: `SKIP` (passed over by the memory scan) +/// or `PSKIP` (passed over by the program scan too). The two bits were never +/// both set in any read; P wins if they ever are, as it does in CHIRP. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ScanSkip { + None, + Memory, + Program, +} + +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. +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 `[bit 7][mult: bits 5-6][bank: bits 0-4]`. The multiplier +/// bits are measured twice over: 4 of 4 non-5 kHz memories and 0 of 37 others +/// on Tim's configured radio, and all 1000 entries of a factory-reset one +/// (`9F`/`BF`/`DF` against records of mult 0/1/2). CHIRP writes the bare bank +/// number; a 6.25 kHz D-STAR memory written that way would disagree with +/// itself. +/// +/// Bit 7 is unexplained. It is SET, with index `FF`, in every entry straight +/// after a factory reset, and CLEAR, with index `00` for an unbanked memory, in +/// every entry of a radio that has been configured — which is the form written +/// here, and the form the radio accepted on every ladder write. +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 << 5) | b; + image[entry + 1] = idx; +} + +/// Empty `slot` the way the radio does: set its empty bit, clear its skips, and +/// take it out of any bank (`1F 00`, what every empty slot on the radio holds). +/// The record bytes are left alone — the radio leaves them too, and the empty +/// bit is what it reads. +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; +} + +// Read back only by tests today: the download sample shows a memory's bank +// letter, and has no column for a bank's name. +#[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); +} + +// ============================================================ +// Human-readable decode, for the download sample +// ============================================================ + +pub(crate) struct DecodedMemory { + pub slot: usize, + pub name: String, + pub rx_mhz: f64, + pub shift: String, + pub tone: String, + pub mode: String, + pub bank: Option<(char, u8)>, + pub skip: ScanSkip, +} + +fn tone_hz(idx: u8) -> String { + TONES_DHZ + .get(usize::from(idx)) + .map(|t| format!("{}.{}", t / 10, t % 10)) + .unwrap_or_else(|| format!("#{idx}")) +} + +pub(crate) fn describe(rec: &Record) -> (String, String, String) { + let shift = match rec.duplex { + 1 => format!("−{:.3}", rec.offset_hz() / 1e6), + 2 => format!("+{:.3}", rec.offset_hz() / 1e6), + _ => String::new(), + }; + let mode = match rec.mode { + MODE_FM => "FM", + MODE_FM_N => "NFM", + MODE_AM => "AM", + 4 => "AM-N", + MODE_DV => "DV", + _ => "?", + } + .to_string(); + let tone = if rec.mode == MODE_DV { + "—".to_string() + } else { + match rec.squelch { + SQL_OFF => "—".to_string(), + SQL_TONE => format!("T {}", tone_hz(rec.rtone)), + SQL_TSQL => format!("TSQL {}", tone_hz(rec.ctone)), + SQL_DTCS => { + let code = DTCS_CODES.get(usize::from(rec.dtcs)).copied().unwrap_or(0); + let pol = ["N", "NR", "RN", "R"][usize::from(rec.polarity & 3)]; + format!("DTCS {code:03} {pol}") + } + other => format!("sql {other}"), + } + }; + (shift, tone, mode) +} + +pub(crate) fn decode_memories(image: &[u8]) -> Vec { + (0..CHANNEL_COUNT) + .filter(|&s| is_used(image, s)) + .map(|s| { + let rec = read_record(image, s); + let (shift, tone, mode) = describe(&rec); + DecodedMemory { + slot: s, + name: rec.name(), + rx_mhz: rec.rx_hz() / 1e6, + shift, + tone, + mode, + bank: read_bank(image, s).map(|(b, i)| ((b'A' + b) as char, i)), + skip: read_skip(image, s), + } + }) + .collect() +} + +const _: () = assert!(BITMAP_LEN * 8 >= CHANNEL_COUNT); + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Channel; + + fn hex(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() + } + + /// Records copied verbatim from the radio's own clone reads, 2026-09-25 + /// (`scratchpad/id5100/id5100_01_base.img`, and `_06_` for the probe). + /// Memories 7 and 46 and the DV memory's routing were also read back off + /// the radio's screen. + /// + /// 446.8125 DV, DUP− 5.000, 6.25 kHz multiplier, RPT1 `W0QEY B`. + const REAL_1_DV: &str = "251742032028a50000e404435355204453544152202020202020200087461d18745020aec28c5b281042aec28c5b281047"; + /// 447.700 FM, DUP− 5.000, TSQL 100.0 (P-skip on the radio). + const REAL_7_TSQL: &str = "015dc403e830c00000e4344255434b48524e2020202020202020200087461d187450204081020408102040810204081020"; + /// 147.000 FM, DUP+ 0.600, TSQL 100.0. + const REAL_12_PLUS: &str = "0072d8007830c00000e438574152532020202020202020202020200087461d187450204081020408102040810204081020"; + /// 145.310 FM, DUP− 0.600, TONE 88.5. + const REAL_16_TONE: &str = "007186007820800000e414434320424f554c4420202020202020200087461d187450204081020408102040810204081020"; + /// 118.400 AM, 8.33 kHz multiplier, step nibble 14. + const REAL_46_AIR: &str = "483780000030c300e0e400464e4c204154432020202020202020200087461d187450204081020408102040810204081020"; + /// The front-panel probe after FM → FM-N: DTCS 023, DUP− 0.600 (the + /// radio's auto-repeater added it), byte 9 = 00. + const REAL_47_PROBE: &str = "007186007820810000005450524f424520445443532020202020200087461d187450204081020408102040810204081020"; + + fn real(h: &str) -> Record { + Record::decode(&hex(h)) + } + + #[test] + fn decode_encode_is_lossless_for_any_bytes() { + // Every byte value in every position, 49 records' worth. + for fill in 0..=255u8 { + let mut b = [0u8; REC_LEN]; + for (i, x) in b.iter_mut().enumerate() { + *x = fill.wrapping_add((i as u8).wrapping_mul(37)); + } + assert_eq!(Record::decode(&b).encode(), b, "fill {fill:#04x}"); + } + } + + #[test] + fn multiplier_matches_what_the_radio_chose() { + // 5 kHz-divisible frequencies, even ones 6.25 kHz also divides: mult 0. + assert_eq!(multiplier_for(146_625_000, false), Some((0, 29_325))); + assert_eq!(multiplier_for(447_275_000, false), Some((0, 89_455))); + // 6.25-only: the D-STAR repeaters. + assert_eq!(multiplier_for(446_812_500, false), Some((1, 71_490))); + assert_eq!(multiplier_for(145_387_500, false), Some((1, 23_262))); + // Airband prefers 8.33 kHz even on a 5 kHz multiple — 118.400 is stored + // as `m22`, units 14208, in the radio's own read. + assert_eq!(multiplier_for(118_400_000, true), Some((2, 14_208))); + // An 8.33 kHz channel arrives inexact and still lands on its grid point. + assert_eq!(multiplier_for(118_008_333, true), Some((2, 14_161))); + // On no grid at all. + assert_eq!(multiplier_for(146_521_000, false), None); + } + + fn ec(c: Channel) -> ExpandedChannel { + ExpandedChannel { + channel: c, + tg_label: None, + timeslot: None, + tg_number: None, + tg_call_type: None, + tg_inline: false, + } + } + + fn channel(rx: f64) -> Channel { + Channel { + rx_freq: rx, + dcs_polarity: "NN".into(), + ..Default::default() + } + } + + /// Byte-for-byte against memory 7 of the radio's own read: an app channel + /// describing the same repeater encodes to the same record, except byte 9's + /// provenance-dependent value, which both carry as E4. + #[test] + fn a_tsql_repeater_encodes_like_the_radios_own_record() { + let mut c = channel(447.700); + c.duplex = Some("-".into()); + c.offset = Some(5.0); + c.tone_mode = Some("TSQL".into()); + c.ctcss_uplink = Some(100.0); + c.ctcss_downlink = Some(100.0); + let rec = encode_channel(&ec(c), "BUCKHRN").unwrap(); + assert_eq!(rec, real(REAL_7_TSQL)); + } + + #[test] + fn plus_shift_and_tone_repeaters_encode_like_the_radios_own() { + let mut c = channel(147.000); + c.duplex = Some("+".into()); + c.offset = Some(0.6); + c.tone_mode = Some("TSQL".into()); + c.ctcss_uplink = Some(100.0); + c.ctcss_downlink = Some(100.0); + assert_eq!(encode_channel(&ec(c), "WARS").unwrap(), real(REAL_12_PLUS)); + + let mut c = channel(145.310); + c.duplex = Some("-".into()); + c.offset = Some(0.6); + c.tone_mode = Some("Tone".into()); + c.ctcss_uplink = Some(88.5); + assert_eq!(encode_channel(&ec(c), "CC BOULD").unwrap(), real(REAL_16_TONE)); + } + + #[test] + fn a_dv_repeater_carries_its_routing() { + let mut c = channel(446.8125); + c.mode = Some("DV".into()); + c.duplex = Some("-".into()); + c.offset = Some(5.0); + c.dstar_ur_call = Some("CQCQCQ".into()); + c.dstar_rpt1 = Some("W0QEY B".into()); + c.dstar_rpt2 = Some("W0QEY G".into()); + let rec = encode_channel(&ec(c), "CSU DSTAR").unwrap(); + // Identical to the radio's own record except the two tone indices, + // which a DV memory does not use: the radio left 94.8 in them, the + // encoder writes its 88.5 default. + let want = Record { + rtone: rec.rtone, + ctone: rec.ctone, + ..real(REAL_1_DV) + }; + assert_eq!(rec, want); + } + + #[test] + fn airband_am_uses_the_radios_airband_encoding() { + let mut c = channel(118.400); + c.mode = Some("AM".into()); + let rec = encode_channel(&ec(c), "FNL ATC").unwrap(); + // Identical except the unused tone indices (the radio left 100.0). + let want = Record { + rtone: rec.rtone, + ctone: rec.ctone, + ..real(REAL_46_AIR) + }; + assert_eq!(rec, want); + } + + #[test] + fn dtcs_and_narrow_fm_match_the_probe() { + let mut c = channel(145.310); + c.mode = Some("NFM".into()); + c.duplex = Some("-".into()); + c.offset = Some(0.6); + c.tone_mode = Some("DTCS".into()); + c.dcs_code = Some("023".into()); + let rec = encode_channel(&ec(c), "PROBE DTCS").unwrap(); + // Identical except byte 9: the front panel stored 00 where the encoder + // writes the E4 that 41 of the radio's memories carry. + let want = Record { + byte9: BYTE9_DEFAULT, + ..real(REAL_47_PROBE) + }; + assert_eq!(rec, want); + } + + #[test] + fn a_frequency_off_every_grid_is_refused_not_rounded() { + let e = encode_channel(&ec(channel(146.521)), "ODD").unwrap_err(); + assert!(e.contains("146.52100"), "{e}"); + } + + #[test] + fn store_writes_the_multiplier_into_the_bank_byte() { + let mut image = vec![0xFFu8; super::super::layout::IMAGE_LEN]; + let mut c = channel(446.8125); + c.mode = Some("DV".into()); + let rec = encode_channel(&ec(c), "X").unwrap(); + store(&mut image, 1, &rec, Some((0, 2))); + assert_eq!(&image[BANK_TABLE + 2..BANK_TABLE + 4], &[0x20, 0x02]); + assert!(is_used(&image, 1)); + assert_eq!(read_bank(&image, 1), Some((0, 2))); + clear(&mut image, 1); + assert!(!is_used(&image, 1)); + assert_eq!(&image[BANK_TABLE + 2..BANK_TABLE + 4], &[NO_BANK, 0]); + } +} diff --git a/src-tauri/src/radios/icom_id5100/mod.rs b/src-tauri/src/radios/icom_id5100/mod.rs new file mode 100644 index 0000000..2cf1174 --- /dev/null +++ b/src-tauri/src/radios/icom_id5100/mod.rs @@ -0,0 +1,464 @@ +//! Icom ID-5100 driver (`driver_key = "icom_id5100"`, issue #49). +//! +//! A D-STAR dual-band mobile programmed over the data cable by Icom's clone +//! protocol: the whole 0x2A380-byte image is read, patched, and written back. +//! (The radio can also load an `.icf` from its SD card, the ID-52's path; the +//! cable was chosen instead.) +//! +//! - `layout` — offsets and the one firmware layout this driver accepts. +//! - `memory` — records, flag bitmaps, banks. +//! - `program` — the codeplug build: channels + banks into a read image. +//! - `protocol` — the clone protocol. Download measured on the radio; upload +//! is CHIRP's sequence, checked against a fake, and NOT yet run on hardware. +//! +//! Its memory layout is the ID-4100's lineage (CHIRP `id5100.py`), **not** the +//! ID-52's — 26 banks A-Z and 49-byte records, where the ID-52 has 100 groups +//! and 51. What it shares with the ID-52 is Icom's channel semantics, so the +//! channel normalisation (`duplex_and_offset`, `tone_columns`, `call_signs`, …) +//! is the ID-52's own, reused rather than copied. +//! +//! - `settings` — the menu settings: decoded from a download, and written by +//! riding out inside the codeplug program's upload (the UV-5R pattern). + +pub(crate) mod layout; +pub(crate) mod memory; +pub(crate) mod program; +pub(crate) mod protocol; +pub(crate) mod settings; +#[cfg(test)] +mod hw_read; +#[cfg(test)] +mod real_images; +#[cfg(test)] +mod dev_export; +#[cfg(test)] +mod hw_ladder; + +use std::time::Duration; + +use serialport::SerialPort; + +use crate::commands::export::SlotChannel; +use crate::models::RadioModel; +use crate::radios::driver::{ + with_restore_hint, CodeplugProgramReport, DecodedChannelSample, ImageProgramRequest, + ImageProgrammer, ImageRestorer, RadioDriver, RadioIdentity, +}; + +use layout::{CHANNEL_COUNT, IMAGE_LEN, VOLATILE_BASE}; + +pub(crate) struct IcomId5100; + +/// Registry entry (see `radios/registry.rs`). +pub(crate) static DRIVER: IcomId5100 = IcomId5100; + +impl RadioDriver for IcomId5100 { + fn key(&self) -> &'static str { + "icom_id5100" + } + + fn display_name(&self) -> &'static str { + "Icom ID-5100" + } + + /// The rate the radio answers its ID query on. The clone itself switches + /// to [`protocol::BAUD_CLONE`] mid-session. + fn baud(&self) -> u32 { + protocol::BAUD_INITIAL + } + + fn identify(&self, port: &str) -> Result { + open_identified(port).map(|(_, id)| id) + } + + fn as_image_programmer(&self) -> Option<&dyn ImageProgrammer> { + Some(self) + } + + fn as_image_restorer(&self) -> Option<&dyn ImageRestorer> { + Some(self) + } + + fn as_settings_reader(&self) -> Option<&dyn crate::radios::driver::SettingsReader> { + Some(self) + } + + // No `SettingsWriter`: settings live in the clone image, so they are written + // by the codeplug program (`carries_profile_settings`). A standalone settings + // write would be the same full clone and the same press-POWER restart. +} + +/// A fresh session for the next clone. Each clone is its own session, as in +/// CHIRP. +/// +/// ★ Measured on Tim's radio, 2026-09-25: after a clone read ends the radio +/// ignores ID queries for **6.8 s**. The ladder's first write died on exactly +/// that — it opened the write session the instant the backup read finished and +/// got silence. Retried rather than delayed by a fixed amount: five attempts of +/// ~3.25 s each plus the pauses between them is ~22 s of patience. +/// +/// Used for EVERY session, the first included: an operator who presses +/// Download and then Program (or Read settings) within those seconds would +/// otherwise get "the radio did not answer — check the cable" about a cable +/// that is fine. +pub(super) fn reopen(port: &str) -> Result, String> { + open_identified(port).map(|(p, _)| p) +} + +/// [`reopen`], keeping the identity the radio answered with. +fn open_identified(port: &str) -> Result<(Box, RadioIdentity), String> { + let mut last = String::new(); + for attempt in 0..5 { + if attempt > 0 { + std::thread::sleep(Duration::from_millis(1500)); + } + let opened = protocol::open_port(port) + .and_then(|mut p| protocol::identify(&mut *p).map(|id| (p, id))); + match opened { + Ok(pair) => return Ok(pair), + // Only silence is worth waiting out. A radio that ANSWERED and is + // the wrong model or layout will not become right in 1.5 s. + Err(e) if !e.contains("did not answer") => return Err(e), + Err(e) => last = e, + } + } + Err(last) +} + +/// Read the radio again after a write. +/// +/// ★ Measured, ladder steps 1-2 (2026-09-25): after a clone-in the radio shows +/// a message and waits for its POWER button. Until it is pressed it still +/// answers ID queries, but a read starts and then STALLS mid-stream. So a +/// stalled read is retried within a bound while the operator — told by +/// [`ImageProgrammer::after_write_instruction`] — restarts it. +fn read_back(port: &str) -> Result, String> { + let deadline = std::time::Instant::now() + Duration::from_secs(120); + loop { + match reopen(port).and_then(|mut p| protocol::download(&mut *p)) { + Ok(image) => return Ok(image), + Err(e) if std::time::Instant::now() >= deadline => return Err(e), + Err(_) => std::thread::sleep(Duration::from_secs(2)), + } + } +} + +/// Addresses that did not read back as written. +/// +/// Everything below [`VOLATILE_BASE`] must match — that is the whole memory +/// area. Above it the radio rewrites its own RX history and state between any +/// two reads, so only the bytes this write CHANGED are held to it. +fn mismatches(base: &[u8], built: &[u8], after: &[u8]) -> usize { + (0..IMAGE_LEN) + .filter(|&i| { + let ours = i < VOLATILE_BASE || base[i] != built[i]; + ours && built[i] != after[i] + }) + .count() +} + +/// Refuse an image the radio would refuse — and answer by factory resetting. +/// +/// Length first, then every USED memory's frequency against +/// [`layout::RX_COVERAGE`]: an upload is all-or-nothing, and a single memory +/// the radio cannot hold makes it reject the clone and wipe itself (measured +/// 2026-10-01). A backup read off this radio passes by construction; this is +/// for a file from anywhere else. +fn check_image(image: &[u8]) -> Result<(), String> { + if image.len() != IMAGE_LEN { + return Err(format!( + "this file is {} bytes; an ID-5100 image (current firmware layout) is {IMAGE_LEN}", + image.len() + )); + } + for slot in (0..CHANNEL_COUNT).filter(|&s| memory::is_used(image, s)) { + let mhz = memory::read_record(image, slot).rx_hz() / 1e6; + if !layout::RX_COVERAGE.iter().any(|&(lo, hi)| mhz >= lo - 1e-6 && mhz <= hi + 1e-6) { + return Err(format!( + "memory {slot} in this file is {mhz:.4} MHz, outside what the ID-5100 can \ + receive. Writing it would make the radio reject the whole image and reset \ + itself to factory defaults, so nothing was sent." + )); + } + } + Ok(()) +} + +impl ImageProgrammer for IcomId5100 { + /// The profile's settings are in the image the program uploads, so they go + /// out with the channels. + fn carries_profile_settings(&self) -> bool { + true + } + + /// Settings are patched into the image just read, and the schema has no + /// defaults — so a form seeded with guesses would write every guess. + fn profile_starts_blank(&self) -> bool { + true + } + + fn after_write_instruction(&self) -> Option<&'static str> { + Some( + "When the write finishes, the radio asks to be restarted — press its POWER \ + button. The app reads it back to verify once it is up again.", + ) + } + + fn download_image(&self, port: &str) -> Result<(RadioIdentity, Vec), String> { + let (mut p, ident) = open_identified(port)?; + let image = protocol::download(&mut *p)?; + Ok((ident, image)) + } + + fn decode_sample(&self, image: &[u8]) -> Vec { + program::decode_sample(image) + } + + fn upload_image(&self, port: &str, image: &[u8]) -> Result<(), String> { + check_image(image)?; + let mut p = reopen(port)?; + protocol::upload(&mut *p, image) + } + + fn build_image( + &self, + model: &RadioModel, + channels: &[SlotChannel], + base: &[u8], + ) -> Result, String> { + program::build_codeplug(model, channels, &[], base).map(|b| b.image) + } + + /// Download and back up, build the codeplug into THAT image, upload it, and + /// read it back — three clone sessions, each on a freshly opened port. + /// + /// ⚠ The upload half has not run against a radio; the hardware ladder is + /// what proves it. + fn program_codeplug( + &self, + port: &str, + req: &ImageProgramRequest, + ) -> Result { + if req.channels.len() > CHANNEL_COUNT { + return Err(format!( + "Codeplug has {} programmable channels, but the ID-5100 holds only {CHANNEL_COUNT}.", + req.channels.len() + )); + } + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let slug = slug_label(req.label); + let backup_path = req.backup_dir.join(if slug.is_empty() { + format!("id5100-prewrite-{stamp}.img") + } else { + format!("id5100-prewrite-{slug}-{stamp}.img") + }); + + // 1. Download + back up. + let mut p = reopen(port)?; + let base = protocol::download(&mut *p)?; + std::fs::write(&backup_path, &base) + .map_err(|e| format!("could not write backup {}: {e}", backup_path.display()))?; + drop(p); + + // 2. Build into that image. Before the port is touched again, so a + // codeplug that cannot be built never starts a write. + let mut built = program::build_codeplug(req.model, req.channels, req.banks, &base)?; + // ⚠ Both halves of the result are REPORTED — the count and the notes. + // A settings write that said nothing is the dead write path this + // codebase has shipped twice. + let (settings_written, settings_notes) = match req.settings { + Some((values, _schema)) => { + let (n, notes) = settings::apply(&mut built.image, values); + (Some(n), notes) + } + None => (None, Vec::new()), + }; + built.warnings.extend(settings_notes); + + // 3. Upload. + let restore_hint = |e: String| { + with_restore_hint( + e, + &backup_path, + "Keep that file. Put it back with \"Restore backup…\" in this dialog, \ + which uploads it over the same cable — it is the only copy of what was \ + on the radio before this write.", + ) + }; + let mut p = reopen(port).map_err(restore_hint)?; + protocol::upload(&mut *p, &built.image).map_err(restore_hint)?; + drop(p); + + // 4. Read back and verify. Non-fatal: the radio confirmed the clone, so + // a failed read-back is a reporting problem, not a write problem. + let reread = read_back(port); + let (verified, note) = match reread { + Ok(after) => match mismatches(&base, &built.image, &after) { + 0 => (true, None), + n => ( + false, + Some(format!( + "The radio confirmed the write, but {n} bytes of what was written did \ + not read back the same. Power-cycle the radio and use Download to \ + confirm what it is holding." + )), + ), + }, + Err(e) => ( + false, + Some(format!( + "Write completed, but read-back verification could not run ({e}). \ + Power-cycle the radio and use Download to confirm." + )), + ), + }; + + let channels_written = req.channels.len(); + Ok(CodeplugProgramReport { + channels_written, + slots_cleared: CHANNEL_COUNT - channels_written, + settings_written, + verified: Some(verified), + note, + backup_path: backup_path.to_string_lossy().to_string(), + channels: program::decode_sample(&built.image), + zones_written: 0, + zones_cleared: 0, + banks_written: built.banks_written, + scan_lists_written: 0, + scan_lists_cleared: 0, + contacts_written: 0, + contacts_cleared: 0, + expected_path: None, + windows_written: Vec::new(), + skipped: Vec::new(), + warnings: built.warnings, + }) + } +} + +impl ImageRestorer for IcomId5100 { + /// Shape only: a backup from another ID-5100 on the same layout is a normal + /// thing to restore. + fn check_restore_image(&self, image: &[u8]) -> Result<(), String> { + check_image(image) + } + + fn restore_image(&self, port: &str, image: &[u8]) -> Result<(), String> { + self.upload_image(port, image) + } +} + +/// Filesystem-safe slug of a codeplug name, for the backup filename. +fn slug_label(label: &str) -> String { + label + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }) + .collect::() + .trim_matches('-') + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verification_holds_the_memory_area_and_only_what_we_changed_above_it() { + let base = vec![0u8; IMAGE_LEN]; + let mut built = base.clone(); + built[0x100] = 1; // a memory we wrote + built[0x29AFC] = 3; // a setting we wrote + let mut after = built.clone(); + // The radio's own RX-history churn: not ours, not a mismatch. + after[0x24000] = 0x55; + assert_eq!(mismatches(&base, &built, &after), 0); + // A setting we wrote that did not stick is. + after[0x29AFC] = 5; + assert_eq!(mismatches(&base, &built, &after), 1); + // Anything in the memory area is, written by us or not. + after[0x29AFC] = 3; + after[0x200] = 9; + assert_eq!(mismatches(&base, &built, &after), 1); + } + + /// The program path's own sequence — build the codeplug into the read + /// image, then apply the profile's settings to that same image — yields one + /// upload carrying both. The settings half is the one this codebase has + /// twice wired for read and left dead for write. + #[test] + fn a_program_carries_memories_and_settings_together() { + use crate::commands::export::{SlotBank, SlotChannel}; + let mut base = vec![0u8; IMAGE_LEN]; + base[layout::EMPTY_BITMAP..layout::EMPTY_BITMAP + layout::BITMAP_LEN].fill(0xFF); + let model = RadioModel { memory_channels: Some(1000), ..Default::default() }; + let slots = vec![SlotChannel { + slot: 0, + name: "W0UPS 275".into(), + channel: crate::models::Channel { + id: 1, + rx_freq: 447.275, + dcs_polarity: "NN".into(), + ..Default::default() + }, + }]; + let banks = vec![SlotBank { name: "HOME".into(), slots: vec![0] }]; + let mut built = program::build_codeplug(&model, &slots, &banks, &base).unwrap(); + let profile = serde_json::json!({ + "sounds-beep-level": "3", + "my-station-my-call-sign-m01": "WW8L", + "gps-tx-gps-tx-mode": "D-PRS", + }); + let (n, notes) = settings::apply(&mut built.image, &profile); + assert_eq!((n, notes.len()), (3, 0), "{notes:?}"); + assert!(memory::is_used(&built.image, 0)); + assert_eq!(memory::read_record(&built.image, 0).name(), "W0UPS 275"); + assert_eq!(built.image[0x29AFC], 3); + assert_eq!(&built.image[0x2215C..0x22164], b"WW8L "); + assert_eq!(built.image[0x29ACD], 1); + } + + /// The profile editor seeds this radio's form blank: its program writes + /// every value the form holds into the radio's own image, and the schema + /// has no defaults, so a seeded guess would overwrite a real setting. + #[test] + fn a_new_profile_starts_blank() { + let caps = crate::radios::driver::DriverCapabilities::of(&DRIVER); + assert!(caps.settings_start_blank && caps.programs_settings); + let schema: Vec = + serde_json::from_str(crate::seed::ID5100_SETTINGS_SCHEMA).unwrap(); + assert!(schema.iter().all(|f| f.get("default").is_none()), "a default would be written"); + } + + #[test] + fn a_wrong_length_file_is_refused_for_restore() { + assert!(DRIVER.check_restore_image(&[0u8; 1000]).is_err()); + } + + /// The probe image that reset the radio on 2026-10-01 is refused here, and + /// the backup that restored it is not. Real bytes: the probe's X1 record + /// (117.975 AM, 8.33 kHz multiplier) as the driver built it. + #[test] + fn a_file_with_an_out_of_coverage_memory_is_refused_for_restore() { + let mut image = vec![0u8; IMAGE_LEN]; + image[layout::EMPTY_BITMAP..layout::EMPTY_BITMAP + layout::BITMAP_LEN].fill(0xFF); + assert!(DRIVER.check_restore_image(&image).is_ok()); + let ec = crate::commands::export::ExpandedChannel { + channel: crate::models::Channel { + rx_freq: 117.975, + mode: Some("AM".into()), + dcs_polarity: "NN".into(), + ..Default::default() + }, + tg_label: None, + timeslot: None, + tg_number: None, + tg_call_type: None, + tg_inline: false, + }; + let rec = memory::encode_channel(&ec, "X1").unwrap(); + memory::store(&mut image, 996, &rec, None); + let e = DRIVER.check_restore_image(&image).unwrap_err(); + assert!(e.contains("memory 996") && e.contains("factory defaults"), "{e}"); + } +} diff --git a/src-tauri/src/radios/icom_id5100/program.rs b/src-tauri/src/radios/icom_id5100/program.rs new file mode 100644 index 0000000..d588b4c --- /dev/null +++ b/src-tauri/src/radios/icom_id5100/program.rs @@ -0,0 +1,326 @@ +//! Building an ID-5100 codeplug: the app's channels and banks patched into an +//! image the radio just handed us. +//! +//! Pure — no serial I/O. `mod.rs` calls [`build_codeplug`] between the download +//! and the upload. +//! +//! ## Full replace, of the memory area only +//! +//! All 1000 memories are rewritten: the codeplug's channels at the slots the +//! resolver chose, every other slot emptied, and all 26 bank names rewritten — +//! the codeplug's lists in order, the rest blank, which is what an unused bank +//! holds on the radio (16 spaces). Nothing outside the record array, the three +//! bitmaps, the bank table and the bank names is touched, so the call channels, +//! the repeater list, Your Call Sign memory, GPS memory and every menu setting +//! go back exactly as they were read. +//! +//! ## Banks +//! +//! Lists become banks A, B, C … in codeplug order. A bank indexes 100 memories +//! (`00`-`99`) and there are 26 of them; what does not fit stays programmed but +//! in no bank, and the operator is told which. A channel in two lists is in the +//! first list's bank — a memory carries one bank byte. + +use crate::commands::export::{ExpandedChannel, SlotBank, SlotChannel}; +use crate::models::RadioModel; +use crate::radios::driver::DecodedChannelSample; + +use super::layout::{BANK_CAPACITY, BANK_COUNT, CHANNEL_COUNT, IMAGE_LEN, RX_COVERAGE}; +use super::memory::{ + clear, decode_memories, encode_channel, is_used, set_bank_name, store, Record, ScanSkip, +}; + +pub(crate) struct Built { + pub image: Vec, + pub banks_written: usize, + pub warnings: Vec, +} + +pub(crate) fn build_codeplug( + model: &RadioModel, + channels: &[SlotChannel], + banks: &[SlotBank], + base: &[u8], +) -> Result { + if base.len() != IMAGE_LEN { + return Err(format!( + "expected a {IMAGE_LEN}-byte ID-5100 image, got {} bytes", + base.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!( + "Codeplug has {} programmable channels, but the ID-5100 holds only {capacity}.", + channels.len() + )); + } + + // Encode every channel BEFORE touching the image, so one bad channel leaves + // nothing half-patched. + let mut records: Vec<(usize, Record)> = Vec::with_capacity(channels.len()); + for sc in channels { + if sc.slot >= capacity { + return Err(format!( + "channel \"{}\" resolved to memory {} — beyond the ID-5100's {capacity}.", + sc.name, sc.slot + )); + } + // ★★ The radio refuses a whole clone over one memory it cannot hold, + // and FACTORY RESETS (layout::RX_COVERAGE). `rx_bands` should have + // excluded this channel upstream; refusing here as well is what makes + // that a guarantee rather than an assumption. + let f = sc.channel.rx_freq; + if !RX_COVERAGE.iter().any(|&(lo, hi)| f >= lo && f <= hi) { + return Err(format!( + "channel \"{}\" ({f:.4} MHz) is outside the ID-5100's receive coverage \ + (118–174 and 375–550 MHz). Nothing was written: the radio rejects an \ + image carrying such a memory and resets itself to factory defaults.", + sc.name + )); + } + let ec = ExpandedChannel { + channel: sc.channel.clone(), + tg_label: None, + timeslot: None, + tg_number: None, + tg_call_type: None, + tg_inline: false, + }; + let rec = encode_channel(&ec, &sc.name).map_err(|e| { + format!("channel \"{}\" ({:.4} MHz) cannot be programmed: {e}", sc.name, sc.channel.rx_freq) + })?; + records.push((sc.slot, rec)); + } + + let mut warnings = Vec::new(); + let mut bank_of: Vec> = vec![None; CHANNEL_COUNT]; + let mut names: Vec<&str> = Vec::new(); + for bank in banks.iter().filter(|b| !b.slots.is_empty()) { + if names.len() == BANK_COUNT { + warnings.push(format!( + "List \"{}\" is programmed but in no bank: the ID-5100 has {BANK_COUNT} banks \ + and the lists before it used them all.", + bank.name + )); + continue; + } + let letter = names.len() as u8; + names.push(&bank.name); + for (idx, &slot) in bank.slots.iter().enumerate() { + if idx < BANK_CAPACITY && slot < CHANNEL_COUNT { + bank_of[slot] = Some((letter, idx as u8)); + } + } + if bank.slots.len() > BANK_CAPACITY { + warnings.push(format!( + "List \"{}\" has {} channels; bank {} holds {BANK_CAPACITY}, so the last {} \ + are programmed but in no bank.", + bank.name, + bank.slots.len(), + (b'A' + letter) as char, + bank.slots.len() - BANK_CAPACITY + )); + } + } + + let mut image = base.to_vec(); + let mut occupied = vec![false; CHANNEL_COUNT]; + for (slot, rec) in &records { + occupied[*slot] = true; + store(&mut image, *slot, rec, bank_of[*slot]); + } + for slot in (0..CHANNEL_COUNT).filter(|&s| !occupied[s] && is_used(base, s)) { + clear(&mut image, slot); + } + for bank in 0..BANK_COUNT { + set_bank_name(&mut image, bank, names.get(bank).copied().unwrap_or("")); + } + + Ok(Built { + image, + banks_written: names.len(), + warnings, + }) +} + +/// Decode an image's memories for the download sanity sample. `power` is `"—"`: +/// the ID-5100 keeps power per band, not per memory. +pub(crate) fn decode_sample(image: &[u8]) -> Vec { + decode_memories(image) + .into_iter() + .map(|m| DecodedChannelSample { + index: m.slot, + // Bank and skip ride in the name column: the sample has no field + // for either, and both are what the operator checks the radio by. + name: format!( + "{}{}{}", + m.name, + m.bank.map(|(b, i)| format!(" [{b}{i:02}]")).unwrap_or_default(), + match m.skip { + ScanSkip::None => "", + ScanSkip::Memory => " (skip)", + ScanSkip::Program => " (P-skip)", + } + ), + rx_mhz: m.rx_mhz, + shift: Some(m.shift), + tone: m.tone, + power: "—".into(), + mode: Some(m.mode), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Channel; + use crate::radios::icom_id5100::layout::{BANK_NAMES, BANK_TABLE, EMPTY_BITMAP}; + use crate::radios::icom_id5100::memory::{bank_name, read_bank, read_record}; + + fn model() -> RadioModel { + RadioModel { + display_name: "Icom ID-5100".into(), + memory_channels: Some(1000), + ..Default::default() + } + } + + /// Shaped like a read off the radio: every slot empty with the radio's own + /// default record and `1F 00` bank entry, all bank names blank — then two + /// memories and a named bank the operator made, which the build must clear. + fn base() -> Vec { + let mut image = vec![0u8; IMAGE_LEN]; + image[EMPTY_BITMAP..EMPTY_BITMAP + 125].fill(0xFF); + for s in 0..CHANNEL_COUNT { + image[BANK_TABLE + 2 * s] = 0x1F; + } + image[BANK_NAMES..BANK_NAMES + 26 * 16].fill(b' '); + let mut c = Channel { rx_freq: 146.52, dcs_polarity: "NN".into(), ..Default::default() }; + c.id = 900; + let ec = ExpandedChannel { + channel: c, + tg_label: None, + timeslot: None, + tg_number: None, + tg_call_type: None, + tg_inline: false, + }; + let rec = encode_channel(&ec, "OLD").unwrap(); + store(&mut image, 500, &rec, Some((3, 0))); + set_bank_name(&mut image, 3, "OLD BANK"); + // Settings live above the memory area; they must come through as read. + image[0x29AFC] = 5; + image + } + + fn slot(n: usize, id: i64, name: &str, rx: f64) -> SlotChannel { + SlotChannel { + slot: n, + name: name.into(), + channel: Channel { + id, + rx_freq: rx, + dcs_polarity: "NN".into(), + ..Default::default() + }, + } + } + + #[test] + fn lists_become_named_banks_and_the_rest_is_cleared() { + let base = base(); + let slots = vec![ + slot(0, 1, "ONE", 146.94), + slot(1, 2, "TWO", 447.275), + slot(2, 3, "THREE", 145.3875), + ]; + let banks = vec![ + SlotBank { name: "Local".into(), slots: vec![0, 2] }, + SlotBank { name: "Empty".into(), slots: vec![] }, + SlotBank { name: "UHF".into(), slots: vec![1] }, + ]; + let built = build_codeplug(&model(), &slots, &banks, &base).unwrap(); + let image = &built.image; + assert_eq!(built.banks_written, 2); + assert!(built.warnings.is_empty()); + // The empty list takes no letter; the next list is B. + assert_eq!(read_bank(image, 0), Some((0, 0))); + assert_eq!(read_bank(image, 2), Some((0, 1))); + assert_eq!(read_bank(image, 1), Some((1, 0))); + assert_eq!(bank_name(image, 0), "Local"); + assert_eq!(bank_name(image, 1), "UHF"); + // The operator's old memory and bank name are gone. + assert!(!is_used(image, 500)); + assert_eq!(bank_name(image, 3), ""); + assert_eq!(read_record(image, 1).name(), "TWO"); + // A 6.25 kHz memory's bank byte carries its multiplier. + assert_eq!(image[BANK_TABLE + 4], 0x20); + // Outside the memory area, nothing moved. + assert_eq!(image[0x29AFC], 5); + let changed_high = (super::super::layout::VOLATILE_BASE..IMAGE_LEN) + .filter(|&i| image[i] != base[i]) + .count(); + assert_eq!(changed_high, 0); + } + + #[test] + fn overflowing_banks_are_programmed_unbanked_and_reported() { + let slots: Vec = + (0..130).map(|i| slot(i, i as i64, "X", 146.0 + i as f64 * 0.01)).collect(); + let mut banks: Vec = vec![SlotBank { + name: "Big".into(), + slots: (0..102).collect(), + }]; + for i in 102..130 { + banks.push(SlotBank { name: format!("L{i}"), slots: vec![i] }); + } + let built = build_codeplug(&model(), &slots, &banks, &base()).unwrap(); + assert_eq!(built.banks_written, 26); + assert_eq!(read_bank(&built.image, 99), Some((0, 99))); + assert_eq!(read_bank(&built.image, 100), None); + assert!(is_used(&built.image, 100)); + // Lists 27+ (L127, L128, L129) get no bank. + assert_eq!(read_bank(&built.image, 127), None); + assert!(is_used(&built.image, 129)); + assert_eq!(built.warnings.len(), 1 + 3, "{:#?}", built.warnings); + } + + #[test] + fn a_channel_outside_coverage_is_refused_before_anything_is_built() { + for f in [117.975, 174.005, 300.0, 374.995, 550.005] { + let Err(e) = build_codeplug(&model(), &[slot(0, 1, "OUT", f)], &[], &base()) else { + panic!("{f} MHz was built"); + }; + assert!(e.contains("factory defaults"), "{f}: {e}"); + } + for f in [118.0, 174.0, 375.0, 550.0] { + assert!(build_codeplug(&model(), &[slot(0, 1, "IN", f)], &[], &base()).is_ok(), "{f}"); + } + } + + /// The seed's `rx_bands` is what keeps out-of-coverage channels away from + /// this driver in the first place; the driver's guard is the backstop. If + /// they ever disagree, one of them is wrong about a limit that resets the + /// radio. + #[test] + fn the_guard_and_the_seed_agree_on_coverage() { + let seeded = crate::seed::model_rx_bands("ID-5100").expect("seeded"); + let seeded: Vec<(f64, f64)> = serde_json::from_str::>>(seeded) + .unwrap() + .into_iter() + .map(|b| (b[0], b[1])) + .collect(); + assert_eq!(seeded, RX_COVERAGE.to_vec()); + } + + #[test] + fn one_bad_channel_leaves_the_image_untouched() { + let slots = vec![slot(0, 1, "OK", 146.94), slot(1, 2, "ODD", 146.521)]; + assert!(build_codeplug(&model(), &slots, &[], &base()).is_err()); + } +} diff --git a/src-tauri/src/radios/icom_id5100/protocol.rs b/src-tauri/src/radios/icom_id5100/protocol.rs new file mode 100644 index 0000000..27cf9e3 --- /dev/null +++ b/src-tauri/src/radios/icom_id5100/protocol.rs @@ -0,0 +1,526 @@ +//! Icom clone protocol, as the ID-5100 speaks it over its data cable. +//! +//! Transcribed from CHIRP's `icf.py` (the ID-5100 is `_raw_frames`, +//! `_highbit_flip`, `_can_hispeed`), then run against the real radio in +//! `scratchpad/id5100/readradio.py` before this file existed: that reader's +//! image matched CHIRP's own download of the same radio in every byte but the +//! read counter. What that proves and what it does not: +//! +//! - **Download** — the frame format, escaping, checksum, hispeed switch, high- +//! bit flip and end frame are measured (six reads, zero checksum failures). +//! - **Upload** — NOT run against a radio. It is CHIRP's sequence with the same +//! framing the download proves, and the fake radio below checks its +//! sequencing; the hardware ladder is what proves it. +//! +//! ## Frames +//! +//! `FE FE FD`, PC = `EE`, radio = `EF`. Payload +//! bytes above `F9` are escaped as `FF, b & 0x0F` so `FD`/`FE` never appear +//! inside one. Clone data frames carry `[addr u32 BE][len][data…][checksum]`, +//! the checksum being the two's complement of the byte sum of everything before +//! it. On the wire every image byte has its high bit flipped. +//! +//! The RT Systems cable Tim uses does **not** echo (measured: an ID query came +//! back as the radio's reply alone). Echoed frames from a cable that does are +//! skipped by source address anyway, as CHIRP does. + +use std::time::Duration; + +use serialport::{ClearBuffer, SerialPort}; + +use super::layout::{ENDFRAME, IMAGE_LEN, MAP_REV, MODEL}; +use crate::radios::driver::RadioIdentity; + +pub(crate) const BAUD_INITIAL: u32 = 9600; +pub(crate) const BAUD_CLONE: u32 = 38400; + +const PC: u8 = 0xEE; +const RADIO: u8 = 0xEF; +const CMD_ID: u8 = 0xE0; +const CMD_MODEL: u8 = 0xE1; +const CMD_CLONE_OUT: u8 = 0xE2; +const CMD_CLONE_IN: u8 = 0xE3; +const CMD_DATA: u8 = 0xE4; +const CMD_END: u8 = 0xE5; +const CMD_OK: u8 = 0xE6; +const CMD_HISPEED: u8 = 0xE8; + +/// Bytes per upload frame — CHIRP's `_ranges = [(0, memsize, 64)]`. The radio +/// sends 32 per frame on download; it is the writer's choice on upload. +const UPLOAD_CHUNK: usize = 64; + +const TIMEOUT: Duration = Duration::from_millis(250); +/// Quiet reads before a stalled stream is given up on: 40 x 250 ms = CHIRP's +/// ten seconds. +const STALL_READS: usize = 40; + +pub(crate) fn open_port(port: &str) -> Result, String> { + serialport::new(port, BAUD_INITIAL) + .data_bits(serialport::DataBits::Eight) + .parity(serialport::Parity::None) + .stop_bits(serialport::StopBits::One) + .flow_control(serialport::FlowControl::None) + .timeout(TIMEOUT) + .open() + .map_err(|e| format!("could not open {port}: {e}")) +} + +fn frame(cmd: u8, payload: &[u8]) -> Vec { + let mut f = vec![0xFE, 0xFE, PC, RADIO, cmd]; + f.extend_from_slice(payload); + f.push(0xFD); + f +} + +fn escape(data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(data.len() + 8); + for &b in data { + if b > 0xF9 { + out.extend_from_slice(&[0xFF, b & 0x0F]); + } else { + out.push(b); + } + } + out +} + +fn unescape(data: &[u8]) -> Result, String> { + let mut out = Vec::with_capacity(data.len()); + let mut it = data.iter(); + while let Some(&b) = it.next() { + if b == 0xFF { + let &lo = it.next().ok_or("escape byte at the end of a frame")?; + out.push(0xF0 | lo); + } else { + out.push(b); + } + } + Ok(out) +} + +fn checksum(data: &[u8]) -> u8 { + let sum: u32 = data.iter().map(|&b| u32::from(b)).sum(); + ((sum ^ 0xFFFF) + 1) as u8 +} + +fn write(p: &mut dyn SerialPort, bytes: &[u8]) -> Result<(), String> { + p.write_all(bytes).map_err(|e| format!("write to radio failed: {e}"))?; + p.flush().map_err(|e| format!("write to radio failed: {e}")) +} + +/// Radio-to-PC frames, reassembled from whatever the port delivers. +struct Frames { + buf: Vec, +} + +impl Frames { + fn new() -> Self { + Frames { buf: Vec::new() } + } + + /// Read what is available. `false` means the port was quiet for one timeout. + fn fill(&mut self, p: &mut dyn SerialPort) -> Result { + let mut chunk = [0u8; 4096]; + match p.read(&mut chunk) { + Ok(0) => Ok(false), + Ok(n) => { + self.buf.extend_from_slice(&chunk[..n]); + Ok(true) + } + Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => Ok(false), + Err(e) => Err(format!("read from radio failed: {e}")), + } + } + + /// Next complete frame from the radio as `(cmd, raw payload)`. Frames the PC + /// sent (an echoing cable) are dropped here. + fn next(&mut self) -> Option<(u8, Vec)> { + loop { + let start = self.buf.windows(2).position(|w| w == [0xFE, 0xFE])?; + self.buf.drain(..start); + // Hispeed frames arrive behind a run of FE padding. + while self.buf.len() >= 3 && self.buf[..3] == [0xFE, 0xFE, 0xFE] { + self.buf.remove(0); + } + let end = self.buf.iter().position(|&b| b == 0xFD)?; + let f: Vec = self.buf.drain(..=end).collect(); + if f.len() < 6 || f[2] == PC { + continue; + } + return Some((f[4], f[5..f.len() - 1].to_vec())); + } + } +} + +/// Ask the radio who it is, and refuse anything but an ID-5100 on the layout +/// this driver knows. +/// +/// The model-data reply is 49 bytes; byte 5 is the memory-layout revision +/// (CHIRP `decode_model`). Measured `3` on firmware 1.21. A radio on an older +/// layout stores its memories at different offsets, and the next thing this +/// driver does after identifying may be to write — so it stops here. +pub(crate) fn identify(p: &mut dyn SerialPort) -> Result { + let _ = p.clear(ClearBuffer::Input); + write(p, &frame(CMD_ID, &escape(&[0, 0, 0, 0])))?; + let mut frames = Frames::new(); + let mut quiet = 0; + let md = loop { + if let Some((cmd, payload)) = frames.next() { + if cmd == CMD_MODEL { + break unescape(&payload)?; + } + continue; + } + if !frames.fill(p)? { + quiet += 1; + if quiet > 12 { + return Err("the radio did not answer. Check the cable is in the data jack \ + and the radio is on." + .into()); + } + } + }; + let hex = md.iter().map(|b| format!("{b:02x}")).collect::(); + if md.len() < 6 || md[..4] != MODEL { + return Err(format!( + "the radio on this port is not an ID-5100 (it identified as {}). Writing an \ + ID-5100 codeplug to it could not work and might not be harmless.", + &hex[..hex.len().min(8)] + )); + } + if md[5] != MAP_REV { + return Err(format!( + "this ID-5100 reports memory layout revision {}, and CodePlug Magic knows only \ + revision {MAP_REV} — the layout current firmware uses. Older firmware stores \ + memories at different addresses, so nothing will be written. Updating the \ + radio's firmware from Icom's site moves it to revision {MAP_REV}.", + md[5] + )); + } + Ok(RadioIdentity { + matched: "ID-5100".into(), + ident_hex: hex, + ident_ascii: None, + }) +} + +/// Switch the session to 38400 baud and send `cmd` (clone out or in). +/// +/// The switch frame's payload ends in a literal `FD` before the frame's own — +/// CHIRP's `start_hispeed_clone`, byte for byte — and the radio sends nothing +/// back (measured: zero reply bytes). +fn start_hispeed(p: &mut dyn SerialPort, cmd: u8) -> Result<(), String> { + let mut payload = MODEL.to_vec(); + payload.extend_from_slice(&[0x00, 0x00, 0x02, 0x01, 0xFD]); + let mut bytes = vec![0xFE; 20]; + bytes.extend(frame(CMD_HISPEED, &payload)); + write(p, &bytes)?; + // CHIRP reads (and ignores) up to 128 bytes here, costing one timeout. + let mut sink = Frames::new(); + sink.fill(p)?; + p.set_baud_rate(BAUD_CLONE) + .map_err(|e| format!("could not switch to {BAUD_CLONE} baud: {e}"))?; + let mut bytes = vec![0xFE; 14]; + bytes.extend(frame(cmd, &[MODEL[0], MODEL[1], MODEL[2], 0x00])); + write(p, &bytes) +} + +/// Read the whole image. Call [`identify`] on the same port first. +/// +/// Returns the image with the high bits already restored — the same bytes +/// CHIRP stores in an `.img`. Refuses a read with a bad frame checksum, a gap, +/// or an end frame other than the one this layout ends with. +pub(crate) fn download(p: &mut dyn SerialPort) -> Result, String> { + start_hispeed(p, CMD_CLONE_OUT)?; + let mut image = vec![0u8; IMAGE_LEN]; + let mut covered = vec![false; IMAGE_LEN]; + let mut frames = Frames::new(); + let mut quiet = 0; + let end = loop { + match frames.next() { + Some((CMD_DATA, payload)) => { + quiet = 0; + let d = unescape(&payload)?; + if d.len() < 6 { + return Err("a clone frame from the radio was too short".into()); + } + let addr = u32::from_be_bytes([d[0], d[1], d[2], d[3]]) as usize; + let n = usize::from(d[4]); + if d.len() != 5 + n + 1 { + return Err(format!("clone frame at {addr:#07x} has the wrong length")); + } + if checksum(&d[..5 + n]) != d[5 + n] { + return Err(format!("checksum error in the clone frame at {addr:#07x}")); + } + if addr + n > IMAGE_LEN { + return Err(format!( + "the radio sent data at {addr:#07x}, past the {IMAGE_LEN:#x}-byte image \ + this driver knows — a different memory layout" + )); + } + image[addr..addr + n].copy_from_slice(&d[5..5 + n]); + covered[addr..addr + n].iter_mut().for_each(|c| *c = true); + } + Some((CMD_END, payload)) => break unescape(&payload)?, + Some(_) => {} + None => { + if !frames.fill(p)? { + quiet += 1; + if quiet > STALL_READS { + return Err("the radio stopped sending before the clone finished".into()); + } + } + } + } + }; + if end != ENDFRAME { + return Err(format!( + "the clone ended with {:?}, not {:?} — a different memory layout", + String::from_utf8_lossy(&end), + String::from_utf8_lossy(ENDFRAME) + )); + } + if let Some(gap) = covered.iter().position(|c| !c) { + return Err(format!("the radio's clone skipped the bytes at {gap:#07x}")); + } + image.iter_mut().for_each(|b| *b ^= 0x80); + Ok(image) +} + +/// Write a whole image. Call [`identify`] on the same port first. +/// +/// ⚠ Not yet run against a radio — see the module header. +pub(crate) fn upload(p: &mut dyn SerialPort, image: &[u8]) -> Result<(), String> { + if image.len() != IMAGE_LEN { + return Err(format!( + "refusing to write a {}-byte image; an ID-5100 image is {IMAGE_LEN} bytes", + image.len() + )); + } + start_hispeed(p, CMD_CLONE_IN)?; + let mut frames = Frames::new(); + for addr in (0..IMAGE_LEN).step_by(UPLOAD_CHUNK) { + let n = UPLOAD_CHUNK.min(IMAGE_LEN - addr); + let mut chunk = (addr as u32).to_be_bytes().to_vec(); + chunk.push(n as u8); + chunk.extend(image[addr..addr + n].iter().map(|b| b ^ 0x80)); + chunk.push(checksum(&chunk)); + write(p, &frame(CMD_DATA, &escape(&chunk)))?; + // Nothing is expected back mid-stream, but a radio that has something + // to say should not have it pile up in the driver's buffer. + if p.bytes_to_read().unwrap_or(0) > 0 { + frames.fill(p)?; + } + } + write(p, &frame(CMD_END, &escape(ENDFRAME)))?; + + let mut quiet = 0; + loop { + match frames.next() { + Some((CMD_OK, payload)) => { + let r = unescape(&payload)?; + return if r.first() == Some(&0) { + Ok(()) + } else { + Err(format!( + "the radio refused the clone (result {:02x?}). It may be holding a \ + partial write — restore the backup before using it.", + r + )) + }; + } + Some(_) => {} + None => { + if !frames.fill(p)? { + quiet += 1; + if quiet > 20 { + return Err("the radio took the whole image but never confirmed it. \ + Power-cycle it and read it back before trusting it." + .into()); + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::radios::fake_port::{FakePort, FakeRadio}; + + /// An ID-5100 at the far end of the cable: answers the ID query, streams its + /// image on clone-out the way the real radio does (32-byte frames, high bits + /// flipped), takes frames on clone-in, and confirms on end. + struct FakeIcom { + image: Vec, + rev: u8, + written: Vec, + received: Vec, + clone_in: bool, + ended: bool, + /// Corrupt the checksum of the download frame at this address. + bad_checksum_at: Option, + } + + impl FakeIcom { + fn new(image: Vec) -> Self { + FakeIcom { + written: vec![0; image.len()], + received: vec![false; image.len()], + image, + rev: MAP_REV, + clone_in: false, + ended: false, + bad_checksum_at: None, + } + } + + fn reply(out: &mut Vec, cmd: u8, payload: &[u8]) { + out.extend_from_slice(&[0xFE, 0xFE, RADIO, PC, cmd]); + out.extend(escape(payload)); + out.push(0xFD); + } + } + + impl FakeRadio for FakeIcom { + fn step(&mut self, req: &[u8], out: &mut Vec) -> usize { + // Padding, and the stray FD the hispeed frame's payload carries. + let lead = req.iter().take_while(|&&b| b == 0xFD).count(); + if lead > 0 { + return lead; + } + let Some(end) = req.iter().position(|&b| b == 0xFD) else { + return 0; + }; + let f = &req[..=end]; + let fe = f.iter().take_while(|&&b| b == 0xFE).count(); + assert!(fe >= 2, "frame without FE FE: {f:02x?}"); + let body = &f[fe..f.len() - 1]; + assert_eq!(&body[..2], &[PC, RADIO], "frame not PC→radio"); + let (cmd, payload) = (body[2], &body[3..]); + match cmd { + CMD_ID => { + let mut md = MODEL.to_vec(); + md.push(0x2D); + md.push(self.rev); + md.resize(49, 0x20); + Self::reply(out, CMD_MODEL, &md); + } + // The hispeed payload is 00 00 02 01 then its own FD, which + // ended this frame early; the frame's real FD is consumed as a + // stray on the next step. + CMD_HISPEED => {} + CMD_CLONE_OUT => { + for addr in (0..self.image.len()).step_by(32) { + let mut d = (addr as u32).to_be_bytes().to_vec(); + d.push(32); + d.extend(self.image[addr..addr + 32].iter().map(|b| b ^ 0x80)); + let mut cs = checksum(&d); + if self.bad_checksum_at == Some(addr) { + cs ^= 1; + } + d.push(cs); + Self::reply(out, CMD_DATA, &d); + } + Self::reply(out, CMD_END, ENDFRAME); + } + CMD_CLONE_IN => self.clone_in = true, + CMD_DATA => { + assert!(self.clone_in, "data before clone-in"); + let d = unescape(payload).unwrap(); + let n = usize::from(d[4]); + assert_eq!(checksum(&d[..5 + n]), d[5 + n], "bad upload checksum"); + let addr = u32::from_be_bytes([d[0], d[1], d[2], d[3]]) as usize; + for (i, b) in d[5..5 + n].iter().enumerate() { + self.written[addr + i] = b ^ 0x80; + self.received[addr + i] = true; + } + } + CMD_END => { + assert_eq!(unescape(payload).unwrap(), ENDFRAME); + self.ended = true; + if self.clone_in { + Self::reply(out, CMD_OK, &[0x00]); + } + } + other => panic!("unexpected command {other:#04x}"), + } + end + 1 + } + } + + /// A deterministic image with every byte value in it, so escaping (F A-F F) + /// and the high-bit flip are both exercised. + fn sample_image() -> Vec { + (0..IMAGE_LEN).map(|i| (i * 7 + i / 256) as u8).collect() + } + + #[test] + fn escape_round_trips_every_byte() { + let all: Vec = (0..=255).collect(); + let e = escape(&all); + assert!(!e.contains(&0xFD) && !e.contains(&0xFE)); + assert_eq!(unescape(&e).unwrap(), all); + } + + #[test] + fn checksum_matches_chirps_formula() { + // ((sum ^ 0xFFFF) + 1) & 0xFF == two's complement of the low byte. + assert_eq!(checksum(&[0x00, 0x00, 0x01, 0x00, 0x20]), 0xDF); + assert_eq!(checksum(&[]), 0x00); + } + + #[test] + fn identify_accepts_revision_3() { + let mut p = FakePort::new(FakeIcom::new(sample_image())); + let id = identify(&mut p).unwrap(); + assert_eq!(id.matched, "ID-5100"); + assert!(id.ident_hex.starts_with("348400012d03")); + } + + #[test] + fn identify_refuses_an_older_layout_by_name() { + let mut radio = FakeIcom::new(sample_image()); + radio.rev = 2; + let Err(e) = identify(&mut FakePort::new(radio)) else { + panic!("a revision-2 radio was accepted"); + }; + assert!(e.contains("revision 2"), "{e}"); + } + + #[test] + fn download_returns_the_radios_image() { + let image = sample_image(); + let mut p = FakePort::new(FakeIcom::new(image.clone())); + identify(&mut p).unwrap(); + assert_eq!(download(&mut p).unwrap(), image); + } + + #[test] + fn a_bad_frame_checksum_fails_the_download() { + let mut radio = FakeIcom::new(sample_image()); + radio.bad_checksum_at = Some(0x1000); + let mut p = FakePort::new(radio); + identify(&mut p).unwrap(); + assert!(download(&mut p).unwrap_err().contains("0x01000")); + } + + #[test] + fn upload_delivers_every_byte_and_ends_confirmed() { + let image = sample_image(); + let mut p = FakePort::new(FakeIcom::new(vec![0; IMAGE_LEN])); + identify(&mut p).unwrap(); + upload(&mut p, &image).unwrap(); + assert!(p.radio.ended); + assert!(p.radio.received.iter().all(|&r| r)); + assert_eq!(p.radio.written, image); + } + + #[test] + fn upload_refuses_a_wrong_length_image_before_sending() { + let mut p = FakePort::new(FakeIcom::new(vec![0; IMAGE_LEN])); + assert!(upload(&mut p, &[0u8; 100]).is_err()); + assert!(!p.radio.clone_in); + } +} diff --git a/src-tauri/src/radios/icom_id5100/real_images.rs b/src-tauri/src/radios/icom_id5100/real_images.rs new file mode 100644 index 0000000..8cf225c --- /dev/null +++ b/src-tauri/src/radios/icom_id5100/real_images.rs @@ -0,0 +1,159 @@ +//! Phase 2's gate, at image scale: every record of every real ID-5100 clone +//! read decodes and re-encodes **byte-identically**, and every used memory's +//! flags and bank entry come back the same when stored through [`store`]. +//! +//! `#[ignore]`d and env-gated because the reads live in gitignored +//! `scratchpad/` — they are Tim's radio, so they are not committed and CI +//! cannot see them. `memory.rs` carries six real records as constants, which +//! CI does run. +//! +//! ```sh +//! CPM_ID5100_IMAGES=../scratchpad/id5100 \ +//! cargo test --lib icom_id5100::real_images -- --ignored --nocapture +//! ``` + +use super::layout::{record_offset, BANK_TABLE, CHANNEL_COUNT, IMAGE_LEN, RECORD_COUNT, REC_LEN}; +use super::memory::{is_used, read_bank, read_record, read_skip, store, Record, ScanSkip}; + +fn images() -> Vec<(String, Vec)> { + let dir = std::env::var("CPM_ID5100_IMAGES").expect("CPM_ID5100_IMAGES"); + let mut out = Vec::new(); + for entry in std::fs::read_dir(&dir).expect("read image dir") { + let path = entry.expect("dir entry").path(); + if path.extension().and_then(|e| e.to_str()) != Some("img") { + continue; + } + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + let mut bytes = std::fs::read(&path).expect("read image"); + // CHIRP appends a metadata trailer; our own reader does not. + bytes.truncate(IMAGE_LEN); + out.push((name, bytes)); + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + assert!(!out.is_empty(), "no .img files in {dir}"); + out +} + +#[test] +#[ignore = "needs the real reads in scratchpad/id5100"] +fn every_record_of_every_real_read_re_encodes_byte_identically() { + for (name, image) in images() { + assert_eq!(image.len(), IMAGE_LEN, "{name}"); + for slot in 0..RECORD_COUNT { + let at = record_offset(slot); + let raw = &image[at..at + REC_LEN]; + assert_eq!(Record::decode(raw).encode(), raw, "{name} record {slot}"); + } + println!("{name}: {RECORD_COUNT} records byte-identical"); + } +} + +/// Storing each used memory back through `store` — record, empty bit, skip +/// bits cleared, bank byte with its multiplier bits — reproduces the radio's +/// own bytes for every memory that is not skipped. (`store` is for NEW +/// channels, which carry no skip; a skipped memory differs in exactly its +/// skip bit, and that is checked too.) +#[test] +#[ignore = "needs the real reads in scratchpad/id5100"] +fn storing_the_radios_own_memories_reproduces_its_tables() { + for (name, image) in images() { + let mut rebuilt = image.clone(); + let mut n = 0; + for slot in (0..CHANNEL_COUNT).filter(|&s| is_used(&image, s)) { + let rec = read_record(&image, slot); + let bank = read_bank(&image, slot); + store(&mut rebuilt, slot, &rec, bank); + let entry = BANK_TABLE + slot * 2; + if image[entry] & 0x80 == 0 { + assert_eq!( + rebuilt[entry..entry + 2], + image[entry..entry + 2], + "{name} memory {slot}: bank entry" + ); + } else { + // A factory-reset entry (`9F FF` and kin): `store` writes the + // configured radio's form — bit 7 clear, index 00 — with the + // same bank and multiplier bits. + assert_eq!(rebuilt[entry], image[entry] & 0x7F, "{name} memory {slot}"); + assert_eq!(rebuilt[entry + 1], 0, "{name} memory {slot}"); + } + n += 1; + if read_skip(&image, slot) != ScanSkip::None { + assert_eq!(read_skip(&rebuilt, slot), ScanSkip::None); + } + } + let skipped: Vec = (0..CHANNEL_COUNT) + .filter(|&s| is_used(&image, s) && read_skip(&image, s) != ScanSkip::None) + .collect(); + let reset_entries = (0..CHANNEL_COUNT) + .filter(|&s| is_used(&image, s) && image[BANK_TABLE + 2 * s] & 0x80 != 0) + .count(); + let differing: usize = (0..IMAGE_LEN).filter(|&i| image[i] != rebuilt[i]).count(); + // Each skipped memory flips one bit in one byte; each factory-reset + // bank entry differs in both of its bytes. + assert!( + differing <= skipped.len() + 2 * reset_entries, + "{name}: {differing} bytes differ, {} skipped, {reset_entries} reset entries", + skipped.len() + ); + println!("{name}: {n} memories re-stored, {differing} bytes differ ({} skipped)", skipped.len()); + } +} + +/// The restore gate against every real file on hand: each read of the radio, +/// and each image the ladder wrote and the radio ACCEPTED, passes; the band +/// probe image the radio REFUSED (and factory reset over) does not. +#[test] +#[ignore = "needs the real reads in scratchpad/id5100"] +fn the_restore_gate_passes_every_accepted_image_and_refuses_the_one_that_reset_the_radio() { + use crate::radios::driver::ImageRestorer; + let mut refused = 0; + for (name, image) in images() { + let verdict = super::DRIVER.check_restore_image(&image); + if name.contains("bandprobe_written") { + assert!(verdict.is_err(), "{name} reset the radio and was let through"); + println!("{name}: refused — {}", verdict.unwrap_err()); + refused += 1; + } else { + assert!(verdict.is_ok(), "{name}: {}", verdict.unwrap_err()); + } + } + assert_eq!(refused, 1, "the probe image is not in the folder"); +} + +/// Print every setting decoded from one real read (`CPM_ID5100_SETTINGS_IMG`) +/// — the form Tim reads his radio's menus against. +#[test] +#[ignore = "needs a real read in CPM_ID5100_SETTINGS_IMG"] +fn dump_decoded_settings() { + let path = std::env::var("CPM_ID5100_SETTINGS_IMG").expect("CPM_ID5100_SETTINGS_IMG"); + let image = std::fs::read(path).unwrap(); + let v = super::settings::decode(&image[..IMAGE_LEN]); + for (k, val) in v.as_object().unwrap() { + println!("{k} = {val}"); + } + if let Ok(out) = std::env::var("CPM_ID5100_SETTINGS_OUT") { + std::fs::write(out, serde_json::to_string(&v).unwrap()).unwrap(); + } +} + +/// Programming a radio with the settings just read off it must change nothing: +/// decode every one of the 291 fields from a real image, apply them back, and +/// compare byte for byte. A field whose decode -> encode is not neutral would +/// quietly rewrite the radio on every program — the factory 0°W longitude did, +/// as 0°E, until this was checked. +#[test] +#[ignore = "needs the real reads in scratchpad/id5100"] +fn applying_the_settings_read_off_a_radio_changes_nothing() { + for (name, image) in images() { + let decoded = super::settings::decode(&image); + let mut again = image.clone(); + let (n, notes) = super::settings::apply(&mut again, &decoded); + let changed: Vec = (0..IMAGE_LEN) + .filter(|&i| image[i] != again[i]) + .map(|i| format!("{i:#x}:{:02x}->{:02x}", image[i], again[i])) + .collect(); + assert!(changed.is_empty(), "{name}: applying its own settings changed {changed:?} (notes {notes:?})"); + println!("{name}: {n} settings re-applied, 0 bytes changed, {} notes", notes.len()); + } +} diff --git a/src-tauri/src/radios/icom_id5100/settings.rs b/src-tauri/src/radios/icom_id5100/settings.rs new file mode 100644 index 0000000..9844ed9 --- /dev/null +++ b/src-tauri/src/radios/icom_id5100/settings.rs @@ -0,0 +1,501 @@ +//! ID-5100 non-channel settings: the field table, read out of a clone image +//! and written back into one. +//! +//! Settings live in the same image the cable clones, so there is no separate +//! settings transport: a read is a download, and a write rides out inside the +//! codeplug program's upload (`carries_profile_settings`), exactly like the +//! UV-5R. The byte rules are Icom's — big-endian integers, space-padded text, +//! off = 0 / on = 1 — and the encoder is the ID-52's own +//! ([`decode_settings_with`] / [`apply_settings_with`]); only the table is this +//! radio's. +//! +//! ## Where the table came from +//! +//! GENERATED by `scratchpad/id5100/vm/gen_table.py` from the measurement files +//! beside it. Every address was measured by driving Icom's CS-5100 inside a VM: +//! move one control, save, diff, one byte per control — first a sweep of every +//! row of every settings page for the address, then a cycle of every option of +//! every dropdown for the stored values, each step's label read off a +//! screenshot of the grid. CS-5100's untouched defaults match the radio's own +//! factory-reset image byte for byte across the whole settings block, which is +//! what makes it trustworthy as an instrument; values it could only show, not +//! prove, are spot-checked on the radio's screen before they ship. + +use serde_json::{Map, Value}; + +use crate::radios::driver::{SettingsCapture, SettingsReader}; +use crate::radios::icom_id52::settings::{apply_settings_with, decode_settings_with, SF, SK}; + +use super::protocol; + +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); + if let Some(map) = v.as_object_mut() { + for (key, at) in 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)); + map.insert(format!("{key}-altitude-m"), Value::String(alt)); + } + } + v +} + +/// The three position records — GPS Manual Position, D-PRS Object, D-PRS Item — +/// share one 12-byte layout, each measured by typing 39°45'30"N 105°05'15"W +/// 1234 m into CS-5100 and diffing (textpass, 2026-10-02): +/// +/// | +0 | +1 | +3 | +4 | +5 | +7 | +8 | +/// |---|---|---|---|---|---|---| +/// | lat deg | lat minutes × 1000 (u16 BE) | `N`/`S` | lon deg | lon minutes × 1000 | `E`/`W` | altitude, i32 BE, 0.1 ft; `7FFFFFFF` = unset | +/// +/// `27 B1BC 'N' 69 1482 'W' 00009E26` = 39°45.500'N 105°05.250'W 4048.6 ft. +/// The form carries decimal degrees (negative = S / W) and metres. +pub(crate) const POSITIONS: &[(&str, usize)] = &[ + ("gps-manual-position", 0x29D85), + ("d-prs-object-position", 0x29ED3), + ("d-prs-item-position", 0x29F21), +]; + +const ALT_UNSET: i32 = 0x7FFF_FFFF; + +fn read_position(image: &[u8], at: usize) -> (String, String, String) { + let coord = |o: usize, neg: u8| { + let deg = f64::from(image[at + o]); + let mm = f64::from(u16::from_be_bytes([image[at + o + 1], image[at + o + 2]])); + let v = deg + mm / 60_000.0; + // Zero carries a hemisphere letter on the radio (an unset position is + // 0°W) but has no sign; show it unsigned rather than as "-0.000000". + let v = if image[at + o + 3] == neg && v != 0.0 { -v } else { v }; + format!("{v:.6}") + }; + let alt = i32::from_be_bytes(image[at + 8..at + 12].try_into().unwrap()); + let alt = if alt == ALT_UNSET { String::new() } else { format!("{:.1}", f64::from(alt) * 0.03048) }; + (coord(0, b'S'), coord(4, b'W'), alt) +} + +/// Encode one coordinate: degrees byte, minutes × 1000, hemisphere letter. +fn encode_coord(v: f64, max: f64, pos: u8, neg: u8) -> Option<[u8; 4]> { + if !v.is_finite() || v.abs() > max { + return None; + } + let a = v.abs(); + let mut deg = a.trunc(); + let mut mm = ((a - deg) * 60_000.0).round(); + if mm >= 60_000.0 { + deg += 1.0; + mm = 0.0; + } + let mm = mm as u16; + Some([deg as u8, (mm >> 8) as u8, mm as u8, if v < 0.0 { neg } else { pos }]) +} + +/// 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 { + let mut n = 0; + 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 }; + // Blank is "leave alone", never a value: a profile that never held + // this field must not warn about it, or rewrite it. + if v.as_str().is_some_and(|s| s.trim().is_empty()) { + continue; + } + let parsed = v.as_str().and_then(|s| s.trim().parse::().ok()).or_else(|| v.as_f64()); + match parsed.and_then(|f| encode_coord(f, max, pos, neg)) { + Some(mut bytes) => { + // Zero has no hemisphere of its own: keep the letter the radio + // already holds, or a profile that never touched this field + // would rewrite the factory 0°W as 0°E. + if bytes[..3] == [0, 0, 0] { + bytes[3] = image[at + off + 3]; + } + if image[at + off..at + off + 4] != bytes { + image[at + off..at + off + 4].copy_from_slice(&bytes); + } + n += 1; + } + None => notes.push(format!("{k}: {v} is not a {suffix} the radio can store; left as it was")), + } + } + let k = format!("{key}-altitude-m"); + if let Some(v) = applicable.remove(&k) { + let text = v.as_str().map(str::trim).unwrap_or_default().to_string(); + // Blank leaves the radio's altitude alone — including one it holds + // that the profile never knew about. (The radio's own "unset" reads + // back as blank, so leaving it alone keeps it unset too.) + if text.is_empty() { + continue; + } + let raw = text + .parse::() + .ok() + .filter(|m| m.is_finite() && m.abs() < 100_000.0) + .map(|m| (m / 0.03048).round() as i32); + match raw { + Some(raw) => { + let bytes = raw.to_be_bytes(); + if image[at + 8..at + 12] != bytes { + image[at + 8..at + 12].copy_from_slice(&bytes); + } + n += 1; + } + None => notes.push(format!("{k}: {text:?} is not an altitude in metres; left as it was")), + } + } + } + n +} + +/// Apply a profile's settings to an image about to be uploaded. Returns how +/// 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) { + 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 + .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(); + 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")) + .collect(); + let mut notes: Vec = notes; + let mut applicable: Map = map + .iter() + .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) +} + +/// My Call Sign entries the way CS-5100 stores them (textpass, 2026-10-02): +/// uppercased; anything but A-Z, 0-9 and `/` becomes a space; and a one- +/// character suffix after `-` or a space goes in the 8th column — typing +/// `ab/c-1!` stored `AB/C 1`, the D-STAR call + module/ID layout the app +/// already uses for repeaters (`util::pack_dstar_call`). The 4-character note +/// is filtered the same way (`x.y?` stored `X Y `). +fn normalize_call_signs(applicable: &mut Map) { + let clean = |s: &str| -> String { + s.chars() + .map(|c| c.to_ascii_uppercase()) + .map(|c| if c.is_ascii_alphanumeric() || c == '/' || c == '-' || c == ' ' { c } else { ' ' }) + .collect() + }; + for (k, v) in applicable.iter_mut() { + if !k.starts_with("my-station-my-call-sign-m") || k.ends_with("-in-use") { + continue; + } + let Some(s) = v.as_str() else { continue }; + let out = if k.ends_with("-note") { + clean(s).replace('-', " ") + } else { + let c = clean(s); + let c = c.trim(); + match c.rsplit_once(['-', ' ']) { + Some((call, sfx)) if sfx.chars().count() == 1 && !call.trim().is_empty() => { + format!("{:<7.7}{}", call.trim().replace('-', " "), sfx) + } + _ => c.replace('-', " "), + } + }; + *v = Value::String(out); + } +} + +/// 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)] = &[ + ( + "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, + ), +]; + +fn enforce_limits(image: &[u8], applicable: &mut Map, notes: &mut Vec) { + let current = decode(image); + for (name, keys, min, max) in LIMITS { + if !keys.iter().any(|k| applicable.contains_key(*k)) { + continue; + } + let ticked = keys + .iter() + .filter(|k| { + applicable + .get(**k) + .or_else(|| current.get(**k)) + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .count(); + if ticked < *min || ticked > *max { + for k in *keys { + applicable.remove(*k); + } + notes.push(format!( + "{name}: the profile ticks {ticked}, and the ID-5100 allows {min}-{max}. \ + Left as the radio has them — the radio rejects a clone carrying a \ + combination it does not allow, and resets itself." + )); + } + } +} + +impl SettingsReader for super::IcomId5100 { + /// One clone read: decoded for the form, and kept whole as the backup. + fn read_settings(&self, port: &str, _schema_json: &str) -> Result { + // `reopen` waits out the radio's deaf seconds after a previous read. + let mut p = super::reopen(port)?; + let image = protocol::download(&mut *p)?; + Ok(SettingsCapture { + settings: decode(&image), + backup: image, + backup_ext: "img", + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// No two fields claim the same byte — a sweep that misread one row would + /// show up here as a collision. + #[test] + fn no_two_settings_share_a_byte() { + let mut seen = std::collections::HashMap::new(); + for f in ID5100_SETTINGS_FIELDS { + let len = match &f.kind { + 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, + }; + for b in f.byte as usize..f.byte as usize + len { + if let Some(other) = seen.insert(b, f.key) { + panic!("{} and {} both claim {b:#x}", f.key, other); + } + } + } + } + + /// The profile form and the encoder are generated together; this holds them + /// together. A form field with no table entry silently writes nothing, and + /// a table entry with no form field is a setting nobody can reach. + #[test] + fn the_schema_and_the_table_agree_field_for_field() { + let schema: Vec = + serde_json::from_str(crate::seed::ID5100_SETTINGS_SCHEMA).expect("schema parses"); + let is_position = |k: &str| POSITIONS.iter().any(|(p, _)| k.starts_with(p)); + let form: Vec<&Value> = schema + .iter() + .filter(|f| f["type"] != "section" && !is_position(f["key"].as_str().unwrap())) + .collect(); + assert_eq!(form.len(), ID5100_SETTINGS_FIELDS.len()); + // Every position key the codec handles has a form field, and no more. + let pos_form: Vec<&str> = schema + .iter() + .map(|f| f["key"].as_str().unwrap()) + .filter(|k| is_position(k)) + .collect(); + assert_eq!(pos_form.len(), POSITIONS.len() * 3); + for f in &form { + let key = f["key"].as_str().unwrap(); + let t = ID5100_SETTINGS_FIELDS + .iter() + .find(|t| t.key == key) + .unwrap_or_else(|| panic!("form field {key} has no table entry")); + match (&t.kind, f["type"].as_str().unwrap()) { + (SK::Bool | SK::BoolBit { .. }, "boolean") => {} + (SK::Uint { width }, "integer") => { + let max = f["max"].as_u64().expect("an integer field has a max"); + assert!(max < 1u64 << (8 * u32::from(*width)), "{key}: max {max} does not fit {width} byte(s)"); + } + (SK::IntEnum { labels, .. }, "select") => { + let opts: Vec<&str> = + f["options"].as_array().unwrap().iter().map(|o| o.as_str().unwrap()).collect(); + let table: Vec<&str> = labels.iter().map(|(_, l)| *l).collect(); + assert_eq!(opts, table, "{key}: form options differ from the table's labels"); + } + (SK::Text { len } | SK::TextFill { len, .. }, "text") => { + assert_eq!(f["max_length"].as_u64(), Some(u64::from(*len)), "{key}: max_length"); + } + (SK::Enum { labels, .. }, "select") => { + let opts: Vec<&str> = + f["options"].as_array().unwrap().iter().map(|o| o.as_str().unwrap()).collect(); + let table: Vec<&str> = labels.iter().map(|(_, l)| *l).collect(); + assert_eq!(opts, table, "{key}: form options differ from the table's labels"); + } + (_, ty) => panic!("{key}: table kind and form type {ty} do not pair"), + } + } + } + + /// The measured input reproduces byte for byte in all three records, and + /// reads back as what was typed. + #[test] + fn positions_encode_like_cs5100() { + let mut image = vec![0u8; super::super::layout::IMAGE_LEN]; + let mut s = Map::new(); + for (k, _) in POSITIONS { + s.insert(format!("{k}-latitude"), Value::from("39.758333")); + s.insert(format!("{k}-longitude"), Value::from("-105.0875")); + s.insert(format!("{k}-altitude-m"), Value::from("1234")); + } + let (n, notes) = apply(&mut image, &Value::Object(s)); + assert_eq!((n, notes.len()), (9, 0), "{notes:?}"); + for (k, at) in POSITIONS { + assert_eq!( + &image[*at..*at + 12], + &[0x27, 0xB1, 0xBC, b'N', 0x69, 0x14, 0x82, b'W', 0x00, 0x00, 0x9E, 0x26], + "{k}" + ); + let d = decode(&image); + assert_eq!(d[format!("{k}-latitude")], "39.758333"); + assert_eq!(d[format!("{k}-longitude")], "-105.087500"); + } + // The radio's unset position — 0°N 0°W — reads back unsigned and + // round-trips without its W being rewritten as E. + let mut blank = vec![0u8; super::super::layout::IMAGE_LEN]; + blank[0x29D85..0x29D91].copy_from_slice(&[0, 0, 0, b'N', 0, 0, 0, b'W', 0x7F, 0xFF, 0xFF, 0xFF]); + let d = decode(&blank); + assert_eq!(d["gps-manual-position-longitude"], "0.000000"); + let before = blank.clone(); + apply(&mut blank, &d); + assert_eq!(blank, before, "an untouched decode must write back nothing"); + // Blank means "leave alone" for all three, with no note: a real + // altitude survives, and so does a coordinate. + let mut s = Map::new(); + for f in ["latitude", "longitude", "altitude-m"] { + s.insert(format!("d-prs-item-position-{f}"), Value::from("")); + } + let before = image.clone(); + let (n, notes) = apply(&mut image, &Value::Object(s)); + assert_eq!((n, notes.len()), (0, 0), "{notes:?}"); + assert_eq!(image, before); + } + + /// The two measured CS-5100 inputs reproduce byte for byte. + #[test] + fn my_call_signs_are_stored_the_way_cs5100_stores_them() { + let mut image = vec![b' '; super::super::layout::IMAGE_LEN]; + let mut s = Map::new(); + s.insert("my-station-my-call-sign-m02".into(), Value::from("ab/c-1!")); + s.insert("my-station-my-call-sign-m02-note".into(), Value::from("x.y?")); + s.insert("my-station-my-call-sign-m01".into(), Value::from("WW8L")); + let (n, notes) = apply(&mut image, &Value::Object(s)); + assert_eq!((n, notes.len()), (3, 0)); + assert_eq!(&image[0x22168..0x22174], b"AB/C 1X Y "); + assert_eq!(&image[0x2215C..0x22164], b"WW8L "); + } + + /// A fifth NMEA sentence is refused as a group; four is written. + #[test] + fn nmea_sentences_are_capped_at_four() { + let mut image = vec![0u8; super::super::layout::IMAGE_LEN]; + image[0x29ACF] = 1; // GGA, the factory default + let mut s = Map::new(); + for k in ["rmc", "gll", "gsa", "vtg"] { + s.insert(format!("gps-tx-nmea-sentence-{k}"), Value::Bool(true)); + } + let (_, notes) = apply(&mut image, &Value::Object(s.clone())); + assert_eq!(notes.len(), 1, "{notes:?}"); + assert_eq!(&image[0x29ACE..0x29AD4], &[0, 1, 0, 0, 0, 0], "nothing written"); + s.insert("gps-tx-nmea-sentence-gga".into(), Value::Bool(false)); + let (_, notes) = apply(&mut image, &Value::Object(s)); + assert!(notes.is_empty(), "{notes:?}"); + assert_eq!(&image[0x29ACE..0x29AD4], &[1, 0, 1, 1, 1, 0]); + } + + /// The logger needs at least one sentence. + #[test] + fn logger_needs_at_least_one_sentence() { + let mut image = vec![0u8; super::super::layout::IMAGE_LEN]; + image[0x29AAD] = 0xF0; + let mut s = Map::new(); + for k in ["rmc", "gga", "vtg", "gsa"] { + s.insert(format!("gps-logger-record-sentence-{k}"), Value::Bool(false)); + } + let (_, notes) = apply(&mut image, &Value::Object(s)); + assert_eq!(notes.len(), 1); + assert_eq!(image[0x29AAD], 0xF0); + } + + /// Object and item names pad with 0xFF, not spaces — what CS-5100 wrote + /// when "AB" was typed over a 9-character name: 41 42 FF FF FF FF FF FF FF. + #[test] + fn object_names_pad_with_ff_like_the_radio() { + let mut image = vec![0u8; super::super::layout::IMAGE_LEN]; + image[0x29E9C..0x29EA5].copy_from_slice(b"OBJABCDEF"); + let mut s = Map::new(); + s.insert("d-prs-object-object-name".into(), Value::from("AB")); + let (n, notes) = apply(&mut image, &Value::Object(s)); + assert_eq!((n, notes.len()), (1, 0)); + assert_eq!(&image[0x29E9C..0x29EA5], &[0x41, 0x42, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]); + assert_eq!(decode(&image)["d-prs-object-object-name"], "AB"); + } + + /// Every schema entry, section headings included, has its own key — the + /// form renders by key, and the Display/Sounds headings once collided. + #[test] + fn schema_keys_are_unique() { + let schema: Vec = serde_json::from_str(crate::seed::ID5100_SETTINGS_SCHEMA).unwrap(); + let mut seen = std::collections::HashSet::new(); + for f in &schema { + let k = f["key"].as_str().unwrap(); + assert!(seen.insert(k), "duplicate schema key {k}"); + } + } + + /// Every field sits inside the image and clear of the memory area and its + /// tables, which the codeplug build owns. (Not "above VOLATILE_BASE": My + /// Call Sign lives at 0x2215C.) + #[test] + fn every_field_is_in_the_settings_area() { + let memory_end = super::super::layout::BANK_NAMES + + super::super::layout::BANK_COUNT * super::super::layout::BANK_NAME_LEN; + for f in ID5100_SETTINGS_FIELDS { + let at = f.byte as usize; + assert!(at >= memory_end, "{} at {at:#x} is inside the memory area", f.key); + assert!(at < super::super::layout::IMAGE_LEN, "{} at {at:#x}", f.key); + } + } +} diff --git a/src-tauri/src/radios/icom_id52/memory.rs b/src-tauri/src/radios/icom_id52/memory.rs index bfa29ae..ab54ceb 100644 --- a/src-tauri/src/radios/icom_id52/memory.rs +++ b/src-tauri/src/radios/icom_id52/memory.rs @@ -153,7 +153,7 @@ const ANALOG_TAIL: [u8; 3] = [0xE4, 0xFF, 0xFF]; /// CTCSS tones in tenths of a hertz, indexed as the radio stores them. The same /// 50-entry table the settings menu uses: Repeater Tone `254.1` is index 49 and /// TSQL `67.0` is index 0. -const TONES_DHZ: [u16; 50] = [ +pub(crate) const TONES_DHZ: [u16; 50] = [ 670, 693, 719, 744, 770, 797, 825, 854, 885, 915, 948, 974, 1000, 1035, 1072, 1109, 1148, 1188, 1230, 1273, 1318, 1365, 1413, 1462, 1514, 1567, 1598, 1622, 1655, 1679, 1713, 1738, 1773, 1799, 1835, 1862, 1899, 1928, 1966, 1995, 2035, 2065, 2107, 2181, 2257, 2291, 2336, 2418, 2503, 2541, @@ -161,7 +161,7 @@ const TONES_DHZ: [u16; 50] = [ /// DTCS codes as the radio indexes them. Written in octal the way the front /// panel shows them, which is also how the channel database stores them. -const DTCS_CODES: [u16; 104] = [ +pub(crate) const DTCS_CODES: [u16; 104] = [ 23, 25, 26, 31, 32, 36, 43, 47, 51, 53, 54, 65, 71, 72, 73, 74, 114, 115, 116, 122, 125, 131, 132, 134, 143, 145, 152, 155, 156, 162, 165, 172, 174, 205, 212, 223, 225, 226, 243, 244, 245, 246, 251, 252, 255, 261, 263, 265, 266, 271, 274, 306, 311, 315, 325, 331, 332, 343, 346, 351, @@ -546,13 +546,13 @@ fn raster(freq_hz: u32) -> u8 { } } -fn hz(mhz: f64) -> u32 { +pub(crate) fn hz(mhz: f64) -> u32 { (mhz * 1_000_000.0).round().clamp(0.0, u32::MAX as f64) as u32 } /// Nearest tone in the radio's table. A channel carrying a tone this radio does /// not have is better off on the closest one it does than on a wild index. -fn tone_index(hz: f64) -> u8 { +pub(crate) fn tone_index(hz: f64) -> u8 { let want = (hz * 10.0).round().clamp(0.0, u16::MAX as f64) as u16; TONES_DHZ .iter() @@ -581,7 +581,7 @@ fn dtcs_polarity_index(polarity: &str) -> u8 { /// The stored DTCS code's index, or the radio's default `023` when the channel /// has none — every analog record carries a code whether or not it uses one. -fn dtcs_index(code: Option<&str>) -> u8 { +pub(crate) fn dtcs_index(code: Option<&str>) -> u8 { let Some(want) = code.and_then(|c| c.trim().parse::().ok()) else { return 0; }; @@ -596,7 +596,7 @@ fn dtcs_index(code: Option<&str>) -> u8 { /// Icom's call-sign packing: 8 characters at 7 bits each, most significant /// first, into 7 bytes. The port letter sits in the 8th column, which is why the /// field is padded rather than trimmed. -fn pack_call(call: &str) -> [u8; 7] { +pub(crate) fn pack_call(call: &str) -> [u8; 7] { let mut bits: u64 = 0; let mut chars = call.chars(); for _ in 0..8 { @@ -611,7 +611,7 @@ fn pack_call(call: &str) -> [u8; 7] { /// Write an ASCII field, space-padded to the field's width and truncated to it. /// Names reach here already cut to the radio's limit; this is the backstop that /// keeps a stray character out of the next field. -fn ascii_field(field: &mut [u8], text: &str) { +pub(crate) fn ascii_field(field: &mut [u8], text: &str) { field.fill(b' '); for (slot, c) in field.iter_mut().zip(text.chars()) { *slot = if c.is_ascii_graphic() || c == ' ' { diff --git a/src-tauri/src/radios/icom_id52/memory_csv.rs b/src-tauri/src/radios/icom_id52/memory_csv.rs index ca685d0..fc67a36 100644 --- a/src-tauri/src/radios/icom_id52/memory_csv.rs +++ b/src-tauri/src/radios/icom_id52/memory_csv.rs @@ -101,7 +101,7 @@ const HEADER: [&str; 20] = [ /// and `8.33kHz` for airband AM, which is that band's real channel spacing. /// (Both third-party ID-52 converters write `12.5kHz` instead; the radio's own /// export says otherwise, and the radio wins.) -pub(super) fn tune_step(ec: &ExpandedChannel) -> &'static str { +pub(crate) fn tune_step(ec: &ExpandedChannel) -> &'static str { let f = ec.channel.rx_freq; if mode_of(ec) == "AM" && (108.0..137.0).contains(&f) { "8.33kHz" @@ -286,7 +286,7 @@ pub(crate) fn render_csv( /// split — which this radio has no separate encoding for — comes out as a shift /// of the real difference. That is how the radio behaves anyway, and it is what /// the FT5D writer does with the same case. -pub(super) fn duplex_and_offset(ec: &ExpandedChannel) -> (&'static str, f64) { +pub(crate) fn duplex_and_offset(ec: &ExpandedChannel) -> (&'static str, f64) { let c = &ec.channel; let tx = match c.tx_freq { Some(tx) if (tx - c.rx_freq).abs() > 1e-9 => tx, @@ -334,7 +334,7 @@ fn is_dv(ec: &ExpandedChannel) -> bool { /// `Mode`. The ID-52 spells narrow FM `FM-N`; everything it cannot do is /// programmed as plain FM, which is what a receive-only memory needs anyway. -pub(super) fn mode_of(ec: &ExpandedChannel) -> &'static str { +pub(crate) fn mode_of(ec: &ExpandedChannel) -> &'static str { if is_dv(ec) { return "DV"; } @@ -348,7 +348,7 @@ pub(super) fn mode_of(ec: &ExpandedChannel) -> &'static str { /// What one memory's squelch columns say: the `TONE` column and the three /// values it selects between. -pub(super) struct ToneColumns { +pub(crate) struct ToneColumns { /// The radio's own spelling of the squelch type (Advanced Manual pp. 15-8/ /// 15-9). Also the key the `.icf` encoder maps to a squelch byte. pub tone: &'static str, @@ -384,7 +384,7 @@ pub(super) struct ToneColumns { /// receive-only DTCS type), the mapping keeps whatever the TRANSMIT side needs: /// a channel that does not key its repeater is the failure that matters, and a /// squelch that is tighter than asked for is audible the moment it happens. -pub(super) fn tone_columns(ec: &ExpandedChannel) -> ToneColumns { +pub(crate) fn tone_columns(ec: &ExpandedChannel) -> ToneColumns { let c = &ec.channel; let up = c.ctcss_uplink; let down = c.ctcss_downlink; @@ -464,7 +464,7 @@ pub(super) fn dtcs_polarity(stored: &str) -> &'static str { /// band convention, nor a hotspot whose RPT1 is its owner's call — but it stays, /// because the channel library's D-STAR repeaters come from RepeaterBook, which /// carries a call sign and no module letter. -pub(super) fn call_signs(ec: &ExpandedChannel, dv: bool) -> (String, String, String) { +pub(crate) fn call_signs(ec: &ExpandedChannel, dv: bool) -> (String, String, String) { let stored = |f: &Option| { f.as_deref() .map(str::trim) diff --git a/src-tauri/src/radios/icom_id52/mod.rs b/src-tauri/src/radios/icom_id52/mod.rs index e386bb8..703d257 100644 --- a/src-tauri/src/radios/icom_id52/mod.rs +++ b/src-tauri/src/radios/icom_id52/mod.rs @@ -192,6 +192,8 @@ mod tests { write_callsign_db: false, export: true, diagnostics: false, + after_write: None, + settings_start_blank: false, } ); } diff --git a/src-tauri/src/radios/icom_id52/settings.rs b/src-tauri/src/radios/icom_id52/settings.rs index 19b90f5..323d408 100644 --- a/src-tauri/src/radios/icom_id52/settings.rs +++ b/src-tauri/src/radios/icom_id52/settings.rs @@ -93,6 +93,10 @@ pub(crate) enum SK { }, /// Fixed-length ASCII, space-padded. Text { len: u32 }, + /// Fixed-length ASCII padded with `fill` instead of spaces. The ID-5100's + /// D-PRS object and item names are `0xFF`-padded: typing "AB" over a + /// 9-character name left `41 42 FF FF FF FF FF FF FF` (#49). + TextFill { len: u32, fill: u8 }, } include!("id52_settings_table.rs"); @@ -156,6 +160,12 @@ fn decode_field(image: &[u8], f: &SF) -> Value { .to_string(), ) } + SK::TextFill { len, fill } => { + let end = (at + *len as usize).min(image.len()); + let raw = &image[at..end]; + let cut = raw.iter().position(|b| b == fill).unwrap_or(raw.len()); + Value::String(String::from_utf8_lossy(&raw[..cut]).trim_end().to_string()) + } } } @@ -163,8 +173,15 @@ fn decode_field(image: &[u8], f: &SF) -> Value { /// profile form. Unknown or out-of-range enum values come back as their raw /// number rather than a wrong label. pub(crate) fn decode_settings(image: &[u8]) -> Value { + decode_settings_with(ID52_SETTINGS_FIELDS, image) +} + +/// [`decode_settings`] against any Icom field table. The encoding rules here +/// (big-endian integers, space-padded text, off = 0 / on = 1) are Icom's, not +/// the ID-52's alone: the ID-5100 (#49) uses this with its own table. +pub(crate) fn decode_settings_with(fields: &[SF], image: &[u8]) -> Value { let mut out = Map::new(); - for f in ID52_SETTINGS_FIELDS { + for f in fields { if field_end(f) > image.len() { continue; } @@ -183,7 +200,7 @@ fn field_end(f: &SF) -> usize { | SK::Enum { width, .. } | SK::IntEnum { width, .. } => *width as usize, SK::Bool | SK::BoolBit { .. } => 1, - SK::Text { len } => *len as usize, + SK::Text { len } | SK::TextFill { len, .. } => *len as usize, } } @@ -192,8 +209,17 @@ fn field_end(f: &SF) -> usize { /// shape, are skipped rather than guessed at — a profile saved against an older /// schema must not corrupt the image. pub(crate) fn apply_settings(image: &mut [u8], settings: &Map) -> usize { + apply_settings_with(ID52_SETTINGS_FIELDS, image, settings) +} + +/// [`apply_settings`] against any Icom field table — see [`decode_settings_with`]. +pub(crate) fn apply_settings_with( + fields: &[SF], + image: &mut [u8], + settings: &Map, +) -> usize { let mut written = 0; - for f in ID52_SETTINGS_FIELDS { + for f in fields { let Some(v) = settings.get(f.key) else { continue; }; @@ -272,6 +298,17 @@ pub(crate) fn apply_settings(image: &mut [u8], settings: &Map) -> } None => false, }, + SK::TextFill { len, fill } => match v.as_str() { + Some(s) => { + write_text(image, at, *len as usize, s); + // Pad with the field's own fill, not the spaces write_text + // used, and keep a trailing space the operator typed. + let n = s.chars().count().min(*len as usize); + image[at + n..at + *len as usize].fill(*fill); + true + } + None => false, + }, }; if ok { written += 1; @@ -718,7 +755,7 @@ mod tests { .unwrap_or(label); (Value::from(label), Value::from(first)) } - SK::Text { len } => { + SK::Text { len } | SK::TextFill { len, .. } => { if *len == 0 { return None; } @@ -901,61 +938,16 @@ mod tests { labels, }, SK::Text { len } => SK::Text { len: *len }, + SK::TextFill { len, fill } => SK::TextFill { len: *len, fill: *fill }, }, }]; apply_table(image, &table, settings); } /// `apply_settings` against an arbitrary table rather than the generated - /// one. Kept in the test module so production code has exactly one entry - /// point. + /// one — now just the production [`apply_settings_with`], which exists + /// because the ID-5100 needed the same encoder with its own table. fn apply_table(image: &mut [u8], table: &[SF], settings: &Map) { - for f in table { - let Some(v) = settings.get(f.key) else { - continue; - }; - if decode_field(image, f) == *v { - continue; - } - let at = f.byte as usize; - match &f.kind { - SK::Uint { width } => { - if let Some(n) = v.as_u64() { - write_uint(image, at, *width, n as u32) - } - } - SK::Int { width } => { - if let Some(n) = v.as_i64() { - write_int(image, at, *width, n) - } - } - SK::Bool => { - if let Some(b) = v.as_bool() { - image[at] = b as u8 - } - } - SK::BoolBit { shift } => { - if let Some(b) = v.as_bool() { - let mask = 1u8 << shift; - image[at] = (image[at] & !mask) | if b { mask } else { 0 }; - } - } - SK::Enum { width, labels } => { - if let Some(raw) = raw_for(labels, v) { - write_uint(image, at, *width, raw) - } - } - SK::IntEnum { width, labels } => { - if let Some(raw) = signed_raw_for(labels, v) { - write_int(image, at, *width, raw) - } - } - SK::Text { len } => { - if let Some(s) = v.as_str() { - write_text(image, at, *len as usize, s) - } - } - } - } + apply_settings_with(table, image, settings); } } diff --git a/src-tauri/src/radios/kenwood_thd72/mod.rs b/src-tauri/src/radios/kenwood_thd72/mod.rs index b0f5ee6..9cfed6a 100644 --- a/src-tauri/src/radios/kenwood_thd72/mod.rs +++ b/src-tauri/src/radios/kenwood_thd72/mod.rs @@ -342,6 +342,7 @@ impl ImageProgrammer for KenwoodThd72 { channels: program::decode_sample(&built), zones_written: 0, zones_cleared: 0, + banks_written: 0, scan_lists_written: 0, scan_lists_cleared: 0, contacts_written: 0, diff --git a/src-tauri/src/radios/kenwood_thd75/mod.rs b/src-tauri/src/radios/kenwood_thd75/mod.rs index 2c4c09d..e87a848 100644 --- a/src-tauri/src/radios/kenwood_thd75/mod.rs +++ b/src-tauri/src/radios/kenwood_thd75/mod.rs @@ -128,6 +128,8 @@ mod tests { write_callsign_db: false, export: true, diagnostics: false, + after_write: None, + settings_start_blank: false, } ); } diff --git a/src-tauri/src/radios/mod.rs b/src-tauri/src/radios/mod.rs index c80d420..4c1df82 100644 --- a/src-tauri/src/radios/mod.rs +++ b/src-tauri/src/radios/mod.rs @@ -32,6 +32,7 @@ pub(crate) mod driver; #[cfg(test)] pub(crate) mod fake_port; pub(crate) mod icom_id52; +pub(crate) mod icom_id5100; pub(crate) mod kenwood_thd72; pub(crate) mod kenwood_thd75; pub(crate) mod kenwood_tmd710; diff --git a/src-tauri/src/radios/registry.rs b/src-tauri/src/radios/registry.rs index dd55c35..da987a6 100644 --- a/src-tauri/src/radios/registry.rs +++ b/src-tauri/src/radios/registry.rs @@ -23,7 +23,7 @@ use crate::models::RadioModel; /// Every driver compiled into the app. Order is not significant — lookups are /// by `key()`, which is unique. (A static array rather than a slice literal: /// references to statics aren't const-promotable inside a returned temporary.) -static DRIVERS: [&dyn RadioDriver; 9] = [ +static DRIVERS: [&dyn RadioDriver; 10] = [ &super::baofeng_uv5r::DRIVER, &super::binteradio_bt9000::DRIVER, &super::tidradio_tdh3::DRIVER, @@ -33,6 +33,7 @@ static DRIVERS: [&dyn RadioDriver; 9] = [ &super::kenwood_thd75::DRIVER, &super::kenwood_thd72::DRIVER, &super::kenwood_tmd710::DRIVER, + &super::icom_id5100::DRIVER, ]; pub(crate) fn all_drivers() -> &'static [&'static dyn RadioDriver] { @@ -143,6 +144,12 @@ mod tests { // every exposed range was measured there rather than read off // the published sheet that was wrong about five of them. "kenwood_tmd710" => (true, true), + // ID-5100 (#49): reads, but no standalone write. Its settings + // live in the clone image, so they ride out inside the codeplug + // program (`carries_profile_settings`) exactly like the UV-5R — + // a separate settings write would be the same full clone and + // the same press-POWER restart. + "icom_id5100" => (true, false), _ => (true, true), }; assert_eq!( @@ -205,7 +212,11 @@ mod tests { for d in all_drivers() { let expect_image = matches!( d.key(), - "baofeng_uv5r" | "tidradio_tdh3" | "kenwood_thd72" | "binteradio_bt9000" + "baofeng_uv5r" + | "tidradio_tdh3" + | "kenwood_thd72" + | "binteradio_bt9000" + | "icom_id5100" ); assert_eq!( d.as_image_programmer().is_some(), diff --git a/src-tauri/src/radios/tidradio_tdh3/mod.rs b/src-tauri/src/radios/tidradio_tdh3/mod.rs index 63353fb..fc6e761 100644 --- a/src-tauri/src/radios/tidradio_tdh3/mod.rs +++ b/src-tauri/src/radios/tidradio_tdh3/mod.rs @@ -328,6 +328,7 @@ impl ImageProgrammer for TidradioTdh3 { channels: channels.into_iter().map(decoded_to_sample).collect(), zones_written: 0, zones_cleared: 0, + banks_written: 0, scan_lists_written: 0, scan_lists_cleared: 0, contacts_written: 0, diff --git a/src-tauri/src/radios/wiring.rs b/src-tauri/src/radios/wiring.rs index 2f1c63b..3be513e 100644 --- a/src-tauri/src/radios/wiring.rs +++ b/src-tauri/src/radios/wiring.rs @@ -351,10 +351,21 @@ fn the_profile_form_calls_a_card_radio_by_how_it_is_programmed() { decl.trim() ); - // And the flag has to still be the thing that suppresses the defaults. + // And the flag has to still reach the thing that suppresses the defaults. + // Since #49 it travels inside `startBlank`, which ORs in the driver's own + // `settings_start_blank` for a cable radio in the same position. + let start_blank = editor + .lines() + .find(|l| l.trim_start().starts_with("const startBlank")) + .expect("no `const startBlank` in ProfileEditor.tsx"); + assert!( + start_blank.contains("isCardRadio") && start_blank.contains("settings_start_blank"), + "startBlank must combine isCardRadio with the driver's settings_start_blank: `{}`", + start_blank.trim() + ); assert!( - editor.contains("!isCardRadio"), - "ProfileEditor.tsx no longer passes !isCardRadio as seedValues' `defaults` argument — the \ + editor.contains("!startBlank"), + "ProfileEditor.tsx no longer passes !startBlank as seedValues' `defaults` argument — the \ flag this test guards is not reaching the seeding decision." ); } diff --git a/src-tauri/src/radios/yaesu_ft5d/mod.rs b/src-tauri/src/radios/yaesu_ft5d/mod.rs index 72a5353..6bf20a9 100644 --- a/src-tauri/src/radios/yaesu_ft5d/mod.rs +++ b/src-tauri/src/radios/yaesu_ft5d/mod.rs @@ -287,6 +287,8 @@ mod tests { write_callsign_db: false, export: true, diagnostics: false, + after_write: None, + settings_start_blank: false, } ); } diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index 8b290e1..435f86e 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -199,6 +199,17 @@ pub const BT9000_SETTINGS_SCHEMA: &str = /// undetermined meanings, so they are deliberately absent rather than guessed. pub const TMD710_SETTINGS_SCHEMA: &str = include_str!("tmd710_settings_schema.json"); +/// GENERATED with the Rust field table (`radios/icom_id5100/id5100_settings_table.rs`) +/// by `scratchpad/id5100/vm/gen_table.py`, from CS-5100 measurements: every +/// address moved by one control at a time, every option cycled and its label +/// 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"); + + +#[cfg(test)] +pub(crate) fn model_rx_bands(model: &str) -> Option<&'static str> { + models().into_iter().find(|m| m.model == model)?.rx_bands +} #[cfg(test)] pub(crate) fn model_capability_rows() -> Vec<(&'static str, bool, &'static str)> { @@ -1040,6 +1051,61 @@ fn models() -> Vec { connection_type: "Serial cable (COM port, rear of the operation panel)", non_channel_settings_schema: TMD710_SETTINGS_SCHEMA, }, + // -------------------------------------------------------- + // 9. Icom ID-5100 — D-STAR + analog FM dual-band MOBILE, 1000 + // memories in 26 named banks (A-Z), 16-char names, 50 W, + // built-in GPS and D-PRS. The A model (USA). Programmed over + // the data cable by Icom's clone protocol (issue #49): the + // whole image is read, patched, and written back. See + // radios/icom_id5100/. + // NOTES: + // (a) tx_bands / rx_bands are the manual's USA specification + // (TX 144-148 / 430-450, RX 118-174 / 375-550). NOT yet + // measured — the hardware ladder's band probe does that. The + // 174-375 gap is real: the receiver has no coverage there, + // and a channel inside it would be a silently empty memory. + // (b) aprs_capable is TRUE for D-PRS, the radio's APRS over + // D-STAR, and the schema carries the D-PRS menus (Position, + // Object, Item, Weather) — which + // `an_aprs_radio_offers_at_least_one_aprs_setting` requires. + // (c) banks are named and a memory is in at most one, so a + // channel in two lists lands in the first list's bank. + // -------------------------------------------------------- + ModelSeed { + manufacturer: "Icom", + model: "ID-5100", + driver_key: Some("icom_id5100"), + programming_ui: Some("generic"), + display_name: "Icom ID-5100", + 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("[[118.0,174.0],[375.0,550.0]]"), + memory_channels: 1000, + 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: "chirp_csv", + connection_type: "Data cable (OPC-2218LU or equivalent)", + non_channel_settings_schema: ID5100_SETTINGS_SCHEMA, + }, ] } @@ -1268,6 +1334,27 @@ mod tests { } } + /// The ID-5100's receiver stops at 174 and resumes at 375 (manual, USA + /// specification). A 220 MHz repeater or a 300 MHz milair channel in a + /// codeplug must be excluded here, or it becomes a memory the radio + /// cannot tune — which the ID-52 showed is silently empty, not an error. + #[test] + fn the_id5100_receiver_gap_is_modelled() { + let m = models() + .into_iter() + .find(|m| m.model == "ID-5100") + .expect("the ID-5100 is seeded"); + let rx: Vec> = + serde_json::from_str(m.rx_bands.expect("ID-5100 has rx_bands")).unwrap(); + let hears = |f: f64| rx.iter().any(|b| f >= b[0] && f <= b[1]); + for f in [224.94, 250.0, 300.0, 374.99, 108.0, 117.99, 550.01, 902.0] { + assert!(!hears(f), "{f} MHz is outside the ID-5100A's coverage"); + } + for f in [118.4, 146.94, 162.55, 380.0, 446.8125, 462.5625, 549.99] { + assert!(hears(f), "{f} MHz is inside the ID-5100A's coverage"); + } + } + /// The TH-D75 is the other half of that lesson: the same 220 MHz repeaters /// the ID-52 threw away are a band this radio TRANSMITS on, so seeding it /// too conservatively would drop channels the operator can legally work. diff --git a/src/components/codeplugs/ProgramRadioDialog.tsx b/src/components/codeplugs/ProgramRadioDialog.tsx index 51dbd35..291bd4b 100644 --- a/src/components/codeplugs/ProgramRadioDialog.tsx +++ b/src/components/codeplugs/ProgramRadioDialog.tsx @@ -481,6 +481,12 @@ export function ProgramRadioDialog({ Write to radio. )} + {caps?.after_write && ( + <> + {" "} + {caps.after_write} + + )} )} @@ -637,8 +643,17 @@ export function ProgramRadioDialog({ {busy === "program" && (
- Backing up → writing channels → verifying… keep the radio on and - the cable connected. + + Backing up → writing channels → verifying… keep the radio on and + the cable connected. + {caps?.after_write && {caps.after_write}} + +
+ )} + {busy === "restore" && caps?.after_write && ( +
+ + {caps.after_write}
)} @@ -707,6 +722,9 @@ export function ProgramRadioDialog({ // command layer and read by nothing on this screen. (program.zones_written > 0 ? ` · ${program.zones_written} zone${program.zones_written === 1 ? "" : "s"}` + : "") + + (program.banks_written > 0 + ? ` · ${program.banks_written} bank${program.banks_written === 1 ? "" : "s"}` : "") } note={program.note ?? undefined} diff --git a/src/components/profiles/ProfileEditor.tsx b/src/components/profiles/ProfileEditor.tsx index 339b348..9a2bcb2 100644 --- a/src/components/profiles/ProfileEditor.tsx +++ b/src/components/profiles/ProfileEditor.tsx @@ -885,6 +885,12 @@ export function ProfileEditor({ : undefined; // What this model's driver can actually do — gates the read-from-radio bar. const caps = useDriverCapabilities(model?.driver_key ?? null); + // The card radios' rule, for a cable radio in the same position: its program + // writes every value the form holds into the radio's own image, so a field + // the operator never set must stay blank rather than become a schema default + // that then overwrites their radio (the ID-5100, #49). Declared by the + // driver; never a per-model branch here. + const startBlank = isCardRadio || caps?.settings_start_blank === true; // Form state, re-seeded whenever a different profile is selected. const [name, setName] = useState(profile.display_name); @@ -901,6 +907,10 @@ export function ProfileEditor({ // `write_radio_settings` sends the SAVED row, not the form. const [saved, setSaved] = useState({}); const [lastId, setLastId] = useState(null); + // Whether the current seed invented defaults. The driver's capabilities load + // asynchronously the first time a model is opened, so a form can be seeded + // before `startBlank` is known; see the re-seed below. + const [seededDefaults, setSeededDefaults] = useState(false); if (profile.id !== lastId) { setName(profile.display_name); setNotes(profile.notes ?? ""); @@ -910,14 +920,24 @@ export function ProfileEditor({ const seeded = seedValues( fields, parseSettings(profile.non_channel_settings), - !isCardRadio, + !startBlank, ); setValues(seeded); setBaseline(seeded); setSaved(seeded); + setSeededDefaults(!startBlank); setLastId(profile.id); setTab("settings"); setSubTab(null); + } else if (seededDefaults && startBlank && values === baseline) { + // The capability arrived after the form was seeded with defaults, and the + // operator has not typed into it yet (`values` is still the very object the + // seed produced): re-seed blank. An edited form is left exactly as it is. + const seeded = seedValues(fields, parseSettings(profile.non_channel_settings), false); + setValues(seeded); + setBaseline(seeded); + setSaved(seeded); + setSeededDefaults(false); } const setValue = (key: string, v: string | number | boolean) => diff --git a/src/lib/types.ts b/src/lib/types.ts index d0ce1ab..ee23679 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -191,6 +191,13 @@ export interface DriverCapabilities { write_callsign_db: boolean; export: boolean; diagnostics: boolean; + /// What the operator must do at the radio after an upload (the ID-5100 asks + /// for its POWER button). The driver's read-back waits on it. + after_write: string | null; + /// A new profile's form must start BLANK, not seeded with schema defaults: + /// the program writes every value it holds into the radio's own image (the + /// ID-5100). The card radios' rule, declared by a cable driver. + settings_start_blank: boolean; } // One field definition inside non_channel_settings_schema (JSON). @@ -566,6 +573,8 @@ export interface CodeplugProgramReport { settings_written: number | null; zones_written: number; zones_cleared: number; + // Named banks written (the ID-5100's A-Z); 0 on radios without them. + banks_written: number; scan_lists_written: number; scan_lists_cleared: number; contacts_written: number;