diff --git a/README.md b/README.md index ebd4383..9a7eadf 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ cable, or its microSD card — or exported as CSV for tools that expect it. | **TIDRADIO TD-H3** | Analog FM/NFM/AM | VHF / UHF / 1.25 m | Direct USB — read, write, settings | 200 channels, tri-band | | **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 | +| **Yaesu FTM-300DR** | C4FM (System Fusion) + Analog | 2 m / 70 cm TX, 108–999 MHz RX | microSD — patches the radio's own backup file | 999 memories; memories and 147 menu settings, including the APRS and SmartBeaconing setup | | **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 | @@ -68,7 +69,6 @@ settings together, then verify on the actual radio before shipping. | **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) | | **Yaesu FT-891** | HF / 6 m mobile | [#53](https://github.com/ww8l/codeplug-magic/issues/53) | -| **Yaesu FTM-300** | C4FM + Analog mobile | [#48](https://github.com/ww8l/codeplug-magic/issues/48) | | **Yaesu FTM-400** | C4FM + Analog mobile | [#52](https://github.com/ww8l/codeplug-magic/issues/52) | Adding a radio takes hardware to verify against, so the order these land in follows what's on the diff --git a/src-tauri/migrations/0020_codeplug_memory_group.sql b/src-tauri/migrations/0020_codeplug_memory_group.sql new file mode 100644 index 0000000..1130cf3 --- /dev/null +++ b/src-tauri/migrations/0020_codeplug_memory_group.sql @@ -0,0 +1,9 @@ +-- #48: the channels a codeplug puts in the radio's one user memory group (the +-- Yaesu FTM-300's M-GRP), chosen channel by channel on the codeplug page. Kept +-- per codeplug, like the per-channel scan-list assignment (0009), because the +-- same channel can be in M-GRP in one codeplug and not in another. +CREATE TABLE codeplug_memory_group ( + codeplug_id INTEGER NOT NULL REFERENCES codeplugs(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + PRIMARY KEY (codeplug_id, channel_id) +); diff --git a/src-tauri/src/commands/codeplugs.rs b/src-tauri/src/commands/codeplugs.rs index 01b4ca2..e08d21e 100644 --- a/src-tauri/src/commands/codeplugs.rs +++ b/src-tauri/src/commands/codeplugs.rs @@ -301,3 +301,56 @@ pub async fn clear_channel_scan_list( .estr()?; Ok(()) } + +// ---- Memory group (the FTM-300's M-GRP, #48) ---- +// One user group a channel is either in or out of, chosen per codeplug. + +/// The codeplug's memory-group channels that are still IN the codeplug. A row +/// for a channel whose list was later removed stays stored (re-adding the list +/// brings it back) but is not reported here, so the page's count, the checklist +/// and the export all agree on one set. +pub(crate) async fn codeplug_memory_group( + pool: &sqlx::SqlitePool, + codeplug_id: i64, +) -> Result, String> { + sqlx::query_scalar( + "SELECT mg.channel_id FROM codeplug_memory_group mg + WHERE mg.codeplug_id = ?1 + AND mg.channel_id IN ( + SELECT cle.channel_id FROM codeplug_channel_lists ccl + JOIN channel_list_entries cle ON cle.channel_list_id = ccl.channel_list_id + WHERE ccl.codeplug_id = ?1)", + ) + .bind(codeplug_id) + .fetch_all(pool) + .await + .estr() +} + +#[tauri::command] +pub async fn get_codeplug_memory_group( + state: State<'_, AppState>, + codeplug_id: i64, +) -> Result, String> { + codeplug_memory_group(&state.pool, codeplug_id).await +} + +/// Put a batch of channels in (or take them out of) the codeplug's memory group. +#[tauri::command] +pub async fn set_codeplug_memory_group( + state: State<'_, AppState>, + codeplug_id: i64, + channel_ids: Vec, + member: bool, +) -> Result<(), String> { + let mut tx = state.pool.begin().await.estr()?; + for channel_id in channel_ids { + let sql = if member { + "INSERT OR IGNORE INTO codeplug_memory_group (codeplug_id, channel_id) VALUES (?1, ?2)" + } else { + "DELETE FROM codeplug_memory_group WHERE codeplug_id = ?1 AND channel_id = ?2" + }; + sqlx::query(sql).bind(codeplug_id).bind(channel_id).execute(&mut *tx).await.estr()?; + } + tx.commit().await.estr() +} diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index e4c8647..4d69abb 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -1076,6 +1076,20 @@ pub async fn generate_codeplug( (values, _) => values, }; + // The codeplug's memory group (the FTM-300's M-GRP), only for the formats + // that write one. Read before the exporter is borrowed for the export: the + // registry hands out a non-Send reference that cannot cross an await. + let wants_memory_group = model + .export_format + .as_deref() + .and_then(crate::radios::registry::exporter_for_format) + .is_some_and(|e| e.has_memory_group()); + let memory_group: Vec = if wants_memory_group { + crate::commands::codeplugs::codeplug_memory_group(&state.pool, codeplug_id).await? + } else { + Vec::new() + }; + // Format-keyed registry lookup (Chunk 3.8): a model picks its exporter by // `export_format`, never by name and never by driver — an export-only model // has no driver but still names a format. Unclaimed formats (and NULL) get @@ -1101,6 +1115,7 @@ pub async fn generate_codeplug( groups: &groups, model: &model, profile_settings: profile_settings.as_deref(), + memory_group: &memory_group, }; exporter.export(&target, &req)?; target diff --git a/src-tauri/src/commands/program.rs b/src-tauri/src/commands/program.rs index 9426e79..aa796a4 100644 --- a/src-tauri/src/commands/program.rs +++ b/src-tauri/src/commands/program.rs @@ -519,6 +519,7 @@ pub async fn find_memory_cards(format: String) -> Vec { "yaesu_ft5d_sd" => ft5d_cards(), "icom_id52_icf" => id52_cards(), "kenwood_thd75_sd" => thd75_cards(), + "yaesu_ftm300_sd" => ftm300_cards(), _ => Vec::new(), }) .await @@ -548,6 +549,27 @@ fn ft5d_cards() -> Vec { out } +/// The FTM-300, like the FT5D, writes its ALL backup to one fixed path — the +/// path READ FROM SD → ALL loads back — so there is nothing to navigate to. +fn ftm300_cards() -> Vec { + let mut out = Vec::new(); + for root in mounted_roots() { + let path = root.join("FTM300D").join("BACKUP").join("CLNFTM300D.dat"); + let Ok(image) = std::fs::read(&path) else { + continue; + }; + if crate::radios::yaesu_ftm300::sd_image::validate_backup(&image).is_err() { + continue; + } + out.push(MemoryCard { + has_original: path.with_extension("dat.orig").exists(), + volume: volume_name(&root), + path: path.to_string_lossy().into_owned(), + }); + } + out +} + /// The ID-52 keeps its settings files in one fixed folder but names them /// itself, so what gets offered is the **folder**, not a file: the write creates /// a new `SetYYYYMMDD_NN.icf` in it rather than overwriting anything the @@ -658,6 +680,30 @@ pub async fn read_ft5d_settings_from_backup(path: String) -> Result Result { + tauri::async_runtime::spawn_blocking(move || { + let image = std::fs::read(&path).map_err(|e| { + format!( + "Could not read {path}: {e}. Pick FTM300D/BACKUP/CLNFTM300D.dat from the \ + radio's microSD card." + ) + })?; + crate::radios::yaesu_ftm300::sd_image::validate_backup(&image)?; + let settings = crate::radios::yaesu_ftm300::settings::decode_settings(&image); + let count = settings.as_object().map(|o| o.len()).unwrap_or(0); + Ok(RadioSettingsRead { + settings, + count, + backup_path: path, + }) + }) + .await + .estr()? +} + /// The ID-52's version of [`read_ft5d_settings_from_backup`], reading the `.icf` /// the radio writes with `SET > SD Card > Save Setting`. /// diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 93f7333..ac080de 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -60,7 +60,8 @@ 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, Kenwood TM-D710, Icom ID-5100 and TYT MD-380. (0015 + // the Binteradio BT-9000, Kenwood TM-D710, Icom ID-5100, TYT MD-380 and Yaesu + // FTM-300DR. (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 @@ -70,9 +71,9 @@ mod tests { .await .unwrap(); assert_eq!( - count.0, 11, + count.0, 12, "expected the UV-5R, TD-H3, AT-D890UV, FT5D, ID-52, TH-D75, TH-D72, BT-9000, \ - TM-D710, ID-5100 and MD-380 seeded models" + TM-D710, ID-5100, MD-380 and FTM-300DR seeded models" ); let models: Vec<(String,)> = @@ -84,8 +85,8 @@ mod tests { assert_eq!( names, vec![ - "AT-D890UV", "BT-9000", "FT5D", "ID-5100", "ID-52", "MD-380", "TD-H3", "TH-D72", - "TH-D75", "TM-D710", "UV-5R" + "AT-D890UV", "BT-9000", "FT5D", "FTM-300DR", "ID-5100", "ID-52", "MD-380", "TD-H3", + "TH-D72", "TH-D75", "TM-D710", "UV-5R" ] ); @@ -95,7 +96,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count2.0, 11, "seeding should be idempotent"); + assert_eq!(count2.0, 12, "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/ftm300_settings_schema.json b/src-tauri/src/ftm300_settings_schema.json new file mode 100644 index 0000000..baf9940 --- /dev/null +++ b/src-tauri/src/ftm300_settings_schema.json @@ -0,0 +1,2735 @@ +[ + { + "key": "section-config", + "label": "Config", + "type": "section", + "group": "General" + }, + { + "key": "auto-power-off", + "label": "Auto Power Off", + "type": "select", + "options": [ + "Off", + "30 min", + "1.0 hour", + "1.5 hour", + "2.0 hour", + "2.5 hour", + "3.0 hour", + "3.5 hour", + "4.0 hour", + "4.5 hour", + "5.0 hour", + "5.5 hour", + "6.0 hour", + "6.5 hour", + "7.0 hour", + "7.5 hour", + "8.0 hour", + "8.5 hour", + "9.0 hour", + "9.5 hour", + "10.0 hour", + "10.5 hour", + "11.0 hour", + "11.5 hour", + "12.0 hour" + ] + }, + { + "key": "unit", + "label": "Unit", + "type": "select", + "options": [ + "Metric", + "Inch" + ] + }, + { + "key": "time-format", + "label": "Time Format", + "type": "select", + "options": [ + "24 Hour", + "12 Hour" + ] + }, + { + "key": "date-format", + "label": "Date Format", + "type": "select", + "options": [ + "yyyy/mmm/dd", + "yyyy/dd/mmm", + "mmm/dd/yyyy", + "dd/mmm/yyyy" + ] + }, + { + "key": "time-out-timer", + "label": "Time-Out Timer", + "group": "Transmit & Receive", + "type": "select", + "options": [ + "Off", + "1 minute", + "2 minutes", + "3 minutes", + "5 minutes", + "10 minutes", + "15 minutes", + "20 minutes", + "30 minutes" + ] + }, + { + "key": "band-a-auto-repeater-shift", + "label": "Band A Auto Repeater Shift", + "group": "Transmit & Receive", + "type": "boolean" + }, + { + "key": "band-b-auto-repeater-shift", + "label": "Band B Auto Repeater Shift", + "group": "Transmit & Receive", + "type": "boolean" + }, + { + "key": "band-a-rx-coverage", + "label": "Band A RX Coverage", + "group": "Transmit & Receive", + "type": "select", + "options": [ + "Normal", + "Wide" + ] + }, + { + "key": "band-b-rx-coverage", + "label": "Band B RX Coverage", + "group": "Transmit & Receive", + "type": "select", + "options": [ + "Normal", + "Wide" + ] + }, + { + "key": "gps-datum", + "label": "GPS Datum", + "group": "GPS", + "type": "select", + "options": [ + "WGS-84", + "Tokyo Mean" + ] + }, + { + "key": "gps-log", + "label": "GPS Log", + "group": "GPS", + "type": "select", + "options": [ + "Off", + "1 second", + "2 seconds", + "5 seconds", + "10 seconds", + "30 seconds", + "60 seconds" + ] + }, + { + "key": "time-zone", + "label": "Time Zone", + "type": "select", + "options": [ + "-14:00", + "-13:30", + "-13:00", + "-12:30", + "-12:00", + "-11:30", + "-11:00", + "-10:30", + "-10:00", + "-9:30", + "-9:00", + "-8:30", + "-8:00", + "-7:30", + "-7:00", + "-6:30", + "-6:00", + "-5:30", + "-5:00", + "-4:30", + "-4:00", + "-3:30", + "-3:00", + "-2:30", + "-2:00", + "-1:30", + "-1:00", + "-0:30", + "0:00", + "+0:30", + "+1:00", + "+1:30", + "+2:00", + "+2:30", + "+3:00", + "+3:30", + "+4:00", + "+4:30", + "+5:00", + "+5:30", + "+6:00", + "+6:30", + "+7:00", + "+7:30", + "+8:00", + "+8:30", + "+9:00", + "+9:30", + "+10:00", + "+10:30", + "+11:00", + "+11:30", + "+12:00", + "+12:30", + "+13:00", + "+13:30", + "+14:00" + ] + }, + { + "key": "beep", + "label": "Beep", + "group": "Sounds", + "type": "boolean" + }, + { + "key": "beep-level", + "label": "Beep Level", + "group": "Sounds", + "type": "select", + "options": [ + "Low", + "High" + ] + }, + { + "key": "callsign", + "label": "Callsign", + "type": "text", + "max_length": 10 + }, + { + "key": "section-display", + "label": "Display", + "type": "section", + "group": "Display" + }, + { + "key": "band-scope", + "label": "Band Scope", + "type": "select", + "options": [ + "Wide", + "Narrow" + ] + }, + { + "key": "compass", + "label": "Compass", + "type": "select", + "options": [ + "North Up", + "Heading Up" + ] + }, + { + "key": "lcd-brightness", + "label": "LCD Brightness", + "type": "select", + "options": [ + "Min", + "Mid", + "Max" + ] + }, + { + "key": "display-mode", + "label": "Display Mode", + "type": "select", + "options": [ + "Backtrack", + "Altitude", + "Timer/Clock", + "GPS Info" + ] + }, + { + "key": "memory-list", + "label": "Memory List", + "group": "Memory & VFO", + "type": "select", + "options": [ + "Off", + "On" + ] + }, + { + "key": "section-scan-resume", + "label": "Scan Resume", + "type": "section", + "group": "Scan" + }, + { + "key": "band-a-scan-resume", + "label": "Band A Scan Resume", + "type": "select", + "options": [ + "Busy", + "Hold", + "1.0 seconds", + "3.0 seconds", + "5.0 seconds" + ] + }, + { + "key": "band-b-scan-resume", + "label": "Band B Scan Resume", + "type": "select", + "options": [ + "Busy", + "Hold", + "1.0 seconds", + "3.0 seconds", + "5.0 seconds" + ] + }, + { + "key": "section-mic-keys", + "label": "Mic Keys", + "type": "section", + "group": "Keys" + }, + { + "key": "mic-key-p2", + "label": "Mic Key P2", + "type": "select", + "options": [ + "Off", + "Band Scope", + "Scan", + "Home", + "Rpt Shift", + "Reverse", + "Tx Power", + "Sql Off", + "T-Call", + "Voice", + "D_X", + "Wx", + "S-List", + "MSG", + "Reply", + "M-Edit" + ] + }, + { + "key": "mic-key-p3", + "label": "Mic Key P3", + "type": "select", + "options": [ + "Off", + "Band Scope", + "Scan", + "Home", + "Rpt Shift", + "Reverse", + "Tx Power", + "Sql Off", + "T-Call", + "Voice", + "D_X", + "Wx", + "S-List", + "MSG", + "Reply", + "M-Edit" + ] + }, + { + "key": "mic-key-p4", + "label": "Mic Key P4", + "type": "select", + "options": [ + "Off", + "Band Scope", + "Scan", + "Home", + "Rpt Shift", + "Reverse", + "Tx Power", + "Sql Off", + "T-Call", + "Voice", + "D_X", + "Wx", + "S-List", + "MSG", + "Reply", + "M-Edit" + ] + }, + { + "key": "section-bluetooth", + "label": "Bluetooth", + "type": "section", + "group": "Connections" + }, + { + "key": "bluetooth-save", + "label": "Bluetooth Save", + "type": "boolean" + }, + { + "key": "section-signaling", + "label": "Signaling", + "type": "section", + "group": "Transmit & Receive" + }, + { + "key": "band-a-squelch-level", + "label": "Band A Squelch Level", + "type": "select", + "options": [ + "Level 0", + "Level 1", + "Level 2", + "Level 3", + "Level 4", + "Level 5", + "Level 6", + "Level 7", + "Level 8" + ] + }, + { + "key": "band-b-squelch-level", + "label": "Band B Squelch Level", + "type": "select", + "options": [ + "Level 0", + "Level 1", + "Level 2", + "Level 3", + "Level 4", + "Level 5", + "Level 6", + "Level 7", + "Level 8" + ] + }, + { + "key": "band-a-sql-expansion", + "label": "Band A SQL Expansion", + "group": "DTMF & Signaling", + "type": "boolean" + }, + { + "key": "band-b-sql-expansion", + "label": "Band B SQL Expansion", + "group": "DTMF & Signaling", + "type": "boolean" + }, + { + "key": "band-a-bell-ringer", + "label": "Band A Bell Ringer", + "group": "DTMF & Signaling", + "type": "select", + "options": [ + "Off", + "1 time", + "3 times", + "5 times", + "8 times", + "Continuous" + ] + }, + { + "key": "band-b-bell-ringer", + "label": "Band B Bell Ringer", + "group": "DTMF & Signaling", + "type": "select", + "options": [ + "Off", + "1 time", + "3 times", + "5 times", + "8 times", + "Continuous" + ] + }, + { + "key": "band-a-weather-alert", + "label": "Band A Weather Alert", + "group": "Scan", + "type": "boolean" + }, + { + "key": "band-b-weather-alert", + "label": "Band B Weather Alert", + "group": "Scan", + "type": "boolean" + }, + { + "key": "band-a-weather-channel", + "label": "Band A Weather Channel", + "group": "Scan", + "type": "select", + "options": [ + "162.550 MHz", + "162.400 MHz", + "162.475 MHz", + "162.425 MHz", + "162.450 MHz", + "162.500 MHz", + "162.525 MHz", + "161.650 MHz", + "161.775 MHz", + "163.275 MHz" + ] + }, + { + "key": "band-b-weather-channel", + "label": "Band B Weather Channel", + "group": "Scan", + "type": "select", + "options": [ + "162.550 MHz", + "162.400 MHz", + "162.475 MHz", + "162.425 MHz", + "162.450 MHz", + "162.500 MHz", + "162.525 MHz", + "161.650 MHz", + "161.775 MHz", + "163.275 MHz" + ] + }, + { + "key": "pager-receive-code-1", + "label": "Pager Receive Code 1", + "group": "DTMF & Signaling", + "type": "select", + "options": [ + "01 - 67.0 Hz", + "02 - 69.3 Hz", + "03 - 71.9 Hz", + "04 - 74.4 Hz", + "05 - 77.0 Hz", + "06 - 79.7 Hz", + "07 - 82.5 Hz", + "08 - 85.4 Hz", + "09 - 88.5 Hz", + "10 - 91.5 Hz", + "11 - 94.8 Hz", + "12 - 97.4 Hz", + "13 - 100.0 Hz", + "14 - 103.5 Hz", + "15 - 107.2 Hz", + "16 - 110.9 Hz", + "17 - 114.8 Hz", + "18 - 118.8 Hz", + "19 - 123.0 Hz", + "20 - 127.3 Hz", + "21 - 131.8 Hz", + "22 - 136.5 Hz", + "23 - 141.3 Hz", + "24 - 146.2 Hz", + "25 - 151.4 Hz", + "26 - 156.7 Hz", + "27 - 159.8 Hz", + "28 - 162.2 Hz", + "29 - 165.5 Hz", + "30 - 167.9 Hz", + "31 - 171.3 Hz", + "32 - 173.8 Hz", + "33 - 177.3 Hz", + "34 - 179.9 Hz", + "35 - 183.5 Hz", + "36 - 186.2 Hz", + "37 - 189.9 Hz", + "38 - 192.8 Hz", + "39 - 196.6 Hz", + "40 - 199.5 Hz", + "41 - 203.5 Hz", + "42 - 206.5 Hz", + "43 - 210.7 Hz", + "44 - 218.1 Hz", + "45 - 225.7 Hz", + "46 - 229.1 Hz", + "47 - 233.6 Hz", + "48 - 241.8 Hz", + "49 - 250.3 Hz", + "50 - 254.1 Hz" + ] + }, + { + "key": "pager-receive-code-2", + "label": "Pager Receive Code 2", + "group": "DTMF & Signaling", + "type": "select", + "options": [ + "01 - 67.0 Hz", + "02 - 69.3 Hz", + "03 - 71.9 Hz", + "04 - 74.4 Hz", + "05 - 77.0 Hz", + "06 - 79.7 Hz", + "07 - 82.5 Hz", + "08 - 85.4 Hz", + "09 - 88.5 Hz", + "10 - 91.5 Hz", + "11 - 94.8 Hz", + "12 - 97.4 Hz", + "13 - 100.0 Hz", + "14 - 103.5 Hz", + "15 - 107.2 Hz", + "16 - 110.9 Hz", + "17 - 114.8 Hz", + "18 - 118.8 Hz", + "19 - 123.0 Hz", + "20 - 127.3 Hz", + "21 - 131.8 Hz", + "22 - 136.5 Hz", + "23 - 141.3 Hz", + "24 - 146.2 Hz", + "25 - 151.4 Hz", + "26 - 156.7 Hz", + "27 - 159.8 Hz", + "28 - 162.2 Hz", + "29 - 165.5 Hz", + "30 - 167.9 Hz", + "31 - 171.3 Hz", + "32 - 173.8 Hz", + "33 - 177.3 Hz", + "34 - 179.9 Hz", + "35 - 183.5 Hz", + "36 - 186.2 Hz", + "37 - 189.9 Hz", + "38 - 192.8 Hz", + "39 - 196.6 Hz", + "40 - 199.5 Hz", + "41 - 203.5 Hz", + "42 - 206.5 Hz", + "43 - 210.7 Hz", + "44 - 218.1 Hz", + "45 - 225.7 Hz", + "46 - 229.1 Hz", + "47 - 233.6 Hz", + "48 - 241.8 Hz", + "49 - 250.3 Hz", + "50 - 254.1 Hz" + ] + }, + { + "key": "pager-transmit-code-1", + "label": "Pager Transmit Code 1", + "group": "DTMF & Signaling", + "type": "select", + "options": [ + "01 - 67.0 Hz", + "02 - 69.3 Hz", + "03 - 71.9 Hz", + "04 - 74.4 Hz", + "05 - 77.0 Hz", + "06 - 79.7 Hz", + "07 - 82.5 Hz", + "08 - 85.4 Hz", + "09 - 88.5 Hz", + "10 - 91.5 Hz", + "11 - 94.8 Hz", + "12 - 97.4 Hz", + "13 - 100.0 Hz", + "14 - 103.5 Hz", + "15 - 107.2 Hz", + "16 - 110.9 Hz", + "17 - 114.8 Hz", + "18 - 118.8 Hz", + "19 - 123.0 Hz", + "20 - 127.3 Hz", + "21 - 131.8 Hz", + "22 - 136.5 Hz", + "23 - 141.3 Hz", + "24 - 146.2 Hz", + "25 - 151.4 Hz", + "26 - 156.7 Hz", + "27 - 159.8 Hz", + "28 - 162.2 Hz", + "29 - 165.5 Hz", + "30 - 167.9 Hz", + "31 - 171.3 Hz", + "32 - 173.8 Hz", + "33 - 177.3 Hz", + "34 - 179.9 Hz", + "35 - 183.5 Hz", + "36 - 186.2 Hz", + "37 - 189.9 Hz", + "38 - 192.8 Hz", + "39 - 196.6 Hz", + "40 - 199.5 Hz", + "41 - 203.5 Hz", + "42 - 206.5 Hz", + "43 - 210.7 Hz", + "44 - 218.1 Hz", + "45 - 225.7 Hz", + "46 - 229.1 Hz", + "47 - 233.6 Hz", + "48 - 241.8 Hz", + "49 - 250.3 Hz", + "50 - 254.1 Hz" + ] + }, + { + "key": "pager-transmit-code-2", + "label": "Pager Transmit Code 2", + "group": "DTMF & Signaling", + "type": "select", + "options": [ + "01 - 67.0 Hz", + "02 - 69.3 Hz", + "03 - 71.9 Hz", + "04 - 74.4 Hz", + "05 - 77.0 Hz", + "06 - 79.7 Hz", + "07 - 82.5 Hz", + "08 - 85.4 Hz", + "09 - 88.5 Hz", + "10 - 91.5 Hz", + "11 - 94.8 Hz", + "12 - 97.4 Hz", + "13 - 100.0 Hz", + "14 - 103.5 Hz", + "15 - 107.2 Hz", + "16 - 110.9 Hz", + "17 - 114.8 Hz", + "18 - 118.8 Hz", + "19 - 123.0 Hz", + "20 - 127.3 Hz", + "21 - 131.8 Hz", + "22 - 136.5 Hz", + "23 - 141.3 Hz", + "24 - 146.2 Hz", + "25 - 151.4 Hz", + "26 - 156.7 Hz", + "27 - 159.8 Hz", + "28 - 162.2 Hz", + "29 - 165.5 Hz", + "30 - 167.9 Hz", + "31 - 171.3 Hz", + "32 - 173.8 Hz", + "33 - 177.3 Hz", + "34 - 179.9 Hz", + "35 - 183.5 Hz", + "36 - 186.2 Hz", + "37 - 189.9 Hz", + "38 - 192.8 Hz", + "39 - 196.6 Hz", + "40 - 199.5 Hz", + "41 - 203.5 Hz", + "42 - 206.5 Hz", + "43 - 210.7 Hz", + "44 - 218.1 Hz", + "45 - 225.7 Hz", + "46 - 229.1 Hz", + "47 - 233.6 Hz", + "48 - 241.8 Hz", + "49 - 250.3 Hz", + "50 - 254.1 Hz" + ] + }, + { + "key": "section-tx-rx", + "label": "TX/RX", + "type": "section", + "group": "Digital" + }, + { + "key": "ams-tx-mode", + "label": "AMS TX Mode", + "type": "select", + "options": [ + "Auto", + "Tx FM Fixed", + "Tx DN Fixed" + ] + }, + { + "key": "location-service", + "label": "Location Service", + "type": "boolean" + }, + { + "key": "standby-beep", + "label": "Standby Beep", + "type": "boolean" + }, + { + "key": "digital-vw", + "label": "Digital VW", + "type": "boolean" + }, + { + "key": "digital-popup", + "label": "Digital Popup", + "type": "select", + "options": [ + "Off", + "2 seconds", + "4 seconds", + "6 seconds", + "8 seconds", + "10 seconds", + "20 seconds", + "30 seconds", + "60 seconds", + "Continue" + ] + }, + { + "key": "mic-gain", + "label": "Mic Gain", + "group": "Transmit & Receive", + "type": "select", + "options": [ + "Min", + "Low", + "Normal", + "High", + "Max" + ] + }, + { + "key": "vox", + "label": "VOX", + "group": "Transmit & Receive", + "type": "select", + "options": [ + "Off", + "Low", + "High" + ] + }, + { + "key": "vox-delay", + "label": "VOX Delay", + "group": "Transmit & Receive", + "type": "select", + "options": [ + "0.5s", + "1.0s", + "1.5s", + "2.0s", + "2.5s" + ] + }, + { + "key": "recording-band", + "label": "Recording Band", + "group": "Sounds", + "type": "select", + "options": [ + "A", + "B", + "A+B" + ] + }, + { + "key": "recording-mic", + "label": "Recording Mic", + "group": "Sounds", + "type": "select", + "options": [ + "Off", + "On" + ] + }, + { + "key": "sub-band-mute", + "label": "Sub Band Mute", + "group": "Sounds", + "type": "select", + "options": [ + "Off", + "On" + ] + }, + { + "key": "section-voice-memory", + "label": "Voice Memory", + "type": "section", + "group": "Sounds" + }, + { + "key": "voice-memory-play-rec", + "label": "Voice Memory Play/Rec", + "type": "select", + "options": [ + "Free 5 min", + "Last 30 sec" + ] + }, + { + "key": "voice-announce", + "label": "Voice Announce", + "type": "select", + "options": [ + "Off", + "Manual", + "Auto" + ] + }, + { + "key": "voice-language", + "label": "Voice Language", + "type": "select", + "options": [ + "English", + "Japanese" + ] + }, + { + "key": "voice-volume", + "label": "Voice Volume", + "type": "select", + "options": [ + "Low", + "Mid", + "High" + ] + }, + { + "key": "section-dtmf-internet", + "label": "DTMF / Internet", + "type": "section", + "group": "DTMF & Signaling" + }, + { + "key": "dtmf-auto-dialer", + "label": "DTMF Auto Dialer", + "type": "select", + "options": [ + "Manual", + "Auto" + ] + }, + { + "key": "band-a-dtmf-channel", + "label": "Band A DTMF Channel", + "type": "select", + "options": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "key": "band-b-dtmf-channel", + "label": "Band B DTMF Channel", + "type": "select", + "options": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ] + }, + { + "key": "wires-x-search-setup", + "label": "WIRES-X Search Setup", + "group": "Digital", + "type": "select", + "options": [ + "History", + "Activity" + ] + }, + { + "key": "wires-x-rpt-wires-freq", + "label": "WIRES-X Rpt/Wires Freq", + "group": "Digital", + "type": "select", + "options": [ + "Manual", + "Preset" + ] + }, + { + "key": "wires-x-dg-id", + "label": "WIRES-X DG-ID", + "group": "Digital", + "type": "select", + "options": [ + "Auto", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30", + "31", + "32", + "33", + "34", + "35", + "36", + "37", + "38", + "39", + "40", + "41", + "42", + "43", + "44", + "45", + "46", + "47", + "48", + "49", + "50", + "51", + "52", + "53", + "54", + "55", + "56", + "57", + "58", + "59", + "60", + "61", + "62", + "63", + "64", + "65", + "66", + "67", + "68", + "69", + "70", + "71", + "72", + "73", + "74", + "75", + "76", + "77", + "78", + "79", + "80", + "81", + "82", + "83", + "84", + "85", + "86", + "87", + "88", + "89", + "90", + "91", + "92", + "93", + "94", + "95", + "96", + "97", + "98", + "99" + ] + }, + { + "key": "section-aprs", + "label": "APRS", + "type": "section", + "group": "APRS" + }, + { + "key": "aprs-callsign", + "label": "APRS Callsign", + "type": "text", + "max_length": 6 + }, + { + "key": "aprs-callsign-ssid", + "label": "APRS Callsign SSID", + "type": "select", + "options": [ + "Non", + "-1", + "-2", + "-3", + "-4", + "-5", + "-6", + "-7", + "-8", + "-9", + "-10", + "-11", + "-12", + "-13", + "-14", + "-15" + ] + }, + { + "key": "my-symbol", + "label": "My Symbol", + "type": "select", + "options": [ + "Symbol 1", + "Symbol 2", + "Symbol 3", + "User Symbol" + ] + }, + { + "key": "symbol-1", + "label": "Symbol 1", + "type": "select", + "options": [ + "/#", + "/&", + "/'", + "/-", + "/.", + "/0", + "/:", + "/;", + "/<", + "/>", + "/C", + "/E", + "/I", + "/K", + "/O", + "/P", + "/R", + "/T", + "/U", + "/V", + "/W", + "/X", + "/Y", + "/[", + "/\\", + "/^", + "/_", + "/a", + "/b", + "/f", + "/g", + "/j", + "/k", + "/m", + "/r", + "/s", + "/u", + "/v", + "/y", + "\\#", + "\\&", + "\\-", + "\\.", + "\\0", + "E0", + "I0", + "S0", + "W0", + "\\;", + "\\=", + "\\>", + "\\K", + "\\W", + "\\Y", + "AY", + "IY", + "KY", + "YY", + "\\^", + "\\_", + "\\m", + "\\n", + "\\s", + "\\u", + "\\v", + "\\x" + ] + }, + { + "key": "symbol-2", + "label": "Symbol 2", + "type": "select", + "options": [ + "/#", + "/&", + "/'", + "/-", + "/.", + "/0", + "/:", + "/;", + "/<", + "/>", + "/C", + "/E", + "/I", + "/K", + "/O", + "/P", + "/R", + "/T", + "/U", + "/V", + "/W", + "/X", + "/Y", + "/[", + "/\\", + "/^", + "/_", + "/a", + "/b", + "/f", + "/g", + "/j", + "/k", + "/m", + "/r", + "/s", + "/u", + "/v", + "/y", + "\\#", + "\\&", + "\\-", + "\\.", + "\\0", + "E0", + "I0", + "S0", + "W0", + "\\;", + "\\=", + "\\>", + "\\K", + "\\W", + "\\Y", + "AY", + "IY", + "KY", + "YY", + "\\^", + "\\_", + "\\m", + "\\n", + "\\s", + "\\u", + "\\v", + "\\x" + ] + }, + { + "key": "symbol-3", + "label": "Symbol 3", + "type": "select", + "options": [ + "/#", + "/&", + "/'", + "/-", + "/.", + "/0", + "/:", + "/;", + "/<", + "/>", + "/C", + "/E", + "/I", + "/K", + "/O", + "/P", + "/R", + "/T", + "/U", + "/V", + "/W", + "/X", + "/Y", + "/[", + "/\\", + "/^", + "/_", + "/a", + "/b", + "/f", + "/g", + "/j", + "/k", + "/m", + "/r", + "/s", + "/u", + "/v", + "/y", + "\\#", + "\\&", + "\\-", + "\\.", + "\\0", + "E0", + "I0", + "S0", + "W0", + "\\;", + "\\=", + "\\>", + "\\K", + "\\W", + "\\Y", + "AY", + "IY", + "KY", + "YY", + "\\^", + "\\_", + "\\m", + "\\n", + "\\s", + "\\u", + "\\v", + "\\x" + ] + }, + { + "key": "position-comment", + "label": "Position Comment", + "type": "select", + "options": [ + "Off Duty", + "En Route", + "In Service", + "Returning", + "Committed", + "Special", + "Priority", + "Custom 0", + "Custom 1", + "Custom 2", + "Custom 3", + "Custom 4", + "Custom 5", + "Custom 6", + "EMERGENCY!" + ] + }, + { + "key": "message-popup", + "label": "Message Popup", + "type": "select", + "options": [ + "Off", + "3 seconds", + "5 seconds", + "10 seconds", + "Hold" + ] + }, + { + "key": "tx-message-ringer", + "label": "TX Message Ringer", + "type": "boolean" + }, + { + "key": "rx-message-ringer", + "label": "RX Message Ringer", + "type": "boolean" + }, + { + "key": "aprs-modem", + "label": "APRS Modem", + "type": "boolean" + }, + { + "key": "aprs-speed", + "label": "APRS Speed", + "type": "select", + "options": [ + "1200 bps", + "9600 bps" + ] + }, + { + "key": "aprs-band", + "label": "APRS Band", + "type": "select", + "options": [ + "Main Band", + "Sub Band", + "A-Band Fix", + "B-Band Fix", + "A=Tx / B=Rx", + "A=Rx / B=Tx" + ] + }, + { + "key": "aprs-squelch", + "label": "APRS Squelch", + "type": "select", + "options": [ + "Rx Band", + "Tx/RX Band" + ] + }, + { + "key": "aprs-compass", + "label": "APRS Compass", + "type": "select", + "options": [ + "North Up", + "Heading Up" + ] + }, + { + "key": "aprs-mute", + "label": "APRS Mute", + "type": "select", + "options": [ + "Off", + "On" + ] + }, + { + "key": "tx-squelch", + "label": "TX Squelch", + "type": "boolean" + }, + { + "key": "data-speed", + "label": "Data Speed", + "group": "Connections", + "type": "select", + "options": [ + "1200 bps", + "9600 bps" + ] + }, + { + "key": "data-band", + "label": "Data Band", + "group": "Connections", + "type": "select", + "options": [ + "Main Band", + "Sub Band", + "A-Band Fix", + "B-Band Fix", + "A=Tx / B=Rx", + "A=Rx / B=Tx" + ] + }, + { + "key": "data-squelch", + "label": "Data Squelch", + "group": "Connections", + "type": "select", + "options": [ + "Rx Band", + "Tx/RX Band" + ] + }, + { + "key": "com-port-speed", + "label": "COM Port Speed", + "group": "Connections", + "type": "select", + "options": [ + "4800 bps", + "9600 bps", + "19200 bps", + "38400 bps" + ] + }, + { + "key": "com-port-output", + "label": "COM Port Output", + "group": "Connections", + "type": "select", + "options": [ + "Off (Camera)", + "GPS Out", + "Packet", + "Waypoint" + ] + }, + { + "key": "my-position", + "label": "My Position", + "type": "select", + "options": [ + "GPS", + "Manual" + ] + }, + { + "key": "tx-delay", + "label": "TX Delay", + "type": "select", + "options": [ + "100 ms", + "150 ms", + "200 ms", + "250 ms", + "300 ms", + "400 ms", + "500 ms", + "750 ms", + "1000 ms" + ] + }, + { + "key": "digi-path", + "label": "Digi Path", + "type": "select", + "options": [ + "Off", + "WIDE1-1", + "WIDE1-1, WIDE2-1", + "Path 1", + "Path 2", + "Path 3", + "Path 4", + "Full Path 1", + "Full Path 2" + ] + }, + { + "key": "message-reply", + "label": "Message Reply", + "type": "boolean" + }, + { + "key": "message-reply-ssid", + "label": "Message Reply SSID", + "type": "select", + "options": [ + "Non", + "-1", + "-2", + "-3", + "-4", + "-5", + "-6", + "-7", + "-8", + "-9", + "-10", + "-11", + "-12", + "-13", + "-14", + "-15", + "**" + ] + }, + { + "key": "waypoint-format", + "label": "Waypoint Format", + "group": "Connections", + "type": "select", + "options": [ + "NMEA9", + "NMEA8", + "NMEA7", + "NMEA6" + ] + }, + { + "key": "waypoint-filter", + "label": "Waypoint Filter", + "group": "Connections", + "type": "select", + "options": [ + "ALL", + "Mobile", + "Frequency", + "Object/Item", + "Digipeater", + "VOIP", + "Weather", + "Yaesu", + "Call Ringer", + "Range Ringer" + ] + }, + { + "key": "section-aprs-beacon", + "label": "APRS Beacon", + "type": "section", + "group": "APRS" + }, + { + "key": "tx-beacon-ringer", + "label": "TX Beacon Ringer", + "type": "boolean" + }, + { + "key": "rx-beacon-ringer", + "label": "RX Beacon Ringer", + "type": "boolean" + }, + { + "key": "my-packet-ringer", + "label": "My Packet Ringer", + "type": "boolean" + }, + { + "key": "call-ringer", + "label": "Call Ringer", + "type": "boolean" + }, + { + "key": "message-voice-ringer", + "label": "Message Voice Ringer", + "type": "boolean" + }, + { + "key": "my-packet-popup", + "label": "My Packet Popup", + "type": "boolean" + }, + { + "key": "beacon-interval", + "label": "Beacon Interval", + "type": "select", + "options": [ + "30 seconds", + "1 minute", + "2 minutes", + "3 minutes", + "5 minutes", + "10 minutes", + "15 minutes", + "20 minutes", + "30 minutes", + "60 minutes" + ] + }, + { + "key": "beacon-tx", + "label": "Beacon TX", + "type": "select", + "options": [ + "Off", + "Smart", + "On(Fix)" + ] + }, + { + "key": "smartbeaconing-type", + "label": "SmartBeaconing Type", + "type": "select", + "options": [ + "Off", + "Type 1", + "Type 2", + "Type 3" + ] + }, + { + "key": "beacon-popup", + "label": "Beacon Popup", + "type": "select", + "options": [ + "Off", + "3 seconds", + "5 seconds", + "10 seconds", + "Hold" + ] + }, + { + "key": "filter-mic-e", + "label": "Filter Mic-E", + "type": "boolean" + }, + { + "key": "filter-position", + "label": "Filter Position", + "type": "boolean" + }, + { + "key": "filter-weather", + "label": "Filter Weather", + "type": "boolean" + }, + { + "key": "filter-object", + "label": "Filter Object", + "type": "boolean" + }, + { + "key": "filter-item", + "label": "Filter Item", + "type": "boolean" + }, + { + "key": "filter-status", + "label": "Filter Status", + "type": "boolean" + }, + { + "key": "filter-other", + "label": "Filter Other", + "type": "boolean" + }, + { + "key": "filter-altnet", + "label": "Filter ALTNet", + "type": "boolean" + }, + { + "key": "range-limit", + "label": "Range Limit", + "type": "select", + "options": [ + "Off", + "1 km(mi)", + "2 km(mi)", + "3 km(mi)", + "4 km(mi)", + "5 km(mi)", + "6 km(mi)", + "7 km(mi)", + "8 km(mi)", + "9 km(mi)", + "10 km(mi)", + "20 km(mi)", + "30 km(mi)", + "40 km(mi)", + "50 km(mi)", + "60 km(mi)", + "70 km(mi)", + "80 km(mi)", + "90 km(mi)", + "100 km(mi)", + "110 km(mi)", + "120 km(mi)", + "130 km(mi)", + "140 km(mi)", + "150 km(mi)", + "160 km(mi)", + "170 km(mi)", + "180 km(mi)", + "190 km(mi)", + "200 km(mi)", + "210 km(mi)", + "220 km(mi)", + "230 km(mi)", + "240 km(mi)", + "250 km(mi)", + "260 km(mi)", + "270 km(mi)", + "280 km(mi)", + "290 km(mi)", + "300 km(mi)", + "310 km(mi)", + "320 km(mi)", + "330 km(mi)", + "340 km(mi)", + "350 km(mi)", + "360 km(mi)", + "370 km(mi)", + "380 km(mi)", + "390 km(mi)", + "400 km(mi)", + "410 km(mi)", + "420 km(mi)", + "430 km(mi)", + "440 km(mi)", + "450 km(mi)", + "460 km(mi)", + "470 km(mi)", + "480 km(mi)", + "490 km(mi)", + "500 km(mi)", + "510 km(mi)", + "520 km(mi)", + "530 km(mi)", + "540 km(mi)", + "550 km(mi)", + "560 km(mi)", + "570 km(mi)", + "580 km(mi)", + "590 km(mi)", + "600 km(mi)", + "610 km(mi)", + "620 km(mi)", + "630 km(mi)", + "640 km(mi)", + "650 km(mi)", + "660 km(mi)", + "670 km(mi)", + "680 km(mi)", + "690 km(mi)", + "700 km(mi)", + "710 km(mi)", + "720 km(mi)", + "730 km(mi)", + "740 km(mi)", + "750 km(mi)", + "760 km(mi)", + "770 km(mi)", + "780 km(mi)", + "790 km(mi)", + "800 km(mi)", + "810 km(mi)", + "820 km(mi)", + "830 km(mi)", + "840 km(mi)", + "850 km(mi)", + "860 km(mi)", + "870 km(mi)", + "880 km(mi)", + "890 km(mi)", + "900 km(mi)", + "910 km(mi)", + "920 km(mi)", + "930 km(mi)", + "940 km(mi)", + "950 km(mi)", + "960 km(mi)", + "970 km(mi)", + "980 km(mi)", + "990 km(mi)", + "1000 km(mi)", + "1010 km(mi)", + "1020 km(mi)", + "1030 km(mi)", + "1040 km(mi)", + "1050 km(mi)", + "1060 km(mi)", + "1070 km(mi)", + "1080 km(mi)", + "1090 km(mi)", + "1100 km(mi)", + "1110 km(mi)", + "1120 km(mi)", + "1130 km(mi)", + "1140 km(mi)", + "1150 km(mi)", + "1160 km(mi)", + "1170 km(mi)", + "1180 km(mi)", + "1190 km(mi)", + "1200 km(mi)", + "1210 km(mi)", + "1220 km(mi)", + "1230 km(mi)", + "1240 km(mi)", + "1250 km(mi)", + "1260 km(mi)", + "1270 km(mi)", + "1280 km(mi)", + "1290 km(mi)", + "1300 km(mi)", + "1310 km(mi)", + "1320 km(mi)", + "1330 km(mi)", + "1340 km(mi)", + "1350 km(mi)", + "1360 km(mi)", + "1370 km(mi)", + "1380 km(mi)", + "1390 km(mi)", + "1400 km(mi)", + "1410 km(mi)", + "1420 km(mi)", + "1430 km(mi)", + "1440 km(mi)", + "1450 km(mi)", + "1460 km(mi)", + "1470 km(mi)", + "1480 km(mi)", + "1490 km(mi)", + "1500 km(mi)", + "1510 km(mi)", + "1520 km(mi)", + "1530 km(mi)", + "1540 km(mi)", + "1550 km(mi)", + "1560 km(mi)", + "1570 km(mi)", + "1580 km(mi)", + "1590 km(mi)", + "1600 km(mi)", + "1610 km(mi)", + "1620 km(mi)", + "1630 km(mi)", + "1640 km(mi)", + "1650 km(mi)", + "1660 km(mi)", + "1670 km(mi)", + "1680 km(mi)", + "1690 km(mi)", + "1700 km(mi)", + "1710 km(mi)", + "1720 km(mi)", + "1730 km(mi)", + "1740 km(mi)", + "1750 km(mi)", + "1760 km(mi)", + "1770 km(mi)", + "1780 km(mi)", + "1790 km(mi)", + "1800 km(mi)", + "1810 km(mi)", + "1820 km(mi)", + "1830 km(mi)", + "1840 km(mi)", + "1850 km(mi)", + "1860 km(mi)", + "1870 km(mi)", + "1880 km(mi)", + "1890 km(mi)", + "1900 km(mi)", + "1910 km(mi)", + "1920 km(mi)", + "1930 km(mi)", + "1940 km(mi)", + "1950 km(mi)", + "1960 km(mi)", + "1970 km(mi)", + "1980 km(mi)", + "1990 km(mi)", + "2000 km(mi)", + "2010 km(mi)", + "2020 km(mi)", + "2030 km(mi)", + "2040 km(mi)", + "2050 km(mi)", + "2060 km(mi)", + "2070 km(mi)", + "2080 km(mi)", + "2090 km(mi)", + "2100 km(mi)", + "2110 km(mi)", + "2120 km(mi)", + "2130 km(mi)", + "2140 km(mi)", + "2150 km(mi)", + "2160 km(mi)", + "2170 km(mi)", + "2180 km(mi)", + "2190 km(mi)", + "2200 km(mi)", + "2210 km(mi)", + "2220 km(mi)", + "2230 km(mi)", + "2240 km(mi)", + "2250 km(mi)", + "2260 km(mi)", + "2270 km(mi)", + "2280 km(mi)", + "2290 km(mi)", + "2300 km(mi)", + "2310 km(mi)", + "2320 km(mi)", + "2330 km(mi)", + "2340 km(mi)", + "2350 km(mi)", + "2360 km(mi)", + "2370 km(mi)", + "2380 km(mi)", + "2390 km(mi)", + "2400 km(mi)", + "2410 km(mi)", + "2420 km(mi)", + "2430 km(mi)", + "2440 km(mi)", + "2450 km(mi)", + "2460 km(mi)", + "2470 km(mi)", + "2480 km(mi)", + "2490 km(mi)", + "2500 km(mi)", + "2510 km(mi)", + "2520 km(mi)", + "2530 km(mi)", + "2540 km(mi)", + "2550 km(mi)", + "2560 km(mi)", + "2570 km(mi)", + "2580 km(mi)", + "2590 km(mi)", + "2600 km(mi)", + "2610 km(mi)", + "2620 km(mi)", + "2630 km(mi)", + "2640 km(mi)", + "2650 km(mi)", + "2660 km(mi)", + "2670 km(mi)", + "2680 km(mi)", + "2690 km(mi)", + "2700 km(mi)", + "2710 km(mi)", + "2720 km(mi)", + "2730 km(mi)", + "2740 km(mi)", + "2750 km(mi)", + "2760 km(mi)", + "2770 km(mi)", + "2780 km(mi)", + "2790 km(mi)", + "2800 km(mi)", + "2810 km(mi)", + "2820 km(mi)", + "2830 km(mi)", + "2840 km(mi)", + "2850 km(mi)", + "2860 km(mi)", + "2870 km(mi)", + "2880 km(mi)", + "2890 km(mi)", + "2900 km(mi)", + "2910 km(mi)", + "2920 km(mi)", + "2930 km(mi)", + "2940 km(mi)", + "2950 km(mi)", + "2960 km(mi)", + "2970 km(mi)", + "2980 km(mi)", + "2990 km(mi)", + "3000 km(mi)" + ] + }, + { + "key": "altitude-unit", + "label": "Altitude Unit", + "type": "select", + "options": [ + "meter", + "feet" + ] + }, + { + "key": "distance-unit", + "label": "Distance Unit", + "type": "select", + "options": [ + "km", + "mile" + ] + }, + { + "key": "position-unit", + "label": "Position Unit", + "type": "select", + "options": [ + "DD MM.MM'", + "DD MM'SS''" + ] + }, + { + "key": "speed-unit", + "label": "Speed Unit", + "type": "select", + "options": [ + "km/h", + "mph", + "knot" + ] + }, + { + "key": "temperature-unit", + "label": "Temperature Unit", + "type": "select", + "options": [ + "C", + "F" + ] + }, + { + "key": "rain-unit", + "label": "Rain Unit", + "type": "select", + "options": [ + "mm", + "inch" + ] + }, + { + "key": "wind-unit", + "label": "Wind Unit", + "type": "select", + "options": [ + "m/s", + "mph", + "knot" + ] + }, + { + "key": "barometer-unit", + "label": "Barometer Unit", + "type": "select", + "options": [ + "hPa", + "mb", + "mmHg", + "inHg" + ] + }, + { + "key": "proportional-pathing", + "label": "Proportional Pathing", + "type": "boolean" + }, + { + "key": "decay", + "label": "Decay", + "type": "boolean" + }, + { + "key": "tx-speed-course", + "label": "TX Speed/Course", + "type": "boolean" + }, + { + "key": "tx-altitude", + "label": "TX Altitude", + "type": "boolean" + }, + { + "key": "low-speed", + "label": "Low Speed", + "type": "select", + "options": [ + "1 km/h(mi/h)", + "2 km/h(mi/h)", + "3 km/h(mi/h)", + "4 km/h(mi/h)", + "5 km/h(mi/h)", + "6 km/h(mi/h)", + "7 km/h(mi/h)", + "8 km/h(mi/h)", + "9 km/h(mi/h)", + "10 km/h(mi/h)", + "11 km/h(mi/h)", + "12 km/h(mi/h)", + "13 km/h(mi/h)", + "14 km/h(mi/h)", + "15 km/h(mi/h)", + "16 km/h(mi/h)", + "17 km/h(mi/h)", + "18 km/h(mi/h)", + "19 km/h(mi/h)", + "20 km/h(mi/h)", + "21 km/h(mi/h)", + "22 km/h(mi/h)", + "23 km/h(mi/h)", + "24 km/h(mi/h)", + "25 km/h(mi/h)", + "26 km/h(mi/h)", + "27 km/h(mi/h)", + "28 km/h(mi/h)", + "29 km/h(mi/h)", + "30 km/h(mi/h)", + "31 km/h(mi/h)", + "32 km/h(mi/h)", + "33 km/h(mi/h)", + "34 km/h(mi/h)", + "35 km/h(mi/h)", + "36 km/h(mi/h)", + "37 km/h(mi/h)", + "38 km/h(mi/h)", + "39 km/h(mi/h)", + "40 km/h(mi/h)", + "41 km/h(mi/h)", + "42 km/h(mi/h)", + "43 km/h(mi/h)", + "44 km/h(mi/h)", + "45 km/h(mi/h)", + "46 km/h(mi/h)", + "47 km/h(mi/h)", + "48 km/h(mi/h)", + "49 km/h(mi/h)", + "50 km/h(mi/h)", + "51 km/h(mi/h)", + "52 km/h(mi/h)", + "53 km/h(mi/h)", + "54 km/h(mi/h)", + "55 km/h(mi/h)", + "56 km/h(mi/h)", + "57 km/h(mi/h)", + "58 km/h(mi/h)", + "59 km/h(mi/h)", + "60 km/h(mi/h)", + "61 km/h(mi/h)", + "62 km/h(mi/h)", + "63 km/h(mi/h)", + "64 km/h(mi/h)", + "65 km/h(mi/h)", + "66 km/h(mi/h)", + "67 km/h(mi/h)", + "68 km/h(mi/h)", + "69 km/h(mi/h)", + "70 km/h(mi/h)", + "71 km/h(mi/h)", + "72 km/h(mi/h)", + "73 km/h(mi/h)", + "74 km/h(mi/h)", + "75 km/h(mi/h)", + "76 km/h(mi/h)", + "77 km/h(mi/h)", + "78 km/h(mi/h)", + "79 km/h(mi/h)", + "80 km/h(mi/h)", + "81 km/h(mi/h)", + "82 km/h(mi/h)", + "83 km/h(mi/h)", + "84 km/h(mi/h)", + "85 km/h(mi/h)", + "86 km/h(mi/h)", + "87 km/h(mi/h)", + "88 km/h(mi/h)", + "89 km/h(mi/h)", + "90 km/h(mi/h)", + "91 km/h(mi/h)", + "92 km/h(mi/h)", + "93 km/h(mi/h)", + "94 km/h(mi/h)", + "95 km/h(mi/h)", + "96 km/h(mi/h)", + "97 km/h(mi/h)", + "98 km/h(mi/h)", + "99 km/h(mi/h)" + ] + }, + { + "key": "position-ambiguity", + "label": "Position Ambiguity", + "type": "select", + "options": [ + "Off", + "1 digit", + "2 digits", + "3 digits", + "4 digits" + ] + }, + { + "key": "rate-limit", + "label": "Rate Limit", + "type": "select", + "options": [ + "5 seconds", + "6 seconds", + "7 seconds", + "8 seconds", + "9 seconds", + "10 seconds", + "11 seconds", + "12 seconds", + "13 seconds", + "14 seconds", + "15 seconds", + "16 seconds", + "17 seconds", + "18 seconds", + "19 seconds", + "20 seconds", + "21 seconds", + "22 seconds", + "23 seconds", + "24 seconds", + "25 seconds", + "26 seconds", + "27 seconds", + "28 seconds", + "29 seconds", + "30 seconds", + "31 seconds", + "32 seconds", + "33 seconds", + "34 seconds", + "35 seconds", + "36 seconds", + "37 seconds", + "38 seconds", + "39 seconds", + "40 seconds", + "41 seconds", + "42 seconds", + "43 seconds", + "44 seconds", + "45 seconds", + "46 seconds", + "47 seconds", + "48 seconds", + "49 seconds", + "50 seconds", + "51 seconds", + "52 seconds", + "53 seconds", + "54 seconds", + "55 seconds", + "56 seconds", + "57 seconds", + "58 seconds", + "59 seconds", + "60 seconds", + "61 seconds", + "62 seconds", + "63 seconds", + "64 seconds", + "65 seconds", + "66 seconds", + "67 seconds", + "68 seconds", + "69 seconds", + "70 seconds", + "71 seconds", + "72 seconds", + "73 seconds", + "74 seconds", + "75 seconds", + "76 seconds", + "77 seconds", + "78 seconds", + "79 seconds", + "80 seconds", + "81 seconds", + "82 seconds", + "83 seconds", + "84 seconds", + "85 seconds", + "86 seconds", + "87 seconds", + "88 seconds", + "89 seconds", + "90 seconds", + "91 seconds", + "92 seconds", + "93 seconds", + "94 seconds", + "95 seconds", + "96 seconds", + "97 seconds", + "98 seconds", + "99 seconds", + "100 seconds", + "101 seconds", + "102 seconds", + "103 seconds", + "104 seconds", + "105 seconds", + "106 seconds", + "107 seconds", + "108 seconds", + "109 seconds", + "110 seconds", + "111 seconds", + "112 seconds", + "113 seconds", + "114 seconds", + "115 seconds", + "116 seconds", + "117 seconds", + "118 seconds", + "119 seconds", + "120 seconds", + "121 seconds", + "122 seconds", + "123 seconds", + "124 seconds", + "125 seconds", + "126 seconds", + "127 seconds", + "128 seconds", + "129 seconds", + "130 seconds", + "131 seconds", + "132 seconds", + "133 seconds", + "134 seconds", + "135 seconds", + "136 seconds", + "137 seconds", + "138 seconds", + "139 seconds", + "140 seconds", + "141 seconds", + "142 seconds", + "143 seconds", + "144 seconds", + "145 seconds", + "146 seconds", + "147 seconds", + "148 seconds", + "149 seconds", + "150 seconds", + "151 seconds", + "152 seconds", + "153 seconds", + "154 seconds", + "155 seconds", + "156 seconds", + "157 seconds", + "158 seconds", + "159 seconds", + "160 seconds", + "161 seconds", + "162 seconds", + "163 seconds", + "164 seconds", + "165 seconds", + "166 seconds", + "167 seconds", + "168 seconds", + "169 seconds", + "170 seconds", + "171 seconds", + "172 seconds", + "173 seconds", + "174 seconds", + "175 seconds", + "176 seconds", + "177 seconds", + "178 seconds", + "179 seconds", + "180 seconds" + ] + }, + { + "key": "range-ringer", + "label": "Range Ringer", + "type": "select", + "options": [ + "Off", + "1 km(mi)", + "2 km(mi)", + "3 km(mi)", + "4 km(mi)", + "5 km(mi)", + "6 km(mi)", + "7 km(mi)", + "8 km(mi)", + "9 km(mi)", + "10 km(mi)", + "11 km(mi)", + "12 km(mi)", + "13 km(mi)", + "14 km(mi)", + "15 km(mi)", + "16 km(mi)", + "17 km(mi)", + "18 km(mi)", + "19 km(mi)", + "20 km(mi)", + "21 km(mi)", + "22 km(mi)", + "23 km(mi)", + "24 km(mi)", + "25 km(mi)", + "26 km(mi)", + "27 km(mi)", + "28 km(mi)", + "29 km(mi)", + "30 km(mi)", + "31 km(mi)", + "32 km(mi)", + "33 km(mi)", + "34 km(mi)", + "35 km(mi)", + "36 km(mi)", + "37 km(mi)", + "38 km(mi)", + "39 km(mi)", + "40 km(mi)", + "41 km(mi)", + "42 km(mi)", + "43 km(mi)", + "44 km(mi)", + "45 km(mi)", + "46 km(mi)", + "47 km(mi)", + "48 km(mi)", + "49 km(mi)", + "50 km(mi)", + "51 km(mi)", + "52 km(mi)", + "53 km(mi)", + "54 km(mi)", + "55 km(mi)", + "56 km(mi)", + "57 km(mi)", + "58 km(mi)", + "59 km(mi)", + "60 km(mi)", + "61 km(mi)", + "62 km(mi)", + "63 km(mi)", + "64 km(mi)", + "65 km(mi)", + "66 km(mi)", + "67 km(mi)", + "68 km(mi)", + "69 km(mi)", + "70 km(mi)", + "71 km(mi)", + "72 km(mi)", + "73 km(mi)", + "74 km(mi)", + "75 km(mi)", + "76 km(mi)", + "77 km(mi)", + "78 km(mi)", + "79 km(mi)", + "80 km(mi)", + "81 km(mi)", + "82 km(mi)", + "83 km(mi)", + "84 km(mi)", + "85 km(mi)", + "86 km(mi)", + "87 km(mi)", + "88 km(mi)", + "89 km(mi)", + "90 km(mi)", + "91 km(mi)", + "92 km(mi)", + "93 km(mi)", + "94 km(mi)", + "95 km(mi)", + "96 km(mi)", + "97 km(mi)", + "98 km(mi)", + "99 km(mi)", + "100 km(mi)" + ] + }, + { + "key": "status-text-tx-rate", + "label": "Status Text TX Rate", + "type": "select", + "options": [ + "1/1", + "1/2", + "1/3", + "1/4", + "1/5", + "1/6", + "1/7", + "1/8" + ] + }, + { + "key": "section-smartbeaconing", + "label": "SmartBeaconing", + "type": "section", + "group": "APRS" + }, + { + "key": "smartbeaconing-type-1-low-speed", + "label": "SmartBeaconing Type 1 Low Speed", + "type": "integer", + "min": 2, + "max": 30 + }, + { + "key": "smartbeaconing-type-1-high-speed", + "label": "SmartBeaconing Type 1 High Speed", + "type": "integer", + "min": 3, + "max": 90 + }, + { + "key": "smartbeaconing-type-1-slow-rate", + "label": "SmartBeaconing Type 1 Slow Rate", + "type": "integer", + "min": 1, + "max": 100 + }, + { + "key": "smartbeaconing-type-1-fast-rate", + "label": "SmartBeaconing Type 1 Fast Rate", + "type": "integer", + "min": 10, + "max": 180 + }, + { + "key": "smartbeaconing-type-1-turn-angle", + "label": "SmartBeaconing Type 1 Turn Angle", + "type": "integer", + "min": 5, + "max": 90 + }, + { + "key": "smartbeaconing-type-1-turn-slope", + "label": "SmartBeaconing Type 1 Turn Slope", + "type": "integer", + "min": 1, + "max": 255 + }, + { + "key": "smartbeaconing-type-1-turn-time", + "label": "SmartBeaconing Type 1 Turn Time", + "type": "integer", + "min": 5, + "max": 180 + }, + { + "key": "smartbeaconing-type-2-low-speed", + "label": "SmartBeaconing Type 2 Low Speed", + "type": "integer", + "min": 2, + "max": 30 + }, + { + "key": "smartbeaconing-type-2-high-speed", + "label": "SmartBeaconing Type 2 High Speed", + "type": "integer", + "min": 3, + "max": 90 + }, + { + "key": "smartbeaconing-type-2-slow-rate", + "label": "SmartBeaconing Type 2 Slow Rate", + "type": "integer", + "min": 1, + "max": 100 + }, + { + "key": "smartbeaconing-type-2-fast-rate", + "label": "SmartBeaconing Type 2 Fast Rate", + "type": "integer", + "min": 10, + "max": 180 + }, + { + "key": "smartbeaconing-type-2-turn-angle", + "label": "SmartBeaconing Type 2 Turn Angle", + "type": "integer", + "min": 5, + "max": 90 + }, + { + "key": "smartbeaconing-type-2-turn-slope", + "label": "SmartBeaconing Type 2 Turn Slope", + "type": "integer", + "min": 1, + "max": 255 + }, + { + "key": "smartbeaconing-type-2-turn-time", + "label": "SmartBeaconing Type 2 Turn Time", + "type": "integer", + "min": 5, + "max": 180 + }, + { + "key": "smartbeaconing-type-3-low-speed", + "label": "SmartBeaconing Type 3 Low Speed", + "type": "integer", + "min": 2, + "max": 30 + }, + { + "key": "smartbeaconing-type-3-high-speed", + "label": "SmartBeaconing Type 3 High Speed", + "type": "integer", + "min": 3, + "max": 90 + }, + { + "key": "smartbeaconing-type-3-slow-rate", + "label": "SmartBeaconing Type 3 Slow Rate", + "type": "integer", + "min": 1, + "max": 100 + }, + { + "key": "smartbeaconing-type-3-fast-rate", + "label": "SmartBeaconing Type 3 Fast Rate", + "type": "integer", + "min": 10, + "max": 180 + }, + { + "key": "smartbeaconing-type-3-turn-angle", + "label": "SmartBeaconing Type 3 Turn Angle", + "type": "integer", + "min": 5, + "max": 90 + }, + { + "key": "smartbeaconing-type-3-turn-slope", + "label": "SmartBeaconing Type 3 Turn Slope", + "type": "integer", + "min": 1, + "max": 255 + }, + { + "key": "smartbeaconing-type-3-turn-time", + "label": "SmartBeaconing Type 3 Turn Time", + "type": "integer", + "min": 5, + "max": 180 + } +] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72e804e..48e8ae0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -232,6 +232,9 @@ pub fn run() { commands::program::read_id52_settings_from_card, commands::program::write_card_settings, commands::program::read_thd75_settings_from_card, + commands::program::read_ftm300_settings_from_backup, + commands::codeplugs::get_codeplug_memory_group, + commands::codeplugs::set_codeplug_memory_group, commands::program::find_memory_cards, commands::program::write_radio_settings, commands::program::write_callsign_db, diff --git a/src-tauri/src/radios/card_file.rs b/src-tauri/src/radios/card_file.rs new file mode 100644 index 0000000..cadeda6 --- /dev/null +++ b/src-tauri/src/radios/card_file.rs @@ -0,0 +1,24 @@ +//! The file rule every card radio that patches ONE fixed file in place shares +//! (the FT5D's `BACKUP.dat`, the FTM-300's `CLNFTM300D.dat`): read the +//! operator's file, patch it, keep the untouched original as `.orig` — written +//! once and never overwritten, so a second write cannot clobber the only +//! pristine copy — and write the result back in place, because that fixed path +//! is what the radio's own restore menu reads. + +/// `missing` is the sentence appended when the file cannot be read: which file +/// to pick, and how the radio writes it. +pub(crate) fn patch_in_place( + path: &str, + missing: &str, + patch: impl FnOnce(&[u8]) -> Result<(Vec, usize), String>, +) -> Result { + let template = std::fs::read(path).map_err(|e| format!("Could not read {path}: {e}. {missing}"))?; + let (image, count) = patch(&template)?; + let orig = format!("{path}.orig"); + if !std::path::Path::new(&orig).exists() { + std::fs::write(&orig, &template) + .map_err(|e| format!("Could not save the original as {orig}: {e}"))?; + } + std::fs::write(path, &image).map_err(|e| format!("Could not write {path}: {e}"))?; + Ok(count) +} diff --git a/src-tauri/src/radios/card_settings_write.rs b/src-tauri/src/radios/card_settings_write.rs index f70ed8d..5df2037 100644 --- a/src-tauri/src/radios/card_settings_write.rs +++ b/src-tauri/src/radios/card_settings_write.rs @@ -148,3 +148,31 @@ fn thd75_settings_only_writes_a_new_file_touching_settings_alone() { println!("{src:?}: own settings = 0 bytes; {key} flipped = {d:x?}; new file {t2}"); } } + +#[test] +#[ignore = "needs the real card files in scratchpad/"] +fn ftm300_settings_only_writes_touch_settings_alone() { + use crate::radios::yaesu_ftm300::{settings::decode_settings, DRIVER}; + let ex = DRIVER.as_codeplug_exporter().unwrap(); + let w = ex.as_card_settings_writer().expect("a card settings writer"); + for src in files("ftm300/captures", "dat", "ftm300_01") { + let orig = std::fs::read(&src).unwrap(); + let dir = scratch("ftm300"); + let target = dir.join("CLNFTM300D.dat"); + std::fs::write(&target, &orig).unwrap(); + let t = target.to_str().unwrap(); + + let own = decode_settings(&orig); + w.export_settings(t, own.as_object().unwrap()).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), orig, "{src:?}: own settings changed bytes"); + assert!(dir.join("CLNFTM300D.dat.orig").exists(), "the .orig is kept"); + + let (key, one) = flip_one(&own); + w.export_settings(t, &one).unwrap(); + let after = std::fs::read(&target).unwrap(); + let d = differing(&orig, &after); + assert_eq!(d.len(), 1, "{key}: {d:x?}"); + assert_eq!(decode_settings(&after)[&key], one[&key]); + println!("{src:?}: own settings = 0 bytes; {key} flipped = {d:x?}"); + } +} diff --git a/src-tauri/src/radios/driver.rs b/src-tauri/src/radios/driver.rs index 494a527..87aaa88 100644 --- a/src-tauri/src/radios/driver.rs +++ b/src-tauri/src/radios/driver.rs @@ -628,6 +628,10 @@ pub(crate) struct ExportRequest<'a> { /// (`radio_profiles.non_channel_settings`), when it has a profile. Formats /// that carry non-channel settings apply them; the CSV formats cannot. pub profile_settings: Option<&'a str>, + /// Channel ids the codeplug puts in the radio's one user memory group (the + /// FTM-300's M-GRP), chosen channel by channel on the codeplug page. Every + /// other exporter ignores it. + pub memory_group: &'a [i64], } /// Card formats whose file takes the profile's settings ON THEIR OWN: the @@ -659,6 +663,12 @@ pub(crate) trait CodeplugExporter { /// inferred, like every other capability: a format either implements /// [`CardSettingsWriter`] or it does not, so the profile page's button and /// the write behind it cannot disagree. + /// Whether this format writes the codeplug's memory group (the FTM-300's + /// M-GRP) — so the export only reads it for the formats that use it. + fn has_memory_group(&self) -> bool { + false + } + fn as_card_settings_writer(&self) -> Option<&dyn CardSettingsWriter> { None } diff --git a/src-tauri/src/radios/icom_id52/memory.rs b/src-tauri/src/radios/icom_id52/memory.rs index 4d606ff..d4a8ad2 100644 --- a/src-tauri/src/radios/icom_id52/memory.rs +++ b/src-tauri/src/radios/icom_id52/memory.rs @@ -790,6 +790,7 @@ mod tests { groups: &[], model, profile_settings: settings, + memory_group: &[], } } diff --git a/src-tauri/src/radios/kenwood_thd75/dev_export.rs b/src-tauri/src/radios/kenwood_thd75/dev_export.rs index dd6efab..e8a1657 100644 --- a/src-tauri/src/radios/kenwood_thd75/dev_export.rs +++ b/src-tauri/src/radios/kenwood_thd75/dev_export.rs @@ -100,6 +100,7 @@ async fn a_real_codeplug_exports_through_the_app_pipeline() { groups: &groups, model: &model, profile_settings: None, + memory_group: &[], }, ) .expect("export"); diff --git a/src-tauri/src/radios/kenwood_thd75/memory.rs b/src-tauri/src/radios/kenwood_thd75/memory.rs index c341df9..b8fbceb 100644 --- a/src-tauri/src/radios/kenwood_thd75/memory.rs +++ b/src-tauri/src/radios/kenwood_thd75/memory.rs @@ -1092,6 +1092,7 @@ mod tests { groups: &[], model, profile_settings: None, + memory_group: &[], } } @@ -1191,6 +1192,7 @@ mod tests { groups: &[], model: &model, profile_settings: Some(json), + memory_group: &[], }, ) .expect("export"); diff --git a/src-tauri/src/radios/mod.rs b/src-tauri/src/radios/mod.rs index 9a11deb..f3ca4ea 100644 --- a/src-tauri/src/radios/mod.rs +++ b/src-tauri/src/radios/mod.rs @@ -32,6 +32,7 @@ pub(crate) mod anytone_atd890uv; pub(crate) mod baofeng_uv5r; pub(crate) mod binteradio_bt9000; +pub(crate) mod card_file; #[cfg(test)] mod card_settings_write; pub(crate) mod driver; @@ -55,3 +56,4 @@ pub(crate) mod tyt_md380; #[cfg(test)] mod wiring; pub(crate) mod yaesu_ft5d; +pub(crate) mod yaesu_ftm300; diff --git a/src-tauri/src/radios/registry.rs b/src-tauri/src/radios/registry.rs index fe49a22..eecf5dd 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; 11] = [ +static DRIVERS: [&dyn RadioDriver; 12] = [ &super::baofeng_uv5r::DRIVER, &super::binteradio_bt9000::DRIVER, &super::tidradio_tdh3::DRIVER, @@ -35,6 +35,7 @@ static DRIVERS: [&dyn RadioDriver; 11] = [ &super::kenwood_tmd710::DRIVER, &super::icom_id5100::DRIVER, &super::tyt_md380::DRIVER, + &super::yaesu_ftm300::DRIVER, ]; pub(crate) fn all_drivers() -> &'static [&'static dyn RadioDriver] { @@ -87,6 +88,7 @@ mod tests { "kenwood_thd75", "kenwood_thd72", "kenwood_tmd710", + "yaesu_ftm300", ] { let d = driver_for_key(key).unwrap_or_else(|| panic!("no driver for '{key}'")); assert_eq!(d.key(), key); @@ -119,6 +121,8 @@ mod tests { // its `.icf` and the TH-D75's in its `.d75`, none of which goes // through these traits. "yaesu_ft5d" | "icom_id52" | "kenwood_thd75" => (false, false), + // FTM-300 (#48): the fourth card radio, same reason. + "yaesu_ftm300" => (false, false), // TH-D72: reads and writes its 19 menu parameters over the // `MU` ASCII command — no clone session involved, which is why // it claims both halves where the card radios claim neither. diff --git a/src-tauri/src/radios/yaesu_ft5d/sd_image.rs b/src-tauri/src/radios/yaesu_ft5d/sd_image.rs index ebef1ac..5230228 100644 --- a/src-tauri/src/radios/yaesu_ft5d/sd_image.rs +++ b/src-tauri/src/radios/yaesu_ft5d/sd_image.rs @@ -73,7 +73,6 @@ use std::collections::HashMap; use crate::commands::export::{expanded_names, tx_frequency, CodeplugGroup, ExpandedChannel}; -use crate::error::MapErrString; use crate::models::RadioModel; use crate::radios::driver::{CardSettingsWriter, CodeplugExporter, ExportRequest}; @@ -556,29 +555,18 @@ impl CardSettingsWriter for YaesuFt5d { } } -/// The FT5D's file rule, shared by the export and the settings-only write: -/// read `path` (the operator's `BACKUP.dat`, which is what the radio's Restore -/// menu reads), patch it, keep the untouched original as `.orig` — written once -/// and never overwritten, so a second write cannot clobber the only pristine -/// copy — and write the result back in place. +/// The FT5D's card file rule ([`crate::radios::card_file::patch_in_place`]), +/// with the FT5D's own hint for a missing file. fn patch_in_place( path: &str, patch: impl FnOnce(&[u8]) -> Result<(Vec, usize), String>, ) -> Result { - let template = std::fs::read(path).map_err(|e| { - format!( - "Could not read {path}: {e}. Pick FT5D/BACKUP/BACKUP.dat on the \ - radio's microSD card — back the radio up first if it is not there." - ) - })?; - let (image, count) = patch(&template)?; - let orig = format!("{path}.orig"); - if !std::path::Path::new(&orig).exists() { - std::fs::write(&orig, &template) - .map_err(|e| format!("Could not save the original as {orig}: {e}"))?; - } - std::fs::write(path, &image).estr()?; - Ok(count) + crate::radios::card_file::patch_in_place( + path, + "Pick FT5D/BACKUP/BACKUP.dat on the radio's microSD card — back the radio up \ + first if it is not there.", + patch, + ) } /// The settings half of [`patch_backup`] on its own: validate, apply the diff --git a/src-tauri/src/radios/yaesu_ftm300/dev_export.rs b/src-tauri/src/radios/yaesu_ftm300/dev_export.rs new file mode 100644 index 0000000..e3a54fe --- /dev/null +++ b/src-tauri/src/radios/yaesu_ftm300/dev_export.rs @@ -0,0 +1,87 @@ +//! THROWAWAY (#48, step 4): run the app's own export pipeline against the dev +//! database and write a `CLNFTM300D.dat` from a real codeplug. +//! +//! A harness, not a unit test: it needs Tim's dev SQLite DB and his radio's own +//! backup, neither of which is in the repo. The channels come from any codeplug +//! (no FTM-300 codeplug exists until the app has seeded the model), filtered +//! through the FTM-300's own seeded row exactly as `generate_codeplug` would. +//! +//! ```sh +//! CPM_DEV_DB="$HOME/Library/Application Support/com.ww8l.codeplugmagic.dev/codeplug_manager.sqlite3" \ +//! CPM_CODEPLUG=3 FTM300_DAT=scratchpad/ftm300/captures/ftm300_01_real.dat \ +//! cargo test --lib yaesu_ftm300::dev_export -- --ignored --nocapture +//! ``` + +use crate::commands::export::{exclusion_reason, expand_for_export, resolve_codeplug_groups, ExpandedChannel}; +use crate::radios::driver::ExportRequest; + +#[tokio::test] +#[ignore = "needs the dev database and a real CLNFTM300D.dat"] +async fn a_real_codeplug_exports_through_the_app_pipeline() { + let db = std::env::var("CPM_DEV_DB").expect("CPM_DEV_DB"); + let codeplug_id: i64 = std::env::var("CPM_CODEPLUG").expect("CPM_CODEPLUG").parse().unwrap(); + let dat = std::env::var("FTM300_DAT").expect("FTM300_DAT"); + let pool = sqlx::SqlitePool::connect(&format!("sqlite:file:{db}?mode=ro")).await.expect("open dev db"); + + let model = crate::seed::test_model("FTM-300DR"); + let groups = resolve_codeplug_groups(&pool, codeplug_id).await.expect("groups"); + let mut seen = std::collections::HashSet::new(); + let channels: Vec<_> = groups + .iter() + .flat_map(|g| g.channels.iter().cloned()) + .filter(|c| seen.insert(c.id)) + .collect(); + let expanded = expand_for_export(&pool, channels).await.expect("expand"); + + let mut excluded = Vec::new(); + let included: Vec<&ExpandedChannel> = expanded + .iter() + .filter(|ec| match exclusion_reason(&ec.channel, &model) { + Some(why) => { + excluded.push(format!(" {:>10.4} {}", ec.channel.rx_freq, why)); + false + } + None => true, + }) + .collect(); + println!("{} channels in, {} included, {} excluded", expanded.len(), included.len(), excluded.len()); + for line in &excluded { + println!("{line}"); + } + + let dir = std::env::temp_dir().join("ftm300_dev_export"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let target = dir.join("CLNFTM300D.dat"); + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + std::fs::copy(root.join(&dat), &target).expect("copy template"); + + let exporter = crate::radios::registry::exporter_for_format("yaesu_ftm300_sd").expect("exporter"); + let n = exporter + .export( + target.to_str().unwrap(), + &ExportRequest { channels: &included, groups: &groups, model: &model, profile_settings: None, memory_group: &[] }, + ) + .expect("export"); + let out = std::fs::read(&target).unwrap(); + let template = std::fs::read(root.join(&dat)).unwrap(); + + // Count what landed, the way the radio decides: byte 0 bit 7 of each of the 999 records. + let used = (0..999).filter(|i| out[0x800 + i * 16] & 0x80 != 0).count(); + println!("wrote {n}; {used} records marked used"); + for (slot, ec) in included.iter().enumerate().take(8) { + let r = &out[0x800 + slot * 16..0x800 + slot * 16 + 16]; + let name = String::from_utf8_lossy(out[0x10000 + slot * 16..0x10010 + slot * 16].split(|&b| b == 0xFF).next().unwrap()).into_owned(); + println!(" {:3} {:>10.4} {:02x?} {name:?}", slot + 1, ec.channel.rx_freq, r); + } + assert_eq!(used, included.len()); + assert_eq!(n, included.len()); + // Everything outside the three memory tables is the template's. + let outside = |i: usize| !(0x800..0x800 + 999 * 16).contains(&i) + && !(0x4CB0..0x4CB0 + 999 * 2).contains(&i) + && !(0x10000..0x10000 + 999 * 16).contains(&i); + let strays: Vec = (0..out.len()).filter(|&i| outside(i) && out[i] != template[i]).collect(); + assert!(strays.is_empty(), "bytes outside the memory tables moved: {strays:x?}"); + println!("nothing outside the memory tables moved; settings, PMS, Home, APRS untouched"); +} + diff --git a/src-tauri/src/radios/yaesu_ftm300/ftm300_settings_table.rs b/src-tauri/src/radios/yaesu_ftm300/ftm300_settings_table.rs new file mode 100644 index 0000000..b4b8968 --- /dev/null +++ b/src-tauri/src/radios/yaesu_ftm300/ftm300_settings_table.rs @@ -0,0 +1,156 @@ +// GENERATED by scratchpad/ftm300/rts/gen_settings.py from the bit-signature campaign +// (plan.py / runpass.py / solve.py, 16 RT Systems writes of Tim's CLNFTM300D.dat) plus the +// fields read by eye off the same images. Do not hand-edit; regenerate. +// +// Offsets are FILE offsets into CLNFTM300D.dat. Every one was measured on RT Systems' own writes +// of this radio's file; where CHIRP's ftm300d.py also maps a field, the two agree except where a +// field is withheld (see WITHHELD in the generator). +pub(crate) const FTM300_SETTINGS_FIELDS: &[SF] = &[ + SF { key: "auto-power-off", byte: 0x000a4, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Off"), (1, "30 min"), (2, "1.0 hour"), (3, "1.5 hour"), (4, "2.0 hour"), (5, "2.5 hour"), (6, "3.0 hour"), (7, "3.5 hour"), (8, "4.0 hour"), (9, "4.5 hour"), (10, "5.0 hour"), (11, "5.5 hour"), (12, "6.0 hour"), (13, "6.5 hour"), (14, "7.0 hour"), (15, "7.5 hour"), (16, "8.0 hour"), (17, "8.5 hour"), (18, "9.0 hour"), (19, "9.5 hour"), (20, "10.0 hour"), (21, "10.5 hour"), (22, "11.0 hour"), (23, "11.5 hour"), (24, "12.0 hour")] } }, // common1/1023 + SF { key: "unit", byte: 0x000a1, kind: SK::Enum { shift: 0, width: 1, labels: &[(0, "Metric"), (1, "Inch")] } }, // common1/1044 + SF { key: "time-format", byte: 0x000f9, kind: SK::Enum { shift: 4, width: 1, labels: &[(0, "24 Hour"), (1, "12 Hour")] } }, // common1/1033 + SF { key: "date-format", byte: 0x000f9, kind: SK::Enum { shift: 0, width: 3, labels: &[(0, "yyyy/mmm/dd"), (1, "yyyy/dd/mmm"), (2, "mmm/dd/yyyy"), (4, "dd/mmm/yyyy")] } }, // common1/1083 + SF { key: "time-out-timer", byte: 0x000aa, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Off"), (6, "1 minute"), (7, "2 minutes"), (8, "3 minutes"), (1, "5 minutes"), (2, "10 minutes"), (3, "15 minutes"), (4, "20 minutes"), (5, "30 minutes")] } }, // common1/1071 + SF { key: "band-a-auto-repeater-shift", byte: 0x000e4, kind: SK::Bool { shift: 2, active_low: false } }, // common1/1011 + SF { key: "band-b-auto-repeater-shift", byte: 0x000f4, kind: SK::Bool { shift: 2, active_low: false } }, // common1/1012 + SF { key: "band-a-rx-coverage", byte: 0x000e3, kind: SK::Enum { shift: 5, width: 1, labels: &[(0, "Normal"), (1, "Wide")] } }, // common1/1040 + SF { key: "band-b-rx-coverage", byte: 0x000f3, kind: SK::Enum { shift: 5, width: 1, labels: &[(0, "Normal"), (1, "Wide")] } }, // common1/1043 + SF { key: "gps-datum", byte: 0x000af, kind: SK::Enum { shift: 0, width: 8, labels: &[(21, "WGS-84"), (178, "Tokyo Mean")] } }, // common1/1046 + SF { key: "gps-log", byte: 0x000b5, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Off"), (1, "1 second"), (2, "2 seconds"), (3, "5 seconds"), (4, "10 seconds"), (5, "30 seconds"), (6, "60 seconds")] } }, // common1/1181 + SF { key: "time-zone", byte: 0x000a8, kind: SK::Enum { shift: 0, width: 8, labels: &[(156, "-14:00"), (155, "-13:30"), (154, "-13:00"), (153, "-12:30"), (152, "-12:00"), (151, "-11:30"), (150, "-11:00"), (149, "-10:30"), (148, "-10:00"), (147, "-9:30"), (146, "-9:00"), (145, "-8:30"), (144, "-8:00"), (143, "-7:30"), (142, "-7:00"), (141, "-6:30"), (140, "-6:00"), (139, "-5:30"), (138, "-5:00"), (137, "-4:30"), (136, "-4:00"), (135, "-3:30"), (134, "-3:00"), (133, "-2:30"), (132, "-2:00"), (131, "-1:30"), (130, "-1:00"), (129, "-0:30"), (0, "0:00"), (1, "+0:30"), (2, "+1:00"), (3, "+1:30"), (4, "+2:00"), (5, "+2:30"), (6, "+3:00"), (7, "+3:30"), (8, "+4:00"), (9, "+4:30"), (10, "+5:00"), (11, "+5:30"), (12, "+6:00"), (13, "+6:30"), (14, "+7:00"), (15, "+7:30"), (16, "+8:00"), (17, "+8:30"), (18, "+9:00"), (19, "+9:30"), (20, "+10:00"), (21, "+10:30"), (22, "+11:00"), (23, "+11:30"), (24, "+12:00"), (25, "+12:30"), (26, "+13:00"), (27, "+13:30"), (28, "+14:00")] } }, // common1/1064 + SF { key: "beep", byte: 0x000eb, kind: SK::Bool { shift: 3, active_low: false } }, // beep + SF { key: "beep-level", byte: 0x000fa, kind: SK::Enum { shift: 4, width: 1, labels: &[(0, "Low"), (1, "High")] } }, // beep-level + SF { key: "callsign", byte: 0x002c8, kind: SK::Text { len: 10, pad: 0xFF, punct: true } }, // callsign + SF { key: "band-scope", byte: 0x000e5, kind: SK::Enum { shift: 5, width: 1, labels: &[(0, "Wide"), (1, "Narrow")] } }, // band-scope + SF { key: "compass", byte: 0x002d6, kind: SK::Enum { shift: 0, width: 1, labels: &[(0, "North Up"), (1, "Heading Up")] } }, // common1/1192 + SF { key: "lcd-brightness", byte: 0x0028f, kind: SK::Enum { shift: 0, width: 8, labels: &[(3, "Min"), (5, "Mid"), (6, "Max")] } }, // common1/1188 + SF { key: "display-mode", byte: 0x000ad, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Backtrack"), (1, "Altitude"), (2, "Timer/Clock"), (3, "GPS Info")] } }, // common1/1190 + SF { key: "memory-list", byte: 0x000ec, kind: SK::Enum { shift: 6, width: 1, labels: &[(0, "Off"), (1, "On")] } }, // common1/1191 + SF { key: "band-a-scan-resume", byte: 0x000c9, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Busy"), (1, "Hold"), (2, "1.0 seconds"), (3, "3.0 seconds"), (4, "5.0 seconds")] } }, // common1/1028 + SF { key: "band-b-scan-resume", byte: 0x000d9, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Busy"), (1, "Hold"), (2, "1.0 seconds"), (3, "3.0 seconds"), (4, "5.0 seconds")] } }, // common1/1032 + SF { key: "mic-key-p2", byte: 0x002e0, kind: SK::Enum { shift: 0, width: 8, labels: &[(220, "Off"), (221, "Band Scope"), (222, "Scan"), (223, "Home"), (224, "Rpt Shift"), (225, "Reverse"), (226, "Tx Power"), (227, "Sql Off"), (228, "T-Call"), (229, "Voice"), (230, "D_X"), (231, "Wx"), (232, "S-List"), (233, "MSG"), (234, "Reply"), (235, "M-Edit")] } }, // common1/1037 + SF { key: "mic-key-p3", byte: 0x002e1, kind: SK::Enum { shift: 0, width: 8, labels: &[(220, "Off"), (221, "Band Scope"), (222, "Scan"), (223, "Home"), (224, "Rpt Shift"), (225, "Reverse"), (226, "Tx Power"), (227, "Sql Off"), (228, "T-Call"), (229, "Voice"), (230, "D_X"), (231, "Wx"), (232, "S-List"), (233, "MSG"), (234, "Reply"), (235, "M-Edit")] } }, // common1/1038 + SF { key: "mic-key-p4", byte: 0x002e2, kind: SK::Enum { shift: 0, width: 8, labels: &[(220, "Off"), (221, "Band Scope"), (222, "Scan"), (223, "Home"), (224, "Rpt Shift"), (225, "Reverse"), (226, "Tx Power"), (227, "Sql Off"), (228, "T-Call"), (229, "Voice"), (230, "D_X"), (231, "Wx"), (232, "S-List"), (233, "MSG"), (234, "Reply"), (235, "M-Edit")] } }, // common1/1039 + SF { key: "bluetooth-save", byte: 0x000ea, kind: SK::Bool { shift: 0, active_low: false } }, // common1/1082 + SF { key: "band-a-squelch-level", byte: 0x000c8, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Level 0"), (1, "Level 1"), (2, "Level 2"), (3, "Level 3"), (4, "Level 4"), (5, "Level 5"), (6, "Level 6"), (7, "Level 7"), (8, "Level 8")] } }, // common2/1007 + SF { key: "band-b-squelch-level", byte: 0x000d8, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Level 0"), (1, "Level 1"), (2, "Level 2"), (3, "Level 3"), (4, "Level 4"), (5, "Level 5"), (6, "Level 6"), (7, "Level 7"), (8, "Level 8")] } }, // common2/1008 + SF { key: "band-a-sql-expansion", byte: 0x000e3, kind: SK::Bool { shift: 0, active_low: false } }, // common2/1064 + SF { key: "band-b-sql-expansion", byte: 0x000f3, kind: SK::Bool { shift: 0, active_low: false } }, // common2/1065 + SF { key: "band-a-bell-ringer", byte: 0x0028e, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Off"), (1, "1 time"), (2, "3 times"), (3, "5 times"), (4, "8 times"), (5, "Continuous")] } }, // common2/1030 + SF { key: "band-b-bell-ringer", byte: 0x0029e, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Off"), (1, "1 time"), (2, "3 times"), (3, "5 times"), (4, "8 times"), (5, "Continuous")] } }, // common2/1031 + SF { key: "band-a-weather-alert", byte: 0x000e2, kind: SK::Bool { shift: 6, active_low: false } }, // common2/1072 + SF { key: "band-b-weather-alert", byte: 0x000f2, kind: SK::Bool { shift: 6, active_low: false } }, // common2/1073 + SF { key: "band-a-weather-channel", byte: 0x000c5, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "162.550 MHz"), (1, "162.400 MHz"), (2, "162.475 MHz"), (3, "162.425 MHz"), (4, "162.450 MHz"), (5, "162.500 MHz"), (6, "162.525 MHz"), (7, "161.650 MHz"), (8, "161.775 MHz"), (9, "163.275 MHz")] } }, // common2/1210 + SF { key: "band-b-weather-channel", byte: 0x000d5, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "162.550 MHz"), (1, "162.400 MHz"), (2, "162.475 MHz"), (3, "162.425 MHz"), (4, "162.450 MHz"), (5, "162.500 MHz"), (6, "162.525 MHz"), (7, "161.650 MHz"), (8, "161.775 MHz"), (9, "163.275 MHz")] } }, // common2/1211 + SF { key: "pager-receive-code-1", byte: 0x002d2, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "01 - 67.0 Hz"), (1, "02 - 69.3 Hz"), (2, "03 - 71.9 Hz"), (3, "04 - 74.4 Hz"), (4, "05 - 77.0 Hz"), (5, "06 - 79.7 Hz"), (6, "07 - 82.5 Hz"), (7, "08 - 85.4 Hz"), (8, "09 - 88.5 Hz"), (9, "10 - 91.5 Hz"), (10, "11 - 94.8 Hz"), (11, "12 - 97.4 Hz"), (12, "13 - 100.0 Hz"), (13, "14 - 103.5 Hz"), (14, "15 - 107.2 Hz"), (15, "16 - 110.9 Hz"), (16, "17 - 114.8 Hz"), (17, "18 - 118.8 Hz"), (18, "19 - 123.0 Hz"), (19, "20 - 127.3 Hz"), (20, "21 - 131.8 Hz"), (21, "22 - 136.5 Hz"), (22, "23 - 141.3 Hz"), (23, "24 - 146.2 Hz"), (24, "25 - 151.4 Hz"), (25, "26 - 156.7 Hz"), (26, "27 - 159.8 Hz"), (27, "28 - 162.2 Hz"), (28, "29 - 165.5 Hz"), (29, "30 - 167.9 Hz"), (30, "31 - 171.3 Hz"), (31, "32 - 173.8 Hz"), (32, "33 - 177.3 Hz"), (33, "34 - 179.9 Hz"), (34, "35 - 183.5 Hz"), (35, "36 - 186.2 Hz"), (36, "37 - 189.9 Hz"), (37, "38 - 192.8 Hz"), (38, "39 - 196.6 Hz"), (39, "40 - 199.5 Hz"), (40, "41 - 203.5 Hz"), (41, "42 - 206.5 Hz"), (42, "43 - 210.7 Hz"), (43, "44 - 218.1 Hz"), (44, "45 - 225.7 Hz"), (45, "46 - 229.1 Hz"), (46, "47 - 233.6 Hz"), (47, "48 - 241.8 Hz"), (48, "49 - 250.3 Hz"), (49, "50 - 254.1 Hz")] } }, // common2/1046 + SF { key: "pager-receive-code-2", byte: 0x002d3, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "01 - 67.0 Hz"), (1, "02 - 69.3 Hz"), (2, "03 - 71.9 Hz"), (3, "04 - 74.4 Hz"), (4, "05 - 77.0 Hz"), (5, "06 - 79.7 Hz"), (6, "07 - 82.5 Hz"), (7, "08 - 85.4 Hz"), (8, "09 - 88.5 Hz"), (9, "10 - 91.5 Hz"), (10, "11 - 94.8 Hz"), (11, "12 - 97.4 Hz"), (12, "13 - 100.0 Hz"), (13, "14 - 103.5 Hz"), (14, "15 - 107.2 Hz"), (15, "16 - 110.9 Hz"), (16, "17 - 114.8 Hz"), (17, "18 - 118.8 Hz"), (18, "19 - 123.0 Hz"), (19, "20 - 127.3 Hz"), (20, "21 - 131.8 Hz"), (21, "22 - 136.5 Hz"), (22, "23 - 141.3 Hz"), (23, "24 - 146.2 Hz"), (24, "25 - 151.4 Hz"), (25, "26 - 156.7 Hz"), (26, "27 - 159.8 Hz"), (27, "28 - 162.2 Hz"), (28, "29 - 165.5 Hz"), (29, "30 - 167.9 Hz"), (30, "31 - 171.3 Hz"), (31, "32 - 173.8 Hz"), (32, "33 - 177.3 Hz"), (33, "34 - 179.9 Hz"), (34, "35 - 183.5 Hz"), (35, "36 - 186.2 Hz"), (36, "37 - 189.9 Hz"), (37, "38 - 192.8 Hz"), (38, "39 - 196.6 Hz"), (39, "40 - 199.5 Hz"), (40, "41 - 203.5 Hz"), (41, "42 - 206.5 Hz"), (42, "43 - 210.7 Hz"), (43, "44 - 218.1 Hz"), (44, "45 - 225.7 Hz"), (45, "46 - 229.1 Hz"), (46, "47 - 233.6 Hz"), (47, "48 - 241.8 Hz"), (48, "49 - 250.3 Hz"), (49, "50 - 254.1 Hz")] } }, // common2/1048 + SF { key: "pager-transmit-code-1", byte: 0x002d4, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "01 - 67.0 Hz"), (1, "02 - 69.3 Hz"), (2, "03 - 71.9 Hz"), (3, "04 - 74.4 Hz"), (4, "05 - 77.0 Hz"), (5, "06 - 79.7 Hz"), (6, "07 - 82.5 Hz"), (7, "08 - 85.4 Hz"), (8, "09 - 88.5 Hz"), (9, "10 - 91.5 Hz"), (10, "11 - 94.8 Hz"), (11, "12 - 97.4 Hz"), (12, "13 - 100.0 Hz"), (13, "14 - 103.5 Hz"), (14, "15 - 107.2 Hz"), (15, "16 - 110.9 Hz"), (16, "17 - 114.8 Hz"), (17, "18 - 118.8 Hz"), (18, "19 - 123.0 Hz"), (19, "20 - 127.3 Hz"), (20, "21 - 131.8 Hz"), (21, "22 - 136.5 Hz"), (22, "23 - 141.3 Hz"), (23, "24 - 146.2 Hz"), (24, "25 - 151.4 Hz"), (25, "26 - 156.7 Hz"), (26, "27 - 159.8 Hz"), (27, "28 - 162.2 Hz"), (28, "29 - 165.5 Hz"), (29, "30 - 167.9 Hz"), (30, "31 - 171.3 Hz"), (31, "32 - 173.8 Hz"), (32, "33 - 177.3 Hz"), (33, "34 - 179.9 Hz"), (34, "35 - 183.5 Hz"), (35, "36 - 186.2 Hz"), (36, "37 - 189.9 Hz"), (37, "38 - 192.8 Hz"), (38, "39 - 196.6 Hz"), (39, "40 - 199.5 Hz"), (40, "41 - 203.5 Hz"), (41, "42 - 206.5 Hz"), (42, "43 - 210.7 Hz"), (43, "44 - 218.1 Hz"), (44, "45 - 225.7 Hz"), (45, "46 - 229.1 Hz"), (46, "47 - 233.6 Hz"), (47, "48 - 241.8 Hz"), (48, "49 - 250.3 Hz"), (49, "50 - 254.1 Hz")] } }, // common2/1050 + SF { key: "pager-transmit-code-2", byte: 0x002d5, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "01 - 67.0 Hz"), (1, "02 - 69.3 Hz"), (2, "03 - 71.9 Hz"), (3, "04 - 74.4 Hz"), (4, "05 - 77.0 Hz"), (5, "06 - 79.7 Hz"), (6, "07 - 82.5 Hz"), (7, "08 - 85.4 Hz"), (8, "09 - 88.5 Hz"), (9, "10 - 91.5 Hz"), (10, "11 - 94.8 Hz"), (11, "12 - 97.4 Hz"), (12, "13 - 100.0 Hz"), (13, "14 - 103.5 Hz"), (14, "15 - 107.2 Hz"), (15, "16 - 110.9 Hz"), (16, "17 - 114.8 Hz"), (17, "18 - 118.8 Hz"), (18, "19 - 123.0 Hz"), (19, "20 - 127.3 Hz"), (20, "21 - 131.8 Hz"), (21, "22 - 136.5 Hz"), (22, "23 - 141.3 Hz"), (23, "24 - 146.2 Hz"), (24, "25 - 151.4 Hz"), (25, "26 - 156.7 Hz"), (26, "27 - 159.8 Hz"), (27, "28 - 162.2 Hz"), (28, "29 - 165.5 Hz"), (29, "30 - 167.9 Hz"), (30, "31 - 171.3 Hz"), (31, "32 - 173.8 Hz"), (32, "33 - 177.3 Hz"), (33, "34 - 179.9 Hz"), (34, "35 - 183.5 Hz"), (35, "36 - 186.2 Hz"), (36, "37 - 189.9 Hz"), (37, "38 - 192.8 Hz"), (38, "39 - 196.6 Hz"), (39, "40 - 199.5 Hz"), (40, "41 - 203.5 Hz"), (41, "42 - 206.5 Hz"), (42, "43 - 210.7 Hz"), (43, "44 - 218.1 Hz"), (44, "45 - 225.7 Hz"), (45, "46 - 229.1 Hz"), (46, "47 - 233.6 Hz"), (47, "48 - 241.8 Hz"), (48, "49 - 250.3 Hz"), (49, "50 - 254.1 Hz")] } }, // common2/1052 + SF { key: "ams-tx-mode", byte: 0x000ee, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Auto"), (1, "Tx FM Fixed"), (2, "Tx DN Fixed")] } }, // common2/1005 + SF { key: "location-service", byte: 0x000eb, kind: SK::Bool { shift: 7, active_low: false } }, // common2/1010 + SF { key: "standby-beep", byte: 0x000e8, kind: SK::Bool { shift: 4, active_low: true } }, // common2/1074 + SF { key: "digital-vw", byte: 0x000e8, kind: SK::Bool { shift: 1, active_low: false } }, // common2/1076 + SF { key: "digital-popup", byte: 0x002db, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Off"), (1, "2 seconds"), (2, "4 seconds"), (3, "6 seconds"), (4, "8 seconds"), (5, "10 seconds"), (6, "20 seconds"), (7, "30 seconds"), (8, "60 seconds"), (255, "Continue")] } }, // common2/1004 + SF { key: "mic-gain", byte: 0x002dd, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Min"), (1, "Low"), (2, "Normal"), (3, "High"), (4, "Max")] } }, // common2/1039 + SF { key: "vox", byte: 0x000b6, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Off"), (1, "Low"), (2, "High")] } }, // common2/1080 + SF { key: "vox-delay", byte: 0x000b7, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "0.5s"), (1, "1.0s"), (2, "1.5s"), (3, "2.0s"), (4, "2.5s")] } }, // common2/1081 + SF { key: "recording-band", byte: 0x000b8, kind: SK::Enum { shift: 0, width: 8, labels: &[(1, "A"), (2, "B"), (3, "A+B")] } }, // common2/1078 + SF { key: "recording-mic", byte: 0x000ea, kind: SK::Enum { shift: 3, width: 1, labels: &[(0, "Off"), (1, "On")] } }, // common2/1079 + SF { key: "sub-band-mute", byte: 0x000e9, kind: SK::Enum { shift: 0, width: 2, labels: &[(0, "Off"), (3, "On")] } }, // common2/1077 + SF { key: "voice-memory-play-rec", byte: 0x000fa, kind: SK::Enum { shift: 0, width: 1, labels: &[(0, "Free 5 min"), (1, "Last 30 sec")] } }, // common2/1197 + SF { key: "voice-announce", byte: 0x000ae, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Off"), (1, "Manual"), (2, "Auto")] } }, // common2/1198 + SF { key: "voice-language", byte: 0x000eb, kind: SK::Enum { shift: 6, width: 1, labels: &[(0, "English"), (1, "Japanese")] } }, // common2/1199 + SF { key: "voice-volume", byte: 0x000a9, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Low"), (1, "Mid"), (2, "High")] } }, // common2/1200 + SF { key: "dtmf-auto-dialer", byte: 0x000f8, kind: SK::Enum { shift: 2, width: 1, labels: &[(0, "Manual"), (1, "Auto")] } }, // dtmf/1095 + SF { key: "band-a-dtmf-channel", byte: 0x000c7, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "1"), (1, "2"), (2, "3"), (3, "4"), (4, "5"), (5, "6"), (6, "7"), (7, "8"), (8, "9")] } }, // dtmf/1096 + SF { key: "band-b-dtmf-channel", byte: 0x000d7, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "1"), (1, "2"), (2, "3"), (3, "4"), (4, "5"), (5, "6"), (6, "7"), (7, "8"), (8, "9")] } }, // dtmf/1097 + SF { key: "wires-x-search-setup", byte: 0x000ec, kind: SK::Enum { shift: 3, width: 1, labels: &[(0, "History"), (1, "Activity")] } }, // dtmf/1024 + SF { key: "wires-x-rpt-wires-freq", byte: 0x000ec, kind: SK::Enum { shift: 4, width: 1, labels: &[(0, "Manual"), (1, "Preset")] } }, // dtmf/1025 + SF { key: "wires-x-dg-id", byte: 0x0029f, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Auto"), (1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5"), (6, "6"), (7, "7"), (8, "8"), (9, "9"), (10, "10"), (11, "11"), (12, "12"), (13, "13"), (14, "14"), (15, "15"), (16, "16"), (17, "17"), (18, "18"), (19, "19"), (20, "20"), (21, "21"), (22, "22"), (23, "23"), (24, "24"), (25, "25"), (26, "26"), (27, "27"), (28, "28"), (29, "29"), (30, "30"), (31, "31"), (32, "32"), (33, "33"), (34, "34"), (35, "35"), (36, "36"), (37, "37"), (38, "38"), (39, "39"), (40, "40"), (41, "41"), (42, "42"), (43, "43"), (44, "44"), (45, "45"), (46, "46"), (47, "47"), (48, "48"), (49, "49"), (50, "50"), (51, "51"), (52, "52"), (53, "53"), (54, "54"), (55, "55"), (56, "56"), (57, "57"), (58, "58"), (59, "59"), (60, "60"), (61, "61"), (62, "62"), (63, "63"), (64, "64"), (65, "65"), (66, "66"), (67, "67"), (68, "68"), (69, "69"), (70, "70"), (71, "71"), (72, "72"), (73, "73"), (74, "74"), (75, "75"), (76, "76"), (77, "77"), (78, "78"), (79, "79"), (80, "80"), (81, "81"), (82, "82"), (83, "83"), (84, "84"), (85, "85"), (86, "86"), (87, "87"), (88, "88"), (89, "89"), (90, "90"), (91, "91"), (92, "92"), (93, "93"), (94, "94"), (95, "95"), (96, "96"), (97, "97"), (98, "98"), (99, "99")] } }, // dtmf/1003 + SF { key: "aprs-callsign", byte: 0x00508, kind: SK::Text { len: 6, pad: 0xCA, punct: false } }, // aprs-callsign + SF { key: "aprs-callsign-ssid", byte: 0x0050e, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Non"), (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")] } }, // aprs/1149 + SF { key: "my-symbol", byte: 0x0052c, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Symbol 1"), (1, "Symbol 2"), (2, "Symbol 3"), (3, "User Symbol")] } }, // my-symbol + SF { key: "symbol-1", byte: 0x00522, kind: SK::Enum16 { labels: &[(12067, "/#"), (12070, "/&"), (12071, "/'"), (12077, "/-"), (12078, "/."), (12080, "/0"), (12090, "/:"), (12091, "/;"), (12092, "/<"), (12094, "/>"), (12099, "/C"), (12101, "/E"), (12105, "/I"), (12107, "/K"), (12111, "/O"), (12112, "/P"), (12114, "/R"), (12116, "/T"), (12117, "/U"), (12118, "/V"), (12119, "/W"), (12120, "/X"), (12121, "/Y"), (12123, "/["), (12124, "/\\"), (12126, "/^"), (12127, "/_"), (12129, "/a"), (12130, "/b"), (12134, "/f"), (12135, "/g"), (12138, "/j"), (12139, "/k"), (12141, "/m"), (12146, "/r"), (12147, "/s"), (12149, "/u"), (12150, "/v"), (12153, "/y"), (23587, "\\#"), (23590, "\\&"), (23597, "\\-"), (23598, "\\."), (23600, "\\0"), (17712, "E0"), (18736, "I0"), (21296, "S0"), (22320, "W0"), (23611, "\\;"), (23613, "\\="), (23614, "\\>"), (23627, "\\K"), (23639, "\\W"), (23641, "\\Y"), (16729, "AY"), (18777, "IY"), (19289, "KY"), (22873, "YY"), (23646, "\\^"), (23647, "\\_"), (23661, "\\m"), (23662, "\\n"), (23667, "\\s"), (23669, "\\u"), (23670, "\\v"), (23672, "\\x")] } }, // symbol-1 + SF { key: "symbol-2", byte: 0x00524, kind: SK::Enum16 { labels: &[(12067, "/#"), (12070, "/&"), (12071, "/'"), (12077, "/-"), (12078, "/."), (12080, "/0"), (12090, "/:"), (12091, "/;"), (12092, "/<"), (12094, "/>"), (12099, "/C"), (12101, "/E"), (12105, "/I"), (12107, "/K"), (12111, "/O"), (12112, "/P"), (12114, "/R"), (12116, "/T"), (12117, "/U"), (12118, "/V"), (12119, "/W"), (12120, "/X"), (12121, "/Y"), (12123, "/["), (12124, "/\\"), (12126, "/^"), (12127, "/_"), (12129, "/a"), (12130, "/b"), (12134, "/f"), (12135, "/g"), (12138, "/j"), (12139, "/k"), (12141, "/m"), (12146, "/r"), (12147, "/s"), (12149, "/u"), (12150, "/v"), (12153, "/y"), (23587, "\\#"), (23590, "\\&"), (23597, "\\-"), (23598, "\\."), (23600, "\\0"), (17712, "E0"), (18736, "I0"), (21296, "S0"), (22320, "W0"), (23611, "\\;"), (23613, "\\="), (23614, "\\>"), (23627, "\\K"), (23639, "\\W"), (23641, "\\Y"), (16729, "AY"), (18777, "IY"), (19289, "KY"), (22873, "YY"), (23646, "\\^"), (23647, "\\_"), (23661, "\\m"), (23662, "\\n"), (23667, "\\s"), (23669, "\\u"), (23670, "\\v"), (23672, "\\x")] } }, // symbol-2 + SF { key: "symbol-3", byte: 0x00526, kind: SK::Enum16 { labels: &[(12067, "/#"), (12070, "/&"), (12071, "/'"), (12077, "/-"), (12078, "/."), (12080, "/0"), (12090, "/:"), (12091, "/;"), (12092, "/<"), (12094, "/>"), (12099, "/C"), (12101, "/E"), (12105, "/I"), (12107, "/K"), (12111, "/O"), (12112, "/P"), (12114, "/R"), (12116, "/T"), (12117, "/U"), (12118, "/V"), (12119, "/W"), (12120, "/X"), (12121, "/Y"), (12123, "/["), (12124, "/\\"), (12126, "/^"), (12127, "/_"), (12129, "/a"), (12130, "/b"), (12134, "/f"), (12135, "/g"), (12138, "/j"), (12139, "/k"), (12141, "/m"), (12146, "/r"), (12147, "/s"), (12149, "/u"), (12150, "/v"), (12153, "/y"), (23587, "\\#"), (23590, "\\&"), (23597, "\\-"), (23598, "\\."), (23600, "\\0"), (17712, "E0"), (18736, "I0"), (21296, "S0"), (22320, "W0"), (23611, "\\;"), (23613, "\\="), (23614, "\\>"), (23627, "\\K"), (23639, "\\W"), (23641, "\\Y"), (16729, "AY"), (18777, "IY"), (19289, "KY"), (22873, "YY"), (23646, "\\^"), (23647, "\\_"), (23661, "\\m"), (23662, "\\n"), (23667, "\\s"), (23669, "\\u"), (23670, "\\v"), (23672, "\\x")] } }, // symbol-3 + SF { key: "position-comment", byte: 0x00532, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Off Duty"), (1, "En Route"), (2, "In Service"), (3, "Returning"), (4, "Committed"), (5, "Special"), (6, "Priority"), (7, "Custom 0"), (8, "Custom 1"), (9, "Custom 2"), (10, "Custom 3"), (11, "Custom 4"), (12, "Custom 5"), (13, "Custom 6"), (14, "EMERGENCY!")] } }, // aprs/1177 + SF { key: "message-popup", byte: 0x00546, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Off"), (3, "3 seconds"), (5, "5 seconds"), (10, "10 seconds"), (255, "Hold")] } }, // aprs/1287 + SF { key: "tx-message-ringer", byte: 0x0053a, kind: SK::Bool { shift: 0, active_low: false } }, // aprs/1111 + SF { key: "rx-message-ringer", byte: 0x0053c, kind: SK::Bool { shift: 0, active_low: false } }, // aprs/1355 + SF { key: "aprs-modem", byte: 0x00534, kind: SK::Bool { shift: 0, active_low: false } }, // aprs/1013 + SF { key: "aprs-speed", byte: 0x00534, kind: SK::Enum { shift: 6, width: 1, labels: &[(0, "1200 bps"), (1, "9600 bps")] } }, // aprs/1121 + SF { key: "aprs-band", byte: 0x00543, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Main Band"), (1, "Sub Band"), (2, "A-Band Fix"), (3, "B-Band Fix"), (4, "A=Tx / B=Rx"), (5, "A=Rx / B=Tx")] } }, // aprs/1299 + SF { key: "aprs-squelch", byte: 0x00535, kind: SK::Enum { shift: 7, width: 1, labels: &[(0, "Rx Band"), (1, "Tx/RX Band")] } }, // aprs/1301 + SF { key: "aprs-compass", byte: 0x00542, kind: SK::Enum { shift: 0, width: 1, labels: &[(0, "North Up"), (1, "Heading Up")] } }, // aprs/1194 + SF { key: "aprs-mute", byte: 0x00534, kind: SK::Enum { shift: 1, width: 1, labels: &[(0, "Off"), (1, "On")] } }, // aprs/1286 + SF { key: "tx-squelch", byte: 0x00535, kind: SK::Bool { shift: 5, active_low: false } }, // aprs/1158 + SF { key: "data-speed", byte: 0x00534, kind: SK::Enum { shift: 5, width: 1, labels: &[(0, "1200 bps"), (1, "9600 bps")] } }, // aprs/1122 + SF { key: "data-band", byte: 0x00544, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Main Band"), (1, "Sub Band"), (2, "A-Band Fix"), (3, "B-Band Fix"), (4, "A=Tx / B=Rx"), (5, "A=Rx / B=Tx")] } }, // aprs/1300 + SF { key: "data-squelch", byte: 0x00535, kind: SK::Enum { shift: 6, width: 1, labels: &[(0, "Rx Band"), (1, "Tx/RX Band")] } }, // aprs/1302 + SF { key: "com-port-speed", byte: 0x00549, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "4800 bps"), (1, "9600 bps"), (2, "19200 bps"), (3, "38400 bps")] } }, // aprs/1297 + SF { key: "com-port-output", byte: 0x00548, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Off (Camera)"), (1, "GPS Out"), (2, "Packet"), (3, "Waypoint")] } }, // aprs/1298 + SF { key: "my-position", byte: 0x00541, kind: SK::Enum { shift: 0, width: 1, labels: &[(0, "GPS"), (1, "Manual")] } }, // aprs/1175 + SF { key: "tx-delay", byte: 0x00547, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "100 ms"), (1, "150 ms"), (2, "200 ms"), (3, "250 ms"), (4, "300 ms"), (5, "400 ms"), (6, "500 ms"), (7, "750 ms"), (8, "1000 ms")] } }, // aprs/1161 + SF { key: "digi-path", byte: 0x0052d, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Off"), (1, "WIDE1-1"), (2, "WIDE1-1, WIDE2-1"), (3, "Path 1"), (4, "Path 2"), (5, "Path 3"), (6, "Path 4"), (7, "Full Path 1"), (8, "Full Path 2")] } }, // digi-path + SF { key: "message-reply", byte: 0x00533, kind: SK::Bool { shift: 0, active_low: false } }, // aprs/1305 + SF { key: "message-reply-ssid", byte: 0x00520, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Non"), (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"), (42, "**")] } }, // aprs/1150 + SF { key: "waypoint-format", byte: 0x00562, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "NMEA9"), (1, "NMEA8"), (2, "NMEA7"), (3, "NMEA6")] } }, // aprs/1330 + SF { key: "waypoint-filter", byte: 0x00563, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "ALL"), (1, "Mobile"), (2, "Frequency"), (3, "Object/Item"), (4, "Digipeater"), (5, "VOIP"), (6, "Weather"), (7, "Yaesu"), (9, "Call Ringer"), (10, "Range Ringer")] } }, // aprs/1331 + SF { key: "tx-beacon-ringer", byte: 0x00539, kind: SK::Bool { shift: 0, active_low: false } }, // beacon/1288 + SF { key: "rx-beacon-ringer", byte: 0x0053b, kind: SK::Bool { shift: 0, active_low: false } }, // beacon/1013 + SF { key: "my-packet-ringer", byte: 0x0053d, kind: SK::Bool { shift: 0, active_low: false } }, // beacon/1010 + SF { key: "call-ringer", byte: 0x0053e, kind: SK::Bool { shift: 0, active_low: false } }, // beacon/1161 + SF { key: "message-voice-ringer", byte: 0x00540, kind: SK::Bool { shift: 0, active_low: false } }, // beacon/1065 + SF { key: "my-packet-popup", byte: 0x00561, kind: SK::Bool { shift: 6, active_low: false } }, // beacon/1333 + SF { key: "beacon-interval", byte: 0x0052e, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "30 seconds"), (1, "1 minute"), (2, "2 minutes"), (3, "3 minutes"), (4, "5 minutes"), (5, "10 minutes"), (6, "15 minutes"), (7, "20 minutes"), (8, "30 minutes"), (9, "60 minutes")] } }, // beacon/1123 + SF { key: "beacon-tx", byte: 0x00533, kind: SK::Enum { shift: 5, width: 2, labels: &[(0, "Off"), (1, "Smart"), (2, "On(Fix)")] } }, // beacon/1122 + SF { key: "smartbeaconing-type", byte: 0x0054a, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Off"), (1, "Type 1"), (2, "Type 2"), (3, "Type 3")] } }, // beacon/1213 + SF { key: "beacon-popup", byte: 0x00545, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Off"), (3, "3 seconds"), (5, "5 seconds"), (10, "10 seconds"), (255, "Hold")] } }, // beacon/1287 + SF { key: "filter-mic-e", byte: 0x00538, kind: SK::Bool { shift: 0, active_low: false } }, // beacon/1111 + SF { key: "filter-position", byte: 0x00538, kind: SK::Bool { shift: 1, active_low: false } }, // beacon/1155 + SF { key: "filter-weather", byte: 0x00538, kind: SK::Bool { shift: 2, active_low: false } }, // beacon/1156 + SF { key: "filter-object", byte: 0x00538, kind: SK::Bool { shift: 3, active_low: false } }, // beacon/1157 + SF { key: "filter-item", byte: 0x00538, kind: SK::Bool { shift: 4, active_low: false } }, // beacon/1158 + SF { key: "filter-status", byte: 0x00538, kind: SK::Bool { shift: 5, active_low: false } }, // beacon/1159 + SF { key: "filter-other", byte: 0x00538, kind: SK::Bool { shift: 6, active_low: false } }, // beacon/1160 + SF { key: "filter-altnet", byte: 0x00561, kind: SK::Bool { shift: 5, active_low: false } }, // beacon/1356 + SF { key: "range-limit", byte: 0x0052a, kind: SK::Enum16 { labels: &[(0, "Off"), (1, "1 km(mi)"), (2, "2 km(mi)"), (3, "3 km(mi)"), (4, "4 km(mi)"), (5, "5 km(mi)"), (6, "6 km(mi)"), (7, "7 km(mi)"), (8, "8 km(mi)"), (9, "9 km(mi)"), (10, "10 km(mi)"), (20, "20 km(mi)"), (30, "30 km(mi)"), (40, "40 km(mi)"), (50, "50 km(mi)"), (60, "60 km(mi)"), (70, "70 km(mi)"), (80, "80 km(mi)"), (90, "90 km(mi)"), (100, "100 km(mi)"), (110, "110 km(mi)"), (120, "120 km(mi)"), (130, "130 km(mi)"), (140, "140 km(mi)"), (150, "150 km(mi)"), (160, "160 km(mi)"), (170, "170 km(mi)"), (180, "180 km(mi)"), (190, "190 km(mi)"), (200, "200 km(mi)"), (210, "210 km(mi)"), (220, "220 km(mi)"), (230, "230 km(mi)"), (240, "240 km(mi)"), (250, "250 km(mi)"), (260, "260 km(mi)"), (270, "270 km(mi)"), (280, "280 km(mi)"), (290, "290 km(mi)"), (300, "300 km(mi)"), (310, "310 km(mi)"), (320, "320 km(mi)"), (330, "330 km(mi)"), (340, "340 km(mi)"), (350, "350 km(mi)"), (360, "360 km(mi)"), (370, "370 km(mi)"), (380, "380 km(mi)"), (390, "390 km(mi)"), (400, "400 km(mi)"), (410, "410 km(mi)"), (420, "420 km(mi)"), (430, "430 km(mi)"), (440, "440 km(mi)"), (450, "450 km(mi)"), (460, "460 km(mi)"), (470, "470 km(mi)"), (480, "480 km(mi)"), (490, "490 km(mi)"), (500, "500 km(mi)"), (510, "510 km(mi)"), (520, "520 km(mi)"), (530, "530 km(mi)"), (540, "540 km(mi)"), (550, "550 km(mi)"), (560, "560 km(mi)"), (570, "570 km(mi)"), (580, "580 km(mi)"), (590, "590 km(mi)"), (600, "600 km(mi)"), (610, "610 km(mi)"), (620, "620 km(mi)"), (630, "630 km(mi)"), (640, "640 km(mi)"), (650, "650 km(mi)"), (660, "660 km(mi)"), (670, "670 km(mi)"), (680, "680 km(mi)"), (690, "690 km(mi)"), (700, "700 km(mi)"), (710, "710 km(mi)"), (720, "720 km(mi)"), (730, "730 km(mi)"), (740, "740 km(mi)"), (750, "750 km(mi)"), (760, "760 km(mi)"), (770, "770 km(mi)"), (780, "780 km(mi)"), (790, "790 km(mi)"), (800, "800 km(mi)"), (810, "810 km(mi)"), (820, "820 km(mi)"), (830, "830 km(mi)"), (840, "840 km(mi)"), (850, "850 km(mi)"), (860, "860 km(mi)"), (870, "870 km(mi)"), (880, "880 km(mi)"), (890, "890 km(mi)"), (900, "900 km(mi)"), (910, "910 km(mi)"), (920, "920 km(mi)"), (930, "930 km(mi)"), (940, "940 km(mi)"), (950, "950 km(mi)"), (960, "960 km(mi)"), (970, "970 km(mi)"), (980, "980 km(mi)"), (990, "990 km(mi)"), (1000, "1000 km(mi)"), (1010, "1010 km(mi)"), (1020, "1020 km(mi)"), (1030, "1030 km(mi)"), (1040, "1040 km(mi)"), (1050, "1050 km(mi)"), (1060, "1060 km(mi)"), (1070, "1070 km(mi)"), (1080, "1080 km(mi)"), (1090, "1090 km(mi)"), (1100, "1100 km(mi)"), (1110, "1110 km(mi)"), (1120, "1120 km(mi)"), (1130, "1130 km(mi)"), (1140, "1140 km(mi)"), (1150, "1150 km(mi)"), (1160, "1160 km(mi)"), (1170, "1170 km(mi)"), (1180, "1180 km(mi)"), (1190, "1190 km(mi)"), (1200, "1200 km(mi)"), (1210, "1210 km(mi)"), (1220, "1220 km(mi)"), (1230, "1230 km(mi)"), (1240, "1240 km(mi)"), (1250, "1250 km(mi)"), (1260, "1260 km(mi)"), (1270, "1270 km(mi)"), (1280, "1280 km(mi)"), (1290, "1290 km(mi)"), (1300, "1300 km(mi)"), (1310, "1310 km(mi)"), (1320, "1320 km(mi)"), (1330, "1330 km(mi)"), (1340, "1340 km(mi)"), (1350, "1350 km(mi)"), (1360, "1360 km(mi)"), (1370, "1370 km(mi)"), (1380, "1380 km(mi)"), (1390, "1390 km(mi)"), (1400, "1400 km(mi)"), (1410, "1410 km(mi)"), (1420, "1420 km(mi)"), (1430, "1430 km(mi)"), (1440, "1440 km(mi)"), (1450, "1450 km(mi)"), (1460, "1460 km(mi)"), (1470, "1470 km(mi)"), (1480, "1480 km(mi)"), (1490, "1490 km(mi)"), (1500, "1500 km(mi)"), (1510, "1510 km(mi)"), (1520, "1520 km(mi)"), (1530, "1530 km(mi)"), (1540, "1540 km(mi)"), (1550, "1550 km(mi)"), (1560, "1560 km(mi)"), (1570, "1570 km(mi)"), (1580, "1580 km(mi)"), (1590, "1590 km(mi)"), (1600, "1600 km(mi)"), (1610, "1610 km(mi)"), (1620, "1620 km(mi)"), (1630, "1630 km(mi)"), (1640, "1640 km(mi)"), (1650, "1650 km(mi)"), (1660, "1660 km(mi)"), (1670, "1670 km(mi)"), (1680, "1680 km(mi)"), (1690, "1690 km(mi)"), (1700, "1700 km(mi)"), (1710, "1710 km(mi)"), (1720, "1720 km(mi)"), (1730, "1730 km(mi)"), (1740, "1740 km(mi)"), (1750, "1750 km(mi)"), (1760, "1760 km(mi)"), (1770, "1770 km(mi)"), (1780, "1780 km(mi)"), (1790, "1790 km(mi)"), (1800, "1800 km(mi)"), (1810, "1810 km(mi)"), (1820, "1820 km(mi)"), (1830, "1830 km(mi)"), (1840, "1840 km(mi)"), (1850, "1850 km(mi)"), (1860, "1860 km(mi)"), (1870, "1870 km(mi)"), (1880, "1880 km(mi)"), (1890, "1890 km(mi)"), (1900, "1900 km(mi)"), (1910, "1910 km(mi)"), (1920, "1920 km(mi)"), (1930, "1930 km(mi)"), (1940, "1940 km(mi)"), (1950, "1950 km(mi)"), (1960, "1960 km(mi)"), (1970, "1970 km(mi)"), (1980, "1980 km(mi)"), (1990, "1990 km(mi)"), (2000, "2000 km(mi)"), (2010, "2010 km(mi)"), (2020, "2020 km(mi)"), (2030, "2030 km(mi)"), (2040, "2040 km(mi)"), (2050, "2050 km(mi)"), (2060, "2060 km(mi)"), (2070, "2070 km(mi)"), (2080, "2080 km(mi)"), (2090, "2090 km(mi)"), (2100, "2100 km(mi)"), (2110, "2110 km(mi)"), (2120, "2120 km(mi)"), (2130, "2130 km(mi)"), (2140, "2140 km(mi)"), (2150, "2150 km(mi)"), (2160, "2160 km(mi)"), (2170, "2170 km(mi)"), (2180, "2180 km(mi)"), (2190, "2190 km(mi)"), (2200, "2200 km(mi)"), (2210, "2210 km(mi)"), (2220, "2220 km(mi)"), (2230, "2230 km(mi)"), (2240, "2240 km(mi)"), (2250, "2250 km(mi)"), (2260, "2260 km(mi)"), (2270, "2270 km(mi)"), (2280, "2280 km(mi)"), (2290, "2290 km(mi)"), (2300, "2300 km(mi)"), (2310, "2310 km(mi)"), (2320, "2320 km(mi)"), (2330, "2330 km(mi)"), (2340, "2340 km(mi)"), (2350, "2350 km(mi)"), (2360, "2360 km(mi)"), (2370, "2370 km(mi)"), (2380, "2380 km(mi)"), (2390, "2390 km(mi)"), (2400, "2400 km(mi)"), (2410, "2410 km(mi)"), (2420, "2420 km(mi)"), (2430, "2430 km(mi)"), (2440, "2440 km(mi)"), (2450, "2450 km(mi)"), (2460, "2460 km(mi)"), (2470, "2470 km(mi)"), (2480, "2480 km(mi)"), (2490, "2490 km(mi)"), (2500, "2500 km(mi)"), (2510, "2510 km(mi)"), (2520, "2520 km(mi)"), (2530, "2530 km(mi)"), (2540, "2540 km(mi)"), (2550, "2550 km(mi)"), (2560, "2560 km(mi)"), (2570, "2570 km(mi)"), (2580, "2580 km(mi)"), (2590, "2590 km(mi)"), (2600, "2600 km(mi)"), (2610, "2610 km(mi)"), (2620, "2620 km(mi)"), (2630, "2630 km(mi)"), (2640, "2640 km(mi)"), (2650, "2650 km(mi)"), (2660, "2660 km(mi)"), (2670, "2670 km(mi)"), (2680, "2680 km(mi)"), (2690, "2690 km(mi)"), (2700, "2700 km(mi)"), (2710, "2710 km(mi)"), (2720, "2720 km(mi)"), (2730, "2730 km(mi)"), (2740, "2740 km(mi)"), (2750, "2750 km(mi)"), (2760, "2760 km(mi)"), (2770, "2770 km(mi)"), (2780, "2780 km(mi)"), (2790, "2790 km(mi)"), (2800, "2800 km(mi)"), (2810, "2810 km(mi)"), (2820, "2820 km(mi)"), (2830, "2830 km(mi)"), (2840, "2840 km(mi)"), (2850, "2850 km(mi)"), (2860, "2860 km(mi)"), (2870, "2870 km(mi)"), (2880, "2880 km(mi)"), (2890, "2890 km(mi)"), (2900, "2900 km(mi)"), (2910, "2910 km(mi)"), (2920, "2920 km(mi)"), (2930, "2930 km(mi)"), (2940, "2940 km(mi)"), (2950, "2950 km(mi)"), (2960, "2960 km(mi)"), (2970, "2970 km(mi)"), (2980, "2980 km(mi)"), (2990, "2990 km(mi)"), (3000, "3000 km(mi)")] } }, // range-limit + SF { key: "altitude-unit", byte: 0x00503, kind: SK::Enum { shift: 0, width: 1, labels: &[(0, "meter"), (1, "feet")] } }, // beacon/1120 + SF { key: "distance-unit", byte: 0x00501, kind: SK::Enum { shift: 0, width: 1, labels: &[(0, "km"), (1, "mile")] } }, // beacon/1150 + SF { key: "position-unit", byte: 0x00500, kind: SK::Enum { shift: 0, width: 1, labels: &[(0, "DD MM.MM'"), (1, "DD MM'SS''")] } }, // beacon/1167 + SF { key: "speed-unit", byte: 0x00502, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "km/h"), (1, "mph"), (2, "knot")] } }, // beacon/1255 + SF { key: "temperature-unit", byte: 0x00505, kind: SK::Enum { shift: 0, width: 1, labels: &[(0, "C"), (1, "F")] } }, // beacon/1169 + SF { key: "rain-unit", byte: 0x00506, kind: SK::Enum { shift: 0, width: 1, labels: &[(0, "mm"), (1, "inch")] } }, // beacon/1170 + SF { key: "wind-unit", byte: 0x00507, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "m/s"), (1, "mph"), (2, "knot")] } }, // beacon/1171 + SF { key: "barometer-unit", byte: 0x00504, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "hPa"), (1, "mb"), (2, "mmHg"), (3, "inHg")] } }, // beacon/1281 + SF { key: "proportional-pathing", byte: 0x00533, kind: SK::Bool { shift: 7, active_low: false } }, // beacon/1293 + SF { key: "decay", byte: 0x00533, kind: SK::Bool { shift: 4, active_low: false } }, // beacon/1294 + SF { key: "tx-speed-course", byte: 0x00533, kind: SK::Bool { shift: 3, active_low: false } }, // beacon/1291 + SF { key: "tx-altitude", byte: 0x00533, kind: SK::Bool { shift: 2, active_low: false } }, // beacon/1016 + SF { key: "low-speed", byte: 0x00530, kind: SK::Enum { shift: 0, width: 8, labels: &[(1, "1 km/h(mi/h)"), (2, "2 km/h(mi/h)"), (3, "3 km/h(mi/h)"), (4, "4 km/h(mi/h)"), (5, "5 km/h(mi/h)"), (6, "6 km/h(mi/h)"), (7, "7 km/h(mi/h)"), (8, "8 km/h(mi/h)"), (9, "9 km/h(mi/h)"), (10, "10 km/h(mi/h)"), (11, "11 km/h(mi/h)"), (12, "12 km/h(mi/h)"), (13, "13 km/h(mi/h)"), (14, "14 km/h(mi/h)"), (15, "15 km/h(mi/h)"), (16, "16 km/h(mi/h)"), (17, "17 km/h(mi/h)"), (18, "18 km/h(mi/h)"), (19, "19 km/h(mi/h)"), (20, "20 km/h(mi/h)"), (21, "21 km/h(mi/h)"), (22, "22 km/h(mi/h)"), (23, "23 km/h(mi/h)"), (24, "24 km/h(mi/h)"), (25, "25 km/h(mi/h)"), (26, "26 km/h(mi/h)"), (27, "27 km/h(mi/h)"), (28, "28 km/h(mi/h)"), (29, "29 km/h(mi/h)"), (30, "30 km/h(mi/h)"), (31, "31 km/h(mi/h)"), (32, "32 km/h(mi/h)"), (33, "33 km/h(mi/h)"), (34, "34 km/h(mi/h)"), (35, "35 km/h(mi/h)"), (36, "36 km/h(mi/h)"), (37, "37 km/h(mi/h)"), (38, "38 km/h(mi/h)"), (39, "39 km/h(mi/h)"), (40, "40 km/h(mi/h)"), (41, "41 km/h(mi/h)"), (42, "42 km/h(mi/h)"), (43, "43 km/h(mi/h)"), (44, "44 km/h(mi/h)"), (45, "45 km/h(mi/h)"), (46, "46 km/h(mi/h)"), (47, "47 km/h(mi/h)"), (48, "48 km/h(mi/h)"), (49, "49 km/h(mi/h)"), (50, "50 km/h(mi/h)"), (51, "51 km/h(mi/h)"), (52, "52 km/h(mi/h)"), (53, "53 km/h(mi/h)"), (54, "54 km/h(mi/h)"), (55, "55 km/h(mi/h)"), (56, "56 km/h(mi/h)"), (57, "57 km/h(mi/h)"), (58, "58 km/h(mi/h)"), (59, "59 km/h(mi/h)"), (60, "60 km/h(mi/h)"), (61, "61 km/h(mi/h)"), (62, "62 km/h(mi/h)"), (63, "63 km/h(mi/h)"), (64, "64 km/h(mi/h)"), (65, "65 km/h(mi/h)"), (66, "66 km/h(mi/h)"), (67, "67 km/h(mi/h)"), (68, "68 km/h(mi/h)"), (69, "69 km/h(mi/h)"), (70, "70 km/h(mi/h)"), (71, "71 km/h(mi/h)"), (72, "72 km/h(mi/h)"), (73, "73 km/h(mi/h)"), (74, "74 km/h(mi/h)"), (75, "75 km/h(mi/h)"), (76, "76 km/h(mi/h)"), (77, "77 km/h(mi/h)"), (78, "78 km/h(mi/h)"), (79, "79 km/h(mi/h)"), (80, "80 km/h(mi/h)"), (81, "81 km/h(mi/h)"), (82, "82 km/h(mi/h)"), (83, "83 km/h(mi/h)"), (84, "84 km/h(mi/h)"), (85, "85 km/h(mi/h)"), (86, "86 km/h(mi/h)"), (87, "87 km/h(mi/h)"), (88, "88 km/h(mi/h)"), (89, "89 km/h(mi/h)"), (90, "90 km/h(mi/h)"), (91, "91 km/h(mi/h)"), (92, "92 km/h(mi/h)"), (93, "93 km/h(mi/h)"), (94, "94 km/h(mi/h)"), (95, "95 km/h(mi/h)"), (96, "96 km/h(mi/h)"), (97, "97 km/h(mi/h)"), (98, "98 km/h(mi/h)"), (99, "99 km/h(mi/h)")] } }, // beacon/1295 + SF { key: "position-ambiguity", byte: 0x00531, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Off"), (1, "1 digit"), (2, "2 digits"), (3, "3 digits"), (4, "4 digits")] } }, // beacon/1290 + SF { key: "rate-limit", byte: 0x0052f, kind: SK::Enum { shift: 0, width: 8, labels: &[(5, "5 seconds"), (6, "6 seconds"), (7, "7 seconds"), (8, "8 seconds"), (9, "9 seconds"), (10, "10 seconds"), (11, "11 seconds"), (12, "12 seconds"), (13, "13 seconds"), (14, "14 seconds"), (15, "15 seconds"), (16, "16 seconds"), (17, "17 seconds"), (18, "18 seconds"), (19, "19 seconds"), (20, "20 seconds"), (21, "21 seconds"), (22, "22 seconds"), (23, "23 seconds"), (24, "24 seconds"), (25, "25 seconds"), (26, "26 seconds"), (27, "27 seconds"), (28, "28 seconds"), (29, "29 seconds"), (30, "30 seconds"), (31, "31 seconds"), (32, "32 seconds"), (33, "33 seconds"), (34, "34 seconds"), (35, "35 seconds"), (36, "36 seconds"), (37, "37 seconds"), (38, "38 seconds"), (39, "39 seconds"), (40, "40 seconds"), (41, "41 seconds"), (42, "42 seconds"), (43, "43 seconds"), (44, "44 seconds"), (45, "45 seconds"), (46, "46 seconds"), (47, "47 seconds"), (48, "48 seconds"), (49, "49 seconds"), (50, "50 seconds"), (51, "51 seconds"), (52, "52 seconds"), (53, "53 seconds"), (54, "54 seconds"), (55, "55 seconds"), (56, "56 seconds"), (57, "57 seconds"), (58, "58 seconds"), (59, "59 seconds"), (60, "60 seconds"), (61, "61 seconds"), (62, "62 seconds"), (63, "63 seconds"), (64, "64 seconds"), (65, "65 seconds"), (66, "66 seconds"), (67, "67 seconds"), (68, "68 seconds"), (69, "69 seconds"), (70, "70 seconds"), (71, "71 seconds"), (72, "72 seconds"), (73, "73 seconds"), (74, "74 seconds"), (75, "75 seconds"), (76, "76 seconds"), (77, "77 seconds"), (78, "78 seconds"), (79, "79 seconds"), (80, "80 seconds"), (81, "81 seconds"), (82, "82 seconds"), (83, "83 seconds"), (84, "84 seconds"), (85, "85 seconds"), (86, "86 seconds"), (87, "87 seconds"), (88, "88 seconds"), (89, "89 seconds"), (90, "90 seconds"), (91, "91 seconds"), (92, "92 seconds"), (93, "93 seconds"), (94, "94 seconds"), (95, "95 seconds"), (96, "96 seconds"), (97, "97 seconds"), (98, "98 seconds"), (99, "99 seconds"), (100, "100 seconds"), (101, "101 seconds"), (102, "102 seconds"), (103, "103 seconds"), (104, "104 seconds"), (105, "105 seconds"), (106, "106 seconds"), (107, "107 seconds"), (108, "108 seconds"), (109, "109 seconds"), (110, "110 seconds"), (111, "111 seconds"), (112, "112 seconds"), (113, "113 seconds"), (114, "114 seconds"), (115, "115 seconds"), (116, "116 seconds"), (117, "117 seconds"), (118, "118 seconds"), (119, "119 seconds"), (120, "120 seconds"), (121, "121 seconds"), (122, "122 seconds"), (123, "123 seconds"), (124, "124 seconds"), (125, "125 seconds"), (126, "126 seconds"), (127, "127 seconds"), (128, "128 seconds"), (129, "129 seconds"), (130, "130 seconds"), (131, "131 seconds"), (132, "132 seconds"), (133, "133 seconds"), (134, "134 seconds"), (135, "135 seconds"), (136, "136 seconds"), (137, "137 seconds"), (138, "138 seconds"), (139, "139 seconds"), (140, "140 seconds"), (141, "141 seconds"), (142, "142 seconds"), (143, "143 seconds"), (144, "144 seconds"), (145, "145 seconds"), (146, "146 seconds"), (147, "147 seconds"), (148, "148 seconds"), (149, "149 seconds"), (150, "150 seconds"), (151, "151 seconds"), (152, "152 seconds"), (153, "153 seconds"), (154, "154 seconds"), (155, "155 seconds"), (156, "156 seconds"), (157, "157 seconds"), (158, "158 seconds"), (159, "159 seconds"), (160, "160 seconds"), (161, "161 seconds"), (162, "162 seconds"), (163, "163 seconds"), (164, "164 seconds"), (165, "165 seconds"), (166, "166 seconds"), (167, "167 seconds"), (168, "168 seconds"), (169, "169 seconds"), (170, "170 seconds"), (171, "171 seconds"), (172, "172 seconds"), (173, "173 seconds"), (174, "174 seconds"), (175, "175 seconds"), (176, "176 seconds"), (177, "177 seconds"), (178, "178 seconds"), (179, "179 seconds"), (180, "180 seconds")] } }, // beacon/1296 + SF { key: "range-ringer", byte: 0x0053f, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "Off"), (1, "1 km(mi)"), (2, "2 km(mi)"), (3, "3 km(mi)"), (4, "4 km(mi)"), (5, "5 km(mi)"), (6, "6 km(mi)"), (7, "7 km(mi)"), (8, "8 km(mi)"), (9, "9 km(mi)"), (10, "10 km(mi)"), (11, "11 km(mi)"), (12, "12 km(mi)"), (13, "13 km(mi)"), (14, "14 km(mi)"), (15, "15 km(mi)"), (16, "16 km(mi)"), (17, "17 km(mi)"), (18, "18 km(mi)"), (19, "19 km(mi)"), (20, "20 km(mi)"), (21, "21 km(mi)"), (22, "22 km(mi)"), (23, "23 km(mi)"), (24, "24 km(mi)"), (25, "25 km(mi)"), (26, "26 km(mi)"), (27, "27 km(mi)"), (28, "28 km(mi)"), (29, "29 km(mi)"), (30, "30 km(mi)"), (31, "31 km(mi)"), (32, "32 km(mi)"), (33, "33 km(mi)"), (34, "34 km(mi)"), (35, "35 km(mi)"), (36, "36 km(mi)"), (37, "37 km(mi)"), (38, "38 km(mi)"), (39, "39 km(mi)"), (40, "40 km(mi)"), (41, "41 km(mi)"), (42, "42 km(mi)"), (43, "43 km(mi)"), (44, "44 km(mi)"), (45, "45 km(mi)"), (46, "46 km(mi)"), (47, "47 km(mi)"), (48, "48 km(mi)"), (49, "49 km(mi)"), (50, "50 km(mi)"), (51, "51 km(mi)"), (52, "52 km(mi)"), (53, "53 km(mi)"), (54, "54 km(mi)"), (55, "55 km(mi)"), (56, "56 km(mi)"), (57, "57 km(mi)"), (58, "58 km(mi)"), (59, "59 km(mi)"), (60, "60 km(mi)"), (61, "61 km(mi)"), (62, "62 km(mi)"), (63, "63 km(mi)"), (64, "64 km(mi)"), (65, "65 km(mi)"), (66, "66 km(mi)"), (67, "67 km(mi)"), (68, "68 km(mi)"), (69, "69 km(mi)"), (70, "70 km(mi)"), (71, "71 km(mi)"), (72, "72 km(mi)"), (73, "73 km(mi)"), (74, "74 km(mi)"), (75, "75 km(mi)"), (76, "76 km(mi)"), (77, "77 km(mi)"), (78, "78 km(mi)"), (79, "79 km(mi)"), (80, "80 km(mi)"), (81, "81 km(mi)"), (82, "82 km(mi)"), (83, "83 km(mi)"), (84, "84 km(mi)"), (85, "85 km(mi)"), (86, "86 km(mi)"), (87, "87 km(mi)"), (88, "88 km(mi)"), (89, "89 km(mi)"), (90, "90 km(mi)"), (91, "91 km(mi)"), (92, "92 km(mi)"), (93, "93 km(mi)"), (94, "94 km(mi)"), (95, "95 km(mi)"), (96, "96 km(mi)"), (97, "97 km(mi)"), (98, "98 km(mi)"), (99, "99 km(mi)"), (100, "100 km(mi)")] } }, // beacon/1289 + SF { key: "status-text-tx-rate", byte: 0x00537, kind: SK::Enum { shift: 0, width: 8, labels: &[(0, "1/1"), (1, "1/2"), (2, "1/3"), (3, "1/4"), (4, "1/5"), (5, "1/6"), (6, "1/7"), (7, "1/8")] } }, // beacon/1292 + SF { key: "smartbeaconing-type-1-low-speed", byte: 0x0054b, kind: SK::Uint }, // sb1-0 + SF { key: "smartbeaconing-type-1-high-speed", byte: 0x0054c, kind: SK::Uint }, // sb1-1 + SF { key: "smartbeaconing-type-1-slow-rate", byte: 0x0054d, kind: SK::Uint }, // sb1-2 + SF { key: "smartbeaconing-type-1-fast-rate", byte: 0x0054e, kind: SK::Uint }, // sb1-3 + SF { key: "smartbeaconing-type-1-turn-angle", byte: 0x0054f, kind: SK::Uint }, // sb1-4 + SF { key: "smartbeaconing-type-1-turn-slope", byte: 0x00550, kind: SK::Uint }, // sb1-5 + SF { key: "smartbeaconing-type-1-turn-time", byte: 0x00551, kind: SK::Uint }, // sb1-6 + SF { key: "smartbeaconing-type-2-low-speed", byte: 0x00552, kind: SK::Uint }, // sb2-0 + SF { key: "smartbeaconing-type-2-high-speed", byte: 0x00553, kind: SK::Uint }, // sb2-1 + SF { key: "smartbeaconing-type-2-slow-rate", byte: 0x00554, kind: SK::Uint }, // sb2-2 + SF { key: "smartbeaconing-type-2-fast-rate", byte: 0x00555, kind: SK::Uint }, // sb2-3 + SF { key: "smartbeaconing-type-2-turn-angle", byte: 0x00556, kind: SK::Uint }, // sb2-4 + SF { key: "smartbeaconing-type-2-turn-slope", byte: 0x00557, kind: SK::Uint }, // sb2-5 + SF { key: "smartbeaconing-type-2-turn-time", byte: 0x00558, kind: SK::Uint }, // sb2-6 + SF { key: "smartbeaconing-type-3-low-speed", byte: 0x00559, kind: SK::Uint }, // sb3-0 + SF { key: "smartbeaconing-type-3-high-speed", byte: 0x0055a, kind: SK::Uint }, // sb3-1 + SF { key: "smartbeaconing-type-3-slow-rate", byte: 0x0055b, kind: SK::Uint }, // sb3-2 + SF { key: "smartbeaconing-type-3-fast-rate", byte: 0x0055c, kind: SK::Uint }, // sb3-3 + SF { key: "smartbeaconing-type-3-turn-angle", byte: 0x0055d, kind: SK::Uint }, // sb3-4 + SF { key: "smartbeaconing-type-3-turn-slope", byte: 0x0055e, kind: SK::Uint }, // sb3-5 + SF { key: "smartbeaconing-type-3-turn-time", byte: 0x0055f, kind: SK::Uint }, // sb3-6 +]; diff --git a/src-tauri/src/radios/yaesu_ftm300/gate.rs b/src-tauri/src/radios/yaesu_ftm300/gate.rs new file mode 100644 index 0000000..10bf2e7 --- /dev/null +++ b/src-tauri/src/radios/yaesu_ftm300/gate.rs @@ -0,0 +1,122 @@ +//! The step-3 gate: re-encode a real radio's memories out of a plaintext reading +//! of its own backup and compare with the bytes the radio wrote. +//! +//! The plaintext is RT Systems' tab-delimited export of the SAME file, so every +//! channel arrives here exactly as the operator programmed it. `#[ignore]`d and +//! env-gated because both inputs are a personal radio's data and live in the +//! gitignored scratchpad: +//! +//! ```text +//! FTM300_DAT=scratchpad/ftm300/captures/ftm300_01_real.dat \ +//! FTM300_TAB=scratchpad/ftm300/rts/s01_memories.tab \ +//! cargo test --lib yaesu_ftm300::gate -- --ignored --nocapture +//! ``` +//! +//! Three per-channel bits have no column in the channel database, so they are +//! masked and COUNTED rather than compared: skip, M-GRP and clock shift. VW +//! mode is the fourth (the database's YSF becomes DN). Everything else must +//! match byte for byte, names and the name-length table included. + +use super::sd_image::{encode_record, patch_backup, MEM_SLOTS}; +use crate::commands::export::ExpandedChannel; +use crate::models::{Channel, RadioModel}; + +fn row_to_channel(cols: &[&str], id: i64) -> Channel { + let mhz = |s: &str| s.trim().parse::().unwrap(); + let mode = match cols[5] { + "AM" => "AM", + "FM Narrow" => "NFM", + "DN" | "VW" => "YSF", + _ => "FM", + }; + let tone_mode = match cols[8] { + "Tone" => "Tone", + "T Sql" => "TSQL", + "DCS" => "DTCS", + _ => "off", + }; + let tone: f64 = cols[9].trim_end_matches(" Hz").parse().unwrap(); + Channel { + id, + name_short: Some(cols[7].to_string()), + rx_freq: mhz(cols[1]), + tx_freq: Some(mhz(cols[2])), + mode: Some(mode.into()), + tone_mode: Some(tone_mode.into()), + ctcss_uplink: Some(tone), + ctcss_downlink: Some(tone), + dcs_code: Some(cols[10].trim().to_string()), + power: Some(cols[14].replace("Medium", "Med")), + dcs_polarity: "NN".into(), + cross_mode: "Tone->Tone".into(), + source: "gate".into(), + ..Default::default() + } +} + +#[test] +#[ignore] +fn reencodes_a_real_radios_memories() { + let (Ok(dat), Ok(tab)) = (std::env::var("FTM300_DAT"), std::env::var("FTM300_TAB")) else { + eprintln!("set FTM300_DAT and FTM300_TAB"); + return; + }; + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let radio = std::fs::read(root.join(&dat)).unwrap(); + let text = std::fs::read_to_string(root.join(&tab)).unwrap(); + + let (mut exact, mut masked_only, mut wrong) = (0, 0, Vec::new()); + for line in text.lines().skip(1) { + let cols: Vec<&str> = line.split('\t').collect(); + let slot: usize = cols[0].parse().unwrap(); + let ec = ExpandedChannel { + channel: row_to_channel(&cols, slot as i64), + tg_label: None, + timeslot: None, + tg_number: None, + tg_call_type: None, + tg_inline: false, + }; + let ours = encode_record(&ec); + let at = 0x800 + (slot - 1) * 16; + let theirs = &radio[at..at + 16]; + if ours == theirs { + exact += 1; + continue; + } + let mask = |r: &[u8]| { + let mut m = r.to_vec(); + m[0] &= !0x30; // skip, M-GRP + m[10] &= !0x80; // clock shift + m[11] &= !0x20; // VW + m + }; + if mask(&ours) == mask(theirs) { + masked_only += 1; + } else { + wrong.push(format!("slot {slot}: ours {ours:02x?}\n radio {theirs:02x?}")); + } + } + eprintln!("{exact} exact, {masked_only} differ only in skip/M-GRP/clock-shift/VW, {} wrong", wrong.len()); + assert!(wrong.is_empty(), "{}", wrong.join("\n")); + + // Names and the name-length table: every name the radio holds, run through + // our tag cleaner and length code, must come back as the radio's own bytes. + // (Through `patch_backup` the app's duplicate-name disambiguation would + // rename Tim's six "NCARC"s, which is app behaviour, not encoding.) + let mut named = 0; + for slot in 0..MEM_SLOTS { + let raw = &radio[0x10000 + slot * 16..0x10000 + slot * 16 + 16]; + let code = u16::from_be_bytes([radio[0x4CB0 + slot * 2], radio[0x4CB1 + slot * 2]]); + let text = String::from_utf8_lossy(raw.split(|&b| b == 0xFF).next().unwrap()).into_owned(); + let (ours_raw, ours_code) = super::sd_image::name_fields(&super::sd_image::tag(&text)); + assert_eq!((&ours_raw[..], ours_code), (raw, code), "slot {} name {text:?}", slot + 1); + named += usize::from(!text.is_empty()); + } + eprintln!("{named} names and all {MEM_SLOTS} length codes identical"); + + // The real patcher keeps the 0x400 block RT Systems would blank. + let model = RadioModel { max_name_length: Some(16), ..Default::default() }; + let img = patch_backup(&radio, &[], &model, &Default::default()).unwrap(); + assert_eq!(&img[0x400..0x480], &radio[0x400..0x480]); +} diff --git a/src-tauri/src/radios/yaesu_ftm300/mod.rs b/src-tauri/src/radios/yaesu_ftm300/mod.rs new file mode 100644 index 0000000..030cd30 --- /dev/null +++ b/src-tauri/src/radios/yaesu_ftm300/mod.rs @@ -0,0 +1,217 @@ +//! Yaesu FTM-300DR driver (`driver_key = "yaesu_ftm300"`) — microSD programming. +//! +//! The fourth card radio. The operator backs the radio up to its card with +//! **SD CARD → BACKUP → WRITE TO SD → ALL**, which writes +//! `FTM300D/BACKUP/CLNFTM300D.dat`; [`sd_image`] patches the memories into that +//! file in place, and **READ FROM SD → ALL** loads it back. No cable, and no +//! third-party software in the loop. +//! +//! ## Research first +//! +//! Two published sources cover the file, and both were checked against Tim's +//! own radio rather than trusted ([[research-before-reverse-engineering]]): +//! +//! | source | what it gives | how it held up | +//! |---|---|---| +//! | CHIRP `chirp/drivers/ftm300d.py` (Sep 2026, experimental) | the 16-byte record, ~35 settings, the cable clone protocol | record right but for three fields: offset width, user CTCSS, the name-length table | +//! | xmutantson/ftm300dr-tools | the SD map, "no checksum", the name-length code | name-length code right; its 1-based DCS index wrong | +//! +//! The FT5D's driver contributed the workflow only — the layout is unrelated +//! (16-byte records and a parallel name table, against the FT5D's 32-byte +//! records), and the FT5D's 32-bit checksum has no counterpart found here. +//! +//! ## What is NOT here +//! +//! No cable path. CHIRP documents one (SCU-56/SCU-20 on the DATA jack, 131-byte +//! framed clone at 38400) and reports the clone image IS this file's layout, so +//! it is a possible later modality — but the card needs no cable at all. +//! `identify` fails by name rather than timing out on a port. +//! +//! Working notes: `scratchpad/ftm300/` (gitignored — raw dumps of a personal +//! radio), `FINDINGS.md` for the byte tables, `PLAN.md` for the menu census. + +pub(crate) mod sd_image; +pub(crate) mod settings; + +use crate::radios::driver::{ + CardSettingsWriter, CodeplugExporter, ExportRequest, RadioDriver, RadioIdentity, +}; + +pub(crate) struct YaesuFtm300; + +pub(crate) static DRIVER: YaesuFtm300 = YaesuFtm300; + +impl RadioDriver for YaesuFtm300 { + fn key(&self) -> &'static str { + "yaesu_ftm300" + } + + fn display_name(&self) -> &'static str { + "Yaesu FTM-300DR" + } + + /// Unused: nothing here opens a port. CHIRP's figure for the radio's DATA + /// jack clone is quoted so the number is not invented. + fn baud(&self) -> u32 { + 38400 + } + + /// There is no handshake, because there is no cable path. + fn identify(&self, _port: &str) -> Result { + Err("The FTM-300 is programmed from its microSD card, not over a cable. On the radio, \ + SD CARD → BACKUP → WRITE TO SD → ALL, put the card in this computer, and use the \ + card actions instead." + .into()) + } + + fn as_codeplug_exporter(&self) -> Option<&dyn CodeplugExporter> { + Some(self) + } +} + +impl CodeplugExporter for YaesuFtm300 { + fn export_format(&self) -> &'static str { + "yaesu_ftm300_sd" + } + + /// The radio's microSD `CLNFTM300D.dat`. + fn target_extensions(&self) -> &'static [&'static str] { + &["dat"] + } + + /// `path` is the operator's own `FTM300D/BACKUP/CLNFTM300D.dat`: read as the + /// template and written back in place, because that fixed path is what READ + /// FROM SD loads. The untouched original is kept beside it as + /// `CLNFTM300D.dat.orig`, written once and never overwritten, so a second + /// export cannot clobber the only pristine copy. + fn export(&self, path: &str, req: &ExportRequest) -> Result { + patch_in_place(path, |template| { + let mut image = sd_image::patch_backup(template, req.channels, req.model, &req.memory_group.iter().copied().collect())?; + // Non-channel settings from the codeplug's radio profile. A codeplug with + // no profile leaves every one exactly as the operator's backup had it. + if let Some(json) = req.profile_settings { + let parsed: serde_json::Value = serde_json::from_str(json) + .map_err(|e| format!("This profile's settings are not valid JSON: {e}"))?; + if let Some(map) = parsed.as_object() { + settings::apply_settings(&mut image, map); + } + } + Ok((image, req.channels.len())) + }) + } + + fn has_memory_group(&self) -> bool { + true + } + + fn as_card_settings_writer(&self) -> Option<&dyn CardSettingsWriter> { + Some(self) + } +} + +impl CardSettingsWriter for YaesuFtm300 { + /// The same file and rules as the export — in place, `.orig` kept once — + /// with only the settings applied. Every memory is the template's. + fn export_settings( + &self, + path: &str, + settings: &serde_json::Map, + ) -> Result { + patch_in_place(path, |template| { + sd_image::validate_backup(template)?; + let mut image = template.to_vec(); + let n = settings::apply_settings(&mut image, settings); + Ok((image, n)) + }) + } +} + +fn patch_in_place( + path: &str, + patch: impl FnOnce(&[u8]) -> Result<(Vec, usize), String>, +) -> Result { + crate::radios::card_file::patch_in_place( + path, + "Pick FTM300D/BACKUP/CLNFTM300D.dat on the radio's microSD card — back the radio \ + up first (SD CARD → BACKUP → WRITE TO SD → ALL) if it is not there.", + patch, + ) +} + +#[cfg(test)] +mod gate; +#[cfg(test)] +mod dev_export; + +#[cfg(test)] +mod tests { + use super::*; + use crate::radios::driver::DriverCapabilities; + + /// Step 6's gate: one export carries the memories AND the profile's + /// settings into the same file, and touches nothing else. + #[test] + fn an_export_carries_memories_and_settings_together() { + use crate::commands::export::ExpandedChannel; + use crate::models::Channel; + let dir = std::env::temp_dir().join(format!("ftm300-export-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("CLNFTM300D.dat"); + let mut template = vec![0x5Au8; sd_image::IMAGE_LEN]; + template[..6].copy_from_slice(b"AH071$"); + std::fs::write(&path, &template).unwrap(); + + let ec = ExpandedChannel { + channel: Channel { + id: 7, + name_short: Some("NCARC".into()), + rx_freq: 447.275, + tx_freq: Some(442.275), + dcs_polarity: "NN".into(), + cross_mode: "Tone->Tone".into(), + source: "test".into(), + ..Default::default() + }, + tg_label: None, + timeslot: None, + tg_number: None, + tg_call_type: None, + tg_inline: false, + }; + let ec2 = ExpandedChannel { + channel: Channel { id: 8, name_short: Some("OUTSIDE".into()), ..ec.channel.clone() }, + tg_label: None, + timeslot: None, + tg_number: None, + tg_call_type: None, + tg_inline: false, + }; + let refs = [&ec, &ec2]; + let model = crate::seed::test_model("FTM-300DR"); + // Channel 7 is in the codeplug's M-GRP, so its memory gets the flag; + // channel 8 is not. + let req = ExportRequest { + channels: &refs, + groups: &[], + model: &model, + profile_settings: Some(r#"{"lcd-brightness":"Mid","aprs-modem":true}"#), + memory_group: &[7], + }; + assert_eq!(DRIVER.export(path.to_str().unwrap(), &req).unwrap(), 2); + let out = std::fs::read(&path).unwrap(); + assert_eq!(&out[0x800..0x805], &[0x93, 0x02, 0x84, 0x47, 0x27]); + assert_eq!(out[0x810], 0x83, "a channel not chosen for M-GRP stays out of it"); + let s = settings::decode_settings(&out); + assert_eq!(s["lcd-brightness"], "Mid"); + assert_eq!(s["aprs-modem"], true); + assert_eq!(std::fs::read(path.with_extension("dat.orig")).unwrap(), template); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn advertises_export_only() { + let caps = DriverCapabilities::of(&DRIVER); + assert!(!caps.download_image && !caps.program_image && !caps.read_settings); + assert!(DRIVER.as_codeplug_exporter().is_some()); + } +} diff --git a/src-tauri/src/radios/yaesu_ftm300/sd_image.rs b/src-tauri/src/radios/yaesu_ftm300/sd_image.rs new file mode 100644 index 0000000..c0d8773 --- /dev/null +++ b/src-tauri/src/radios/yaesu_ftm300/sd_image.rs @@ -0,0 +1,569 @@ +//! FTM-300DR microSD codeplug writer: patch memories into the radio's own +//! `FTM300D/BACKUP/CLNFTM300D.dat`. +//! +//! ## Why patch instead of generate +//! +//! The file is the radio's whole configuration — memories, Home and PMS +//! channels, every Set Mode item, APRS, the station list, the GM/WIRES-X +//! state — and a block at `0x400` that RT Systems does not even carry +//! (see below). Generating one from scratch would revert all of it, so this +//! takes the operator's own backup as the template and rewrites exactly the +//! 999 regular memories in three parallel tables: +//! +//! | region | address | what | +//! |---|---|---| +//! | `memory[999]` | `0x0800` | 16-byte records | +//! | `name_len[999]` | `0x4CB0` | u16 BE `(1 << (16 - len)) - 1`, `0xFFFF` = no name | +//! | `name[999]` | `0x10000` | 16 ASCII bytes, `0xFF`-padded | +//! +//! Each table continues for 100 more entries (`1099` in all) — the 50 PMS +//! pairs, by count — which are left exactly as the operator had them. +//! +//! ## Provenance +//! +//! The record layout is CHIRP's `ftm300d.py` (2026-09), corrected against +//! Tim's own radio: all 119 of his channels decode to RT Systems' reading of +//! the same file with zero mismatches across 15 columns, and two designed probe +//! passes (34 + 37 channels written by RT Systems) pinned the fields his +//! codeplug never exercised. Three of CHIRP's fields are wrong for this radio +//! and are corrected here: the offset is a **u16** at bytes 12-13 (CHIRP: u8 at +//! 13), byte 11's low five bits are the **user CTCSS** (CHIRP: "occupied"), and +//! the name-length table is a length code, not a 0/0xFFFF flag (CHIRP writes +//! `0x0000`, which says "16 characters"). The byte tables are in +//! `scratchpad/ftm300/FINDINGS.md`. +//! +//! ## No checksum +//! +//! None was found: a probe that added 34 channels changed nothing outside the +//! three tables above, and xmutantson/ftm300dr-tools reports the radio +//! restores an edited file as-is. This is the claim the hardware ladder's +//! one-byte write exists to test — an identity copy cannot. +//! +//! ## The `0x400` block +//! +//! RT Systems' re-save of an unchanged file blanks `0x400-0x45F` and +//! `0x478-0x47F` to `0xFF` — radio-specific data CHIRP's clone protocol also +//! never sends. Patching the operator's own file keeps it by construction, and +//! [`patch_backup`] asserts it. + +use crate::commands::export::{expanded_names, tx_frequency, ExpandedChannel}; +use crate::models::RadioModel; + +pub(crate) const IMAGE_LEN: usize = 0x18000; +const MAGIC: &[u8] = b"AH071$"; + +const MEM_BASE: usize = 0x0800; +const MEM_STRIDE: usize = 16; +/// Regular memories. The tables run on for 100 more entries (PMS). +pub(crate) const MEM_SLOTS: usize = 999; +const NAME_LEN_BASE: usize = 0x4CB0; +const NAME_BASE: usize = 0x10000; +pub(crate) const NAME_LEN: usize = 16; + +/// What the radio itself holds in an empty slot — 979 of the 980 empty slots in +/// Tim's backup are exactly this (144.000 MHz, 12.5 kHz step, 100.0 Hz, +/// 600 kHz). CHIRP and xmutantson each use a different record; the radio's own +/// is the one to copy. +const EMPTY_RECORD: [u8; MEM_STRIDE] = [ + 0x01, 0x00, 0x01, 0x44, 0x00, 0x04, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x0F, 0x00, 0x0C, 0x00, 0x00, +]; + +/// The standard 50-tone CTCSS table, in tenths of a hertz. +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, +]; +/// 100.0 Hz — the tone an empty slot and an untoned channel carry. +const DEFAULT_TONE: u8 = 12; + +/// The 104 DCS codes; the stored index is 0-based (RT Systems `073` = 0x0F, +/// `754` = 0x67 — xmutantson's 1-based reading is wrong). +const DTCS_CODES: [u16; 104] = [ + 23, 25, 26, 31, 32, 36, 43, 47, 51, 53, 54, 65, 71, 72, 73, 74, 114, 115, 116, 122, 125, 131, + 132, 134, 143, 145, 152, 155, 156, 162, 165, 172, 174, 205, 212, 223, 225, 226, 243, 244, 245, + 246, 251, 252, 255, 261, 263, 265, 266, 271, 274, 306, 311, 315, 325, 331, 332, 343, 346, 351, + 356, 364, 365, 371, 411, 412, 413, 423, 431, 432, 445, 446, 452, 454, 455, 462, 464, 465, 466, + 503, 506, 516, 523, 526, 532, 546, 565, 606, 612, 624, 627, 631, 632, 654, 662, 664, 703, 712, + 723, 731, 732, 734, 743, 754, +]; + +// Byte 0. +const USED: u8 = 0x80; +/// The one user memory group, M-GRP (RT Systems "Memory Group"; set on 5 of +/// Tim's channels and on probe 222). +const MGRP: u8 = 0x10; +// Byte 1. +const AM: u8 = 0x10; +const NARROW: u8 = 0x20; +const DUPLEX_SIMPLEX: u8 = 0; +const DUPLEX_MINUS: u8 = 2; +const DUPLEX_PLUS: u8 = 3; +const DUPLEX_SPLIT: u8 = 4; +// Byte 5, high nibble. +const TMODE_OFF: u8 = 0; +const TMODE_TONE: u8 = 1; +const TMODE_TSQL: u8 = 2; +const TMODE_DCS: u8 = 4; +// Byte 11: DN (0x40), VW (0x20 with 0x40), user CTCSS in Hz/100 (low 5 bits). +const DIGITAL_DN: u8 = 0x40; +const USER_CTCSS_1500: u8 = 15; + +/// Largest repeater shift the radio offers (Set Mode RPT SHIFT FREQ, 0.00-99.95 +/// MHz in 50 kHz steps). A wider or off-grid TX/RX pair is stored as Split. +const MAX_SHIFT_HZ: i64 = 99_950_000; +const SHIFT_UNIT_HZ: i64 = 50_000; + +/// Refuse a file that is not an FTM-300 ALL backup, naming what it is. +pub(crate) fn validate_backup(image: &[u8]) -> Result<(), String> { + if image.len() != IMAGE_LEN { + return Err(format!( + "This is not an FTM-300 backup: it is {} bytes, and the radio's \ + FTM300D/BACKUP/CLNFTM300D.dat is always {IMAGE_LEN}. On the radio, \ + SD CARD → BACKUP → WRITE TO SD → ALL, then pick that file.", + image.len() + )); + } + if &image[..MAGIC.len()] != MAGIC { + return Err(format!( + "This backup is from a different radio (it starts {:?}; an FTM-300 \ + backup starts \"AH071$\"). The FTM-300 writer only patches FTM-300 backups.", + String::from_utf8_lossy(&image[..MAGIC.len()]) + )); + } + Ok(()) +} + +/// The radio's frequency encoding: the low nibble of the first byte and two +/// BCD bytes give the frequency in 10 kHz units (hundreds of MHz in the +/// nibble), and the first byte's HIGH nibble adds the sub-10 kHz part as flags +/// worth 5, 2.5, 1.25 and 0.625 kHz. +/// +/// Exact for every 5, 6.25 and 12.5 kHz channel (all on the 625 Hz grid). Off +/// that grid only the airband's +8.33 kHz point is known: it stores as `0xD`, +/// and Tim's radio DISPLAYS 127.80833 for it — the radio special-cases the code +/// rather than summing its bits. The other 8.33 kHz points (+1.67, +3.33, +/// +6.67) are not measured: summing bits put 127.81667 on the radio's screen as +/// 127.81625. [`patch_backup`] refuses those via [`encodable`] rather than write +/// a frequency the radio shows wrong. +fn encode_freq(hz: i64) -> [u8; 3] { + let hz = hz.max(0); + let tens = (hz / 10_000) as u32; // 10 kHz units, e.g. 14652 for 146.52 MHz + let mut rem = hz % 10_000; + let mut fine = 0u8; + if is_8k33(rem) { + fine = 0xD0; + rem = 0; + } + for (bit, add) in [(0x80u8, 5000i64), (0x40, 2500), (0x20, 1250), (0x10, 625)] { + if rem >= add { + fine |= bit; + rem -= add; + } + } + let d = |n: u32| (n % 10) as u8; + [ + fine | d(tens / 10_000), + (d(tens / 1000) << 4) | d(tens / 100), + (d(tens / 10) << 4) | d(tens), + ] +} + +/// The +8.33 kHz airband point within a 10 kHz step (x.x0833, x.x5833). +fn is_8k33(rem: i64) -> bool { + (8300..=8340).contains(&rem) +} + +/// Whether the radio is known to display `hz` as written: on the 625 Hz grid, +/// or the measured +8.33 kHz airband point. +pub(crate) fn encodable(hz: i64) -> bool { + let rem = hz.rem_euclid(10_000); + rem % 625 == 0 || is_8k33(rem) +} + +#[cfg(test)] +fn decode_freq(b: &[u8]) -> i64 { + let bcd = |x: u8| ((x >> 4) * 10 + (x & 0x0F)) as i64; + let tens = (b[0] & 0x0F) as i64 * 10_000 + bcd(b[1]) * 100 + bcd(b[2]); + let mut hz = tens * 10_000; + for (bit, add) in [(0x80u8, 5000i64), (0x40, 2500), (0x20, 1250), (0x10, 625)] { + if b[0] & bit != 0 { + hz += add; + } + } + hz +} + +/// Byte 0's low nibble. Measured on probes RT Systems wrote at 118.4, 146.52, +/// 222.1, 446 and 851.0125 MHz — the radio's five receive ranges. +fn band_code(hz: i64) -> u8 { + match hz { + h if h < 137_000_000 => 0, + h if h < 174_000_000 => 1, + h if h < 400_000_000 => 2, + h if h < 480_000_000 => 3, + _ => 4, + } +} + +/// The shift the radio keeps in a record that has none — RT Systems and the +/// radio both leave the band's default there (600 kHz on 2 m, 5 MHz on 70 cm, +/// 1.6 MHz in the 222 range, nothing on airband or above 480 MHz). Matching it +/// keeps our records byte-identical with the radio's own. +fn default_shift_units(hz: i64) -> u16 { + match band_code(hz) { + 1 => 12, + 2 => 32, + 3 => 100, + _ => 0, + } +} + +/// Tuning step for the record's low nibble of byte 5: 0 is 5 kHz (RT Systems +/// shows it as Auto too), 1 is 6.25, 2 is 8.33 (airband), 4 is 12.5. A memory +/// is a fixed frequency either way, so this matters only when the operator dials +/// away from it — but it must agree with the frequency's own grid. +fn step_code(hz: i64) -> u8 { + let rem = hz % 10_000; + if rem % 5000 == 0 { + 0 + } else if rem % 625 != 0 { + 2 // only the airband's 8.33 kHz channels fall off the 625 Hz grid + } else if rem % 2500 == 0 { + 4 + } else { + 1 + } +} + +fn tone_index(hz: f64) -> u8 { + let dhz = (hz * 10.0).round() as i32; + let mut best = 0usize; + for (i, &t) in TONES_DHZ.iter().enumerate() { + if (t as i32 - dhz).abs() < (TONES_DHZ[best] as i32 - dhz).abs() { + best = i; + } + } + best as u8 +} + +fn dcs_index(code: Option<&str>) -> Option { + let n: u16 = code?.trim().parse().ok()?; + DTCS_CODES.iter().position(|&c| c == n).map(|i| i as u8) +} + +/// (tone mode nibble, CTCSS index, DCS index). +/// +/// The radio does have cross modes (T DCS = 8, D Tone = 9, D Code = 7), but +/// they only work with Set Mode SQL EXPANSION switched on, which nothing here +/// controls — so Cross falls back the project-wide way +/// (`cross-tone-unsupported-fallback`): keep the TX tone, drop the RX side. +/// An untoned channel still carries a CTCSS index, as the radio's own records +/// do: the channel's tone if it has one, else 100.0 Hz. +fn encode_tone(c: &crate::models::Channel) -> (u8, u8, u8) { + let mode = c.tone_mode.as_deref().unwrap_or("off"); + let carried = c.ctcss_uplink.or(c.ctcss_downlink).map(tone_index).unwrap_or(DEFAULT_TONE); + let dcs = || dcs_index(c.dcs_code.as_deref()).unwrap_or(0); + if mode.eq_ignore_ascii_case("Tone") { + (TMODE_TONE, carried, 0) + } else if mode.eq_ignore_ascii_case("TSQL") { + (TMODE_TSQL, carried, 0) + } else if mode.eq_ignore_ascii_case("DTCS") { + (TMODE_DCS, carried, dcs()) + } else if mode.eq_ignore_ascii_case("Cross") { + let (tx, _) = c.cross_mode.split_once("->").unwrap_or(("", "")); + if tx.eq_ignore_ascii_case("DTCS") { + (TMODE_DCS, carried, dcs()) + } else if tx.eq_ignore_ascii_case("Tone") { + (TMODE_TONE, carried, 0) + } else { + (TMODE_OFF, carried, 0) + } + } else { + (TMODE_OFF, carried, 0) + } +} + +/// Byte 9's power field: 0 High (50 W), 1 Medium (25 W), 2 Low (5 W). +fn power_code(power: Option<&str>) -> u8 { + match power { + Some(p) if p.eq_ignore_ascii_case("Low") => 2, + Some(p) if p.eq_ignore_ascii_case("Med") || p.eq_ignore_ascii_case("Medium") => 1, + _ => 0, + } +} + +/// Encode one channel into its 16-byte record. +/// +/// Receive-only channels (airband, NOAA, GMRS…) are encoded like any other — +/// the radio enforces its own TX bands, and the warning belongs in the program +/// dialog, not in a byte the radio ignores (the FT5D's rule, for the same +/// reason). +pub(crate) fn encode_record(ec: &ExpandedChannel) -> [u8; MEM_STRIDE] { + let c = &ec.channel; + let rx = hz(c.rx_freq); + let tx = hz(tx_frequency(c)); + let diff = tx - rx; + + let (duplex, shift_units, tx_field) = if diff == 0 { + (DUPLEX_SIMPLEX, default_shift_units(rx), [0u8; 3]) + } else if diff.abs() <= MAX_SHIFT_HZ && diff % SHIFT_UNIT_HZ == 0 { + let dir = if diff > 0 { DUPLEX_PLUS } else { DUPLEX_MINUS }; + (dir, (diff.abs() / SHIFT_UNIT_HZ) as u16, [0u8; 3]) + } else { + (DUPLEX_SPLIT, default_shift_units(rx), encode_freq(tx)) + }; + + let mode = c.mode.as_deref().unwrap_or("FM"); + let is = |m: &str| mode.eq_ignore_ascii_case(m); + + let mut r = [0u8; MEM_STRIDE]; + r[0] = USED | band_code(rx); + r[1] = duplex + | if is("AM") { AM } else { 0 } + | if is("NFM") { NARROW } else { 0 }; + r[2..5].copy_from_slice(&encode_freq(rx)); + let (tmode, tone, dcs) = encode_tone(c); + r[5] = (tmode << 4) | step_code(rx); + r[6..9].copy_from_slice(&tx_field); + r[9] = (power_code(c.power.as_deref()) << 6) | tone; + r[10] = dcs; // clock shift (0x80) stays off + // A YSF channel is programmed DN-fixed, as the operator's own C4FM + // repeater channels are; AMS (byte 1, 0x40) stays off. + r[11] = USER_CTCSS_1500 | if is("YSF") { DIGITAL_DN } else { 0 }; + r[12..14].copy_from_slice(&shift_units.to_be_bytes()); + // 14/15: RX/TX DG-ID 00. + r +} + +/// The name-length code at `0x4CB0`: `(1 << (16 - len)) - 1`, so 1 character +/// is `0x7FFF`, 16 is `0x0000`, and no name at all is `0xFFFF`. The radio +/// reads THIS as the tag's length — a name with the code left at `0xFFFF` +/// shows blank. Confirmed on all 119 names in Tim's backup and on probes of 1, +/// 10 and 16 characters. +fn name_len_code(len: usize) -> u16 { + let len = len.min(NAME_LEN) as u32; + if len == 0 { + 0xFFFF + } else { + ((1u32 << (16 - len)) - 1) as u16 + } +} + +/// Printable ASCII, trailing spaces dropped (they would count toward the +/// length code), at most 16 characters. Lower case is kept — the radio stores +/// and displays it. +pub(super) fn tag(name: &str) -> String { + let s: String = name + .chars() + .filter(|c| c.is_ascii_graphic() || *c == ' ') + .take(NAME_LEN) + .collect(); + s.trim_end().to_string() +} + +/// A cleaned tag's two stored forms: the 16-byte name field and its length code. +pub(super) fn name_fields(name: &str) -> ([u8; NAME_LEN], u16) { + let mut field = [0xFFu8; NAME_LEN]; + field[..name.len()].copy_from_slice(name.as_bytes()); + (field, name_len_code(name.len())) +} + +fn write_slot(img: &mut [u8], slot: usize, record: &[u8; MEM_STRIDE], name: &str) { + let at = MEM_BASE + slot * MEM_STRIDE; + img[at..at + MEM_STRIDE].copy_from_slice(record); + let (field, code) = name_fields(name); + img[NAME_BASE + slot * NAME_LEN..NAME_BASE + (slot + 1) * NAME_LEN].copy_from_slice(&field); + img[NAME_LEN_BASE + slot * 2..NAME_LEN_BASE + slot * 2 + 2].copy_from_slice(&code.to_be_bytes()); +} + +/// Patch `template` with `channels` in memory-slot order (slot 1 first). +/// +/// Every one of the 999 regular slots is rewritten — a filled one with its +/// channel, the rest with the radio's own empty record — so nothing from the +/// operator's old memory list survives into the new one. Home, PMS, VFO and +/// every setting are the template's. There are no banks on this radio, so +/// channel lists flatten; its one user group, M-GRP, is a flag on each memory, +/// set for every channel whose id is in `mgrp` (chosen per codeplug). +/// +/// Overflow is an error rather than a silent truncation. +pub(crate) fn patch_backup( + template: &[u8], + channels: &[&ExpandedChannel], + model: &RadioModel, + mgrp: &std::collections::HashSet, +) -> Result, String> { + validate_backup(template)?; + if channels.len() > MEM_SLOTS { + return Err(format!( + "This codeplug has {} channels; the FTM-300 holds {MEM_SLOTS}. \ + Remove {} before exporting.", + channels.len(), + channels.len() - MEM_SLOTS + )); + } + // A frequency the radio would display wrong is refused, not rounded. + let unknown: Vec = channels + .iter() + .filter(|ec| !encodable(hz(ec.channel.rx_freq)) || !encodable(hz(tx_frequency(&ec.channel)))) + .map(|ec| format!("{:.5} MHz", ec.channel.rx_freq)) + .collect(); + if !unknown.is_empty() { + return Err(format!( + "The FTM-300's code for these 8.33 kHz airband frequencies has not been measured, \ + so they would show on the radio at a slightly different frequency: {}. Remove them \ + from this codeplug (127.80833-style channels are fine).", + unknown.join(", ") + )); + } + let mut img = template.to_vec(); + let names = expanded_names(channels.iter().copied(), model); + for (slot, (ec, name)) in channels.iter().zip(&names).enumerate() { + let mut record = encode_record(ec); + if mgrp.contains(&ec.channel.id) { + record[0] |= MGRP; + } + write_slot(&mut img, slot, &record, &tag(name)); + } + for slot in channels.len()..MEM_SLOTS { + write_slot(&mut img, slot, &EMPTY_RECORD, ""); + } + debug_assert_eq!(&img[0x400..0x480], &template[0x400..0x480]); + Ok(img) +} + +fn hz(mhz: f64) -> i64 { + (mhz * 1_000_000.0).round() as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Channel; + + fn chan(rx: f64, tx: Option) -> Channel { + Channel { + id: 1, + name_short: Some("TEST".into()), + rx_freq: rx, + tx_freq: tx, + dcs_polarity: "NN".into(), + cross_mode: "Tone->Tone".into(), + source: "test".into(), + ..Default::default() + } + } + + fn ec(c: Channel) -> ExpandedChannel { + ExpandedChannel { + channel: c, + tg_label: None, + timeslot: None, + tg_number: None, + tg_call_type: None, + tg_inline: false, + } + } + + #[test] + fn frequency_encoding_matches_the_radio() { + // Pairs read off Tim's backup and RT Systems' probe passes. + let cases: &[(i64, [u8; 3])] = &[ + (438_550_000, [0x04, 0x38, 0x55]), + (447_275_000, [0x84, 0x47, 0x27]), + (146_625_000, [0x81, 0x46, 0x62]), + (118_400_000, [0x01, 0x18, 0x40]), + (446_106_250, [0xA4, 0x46, 0x10]), + (446_112_500, [0x44, 0x46, 0x11]), + (851_012_500, [0x48, 0x51, 0x01]), + (127_808_330, [0xD1, 0x27, 0x80]), + (127_808_333, [0xD1, 0x27, 0x80]), + ]; + for &(hz, bytes) in cases { + assert_eq!(encode_freq(hz), bytes, "{hz}"); + } + for &(hz, bytes) in &cases[..7] { + assert_eq!(decode_freq(&bytes), hz, "{hz}"); + } + } + + /// Only the measured 8.33 kHz point is written; the rest are refused. + #[test] + fn unmeasured_airband_points_are_refused() { + assert!(encodable(127_808_330) && encodable(446_106_250) && encodable(851_012_500)); + assert!(!encodable(127_816_670) && !encodable(127_833_330)); + let mut c = chan(127.81667, None); + c.mode = Some("AM".into()); + let e = ec(c); + let mut t = vec![0xFFu8; IMAGE_LEN]; + t[..6].copy_from_slice(MAGIC); + let model = crate::seed::test_model("FTM-300DR"); + let err = patch_backup(&t, &[&e], &model, &Default::default()).unwrap_err(); + assert!(err.contains("127.81667"), "{err}"); + } + + #[test] + fn name_length_codes_match_the_radio() { + assert_eq!(name_len_code(0), 0xFFFF); + assert_eq!(name_len_code(1), 0x7FFF); + assert_eq!(name_len_code(3), 0x1FFF); + assert_eq!(name_len_code(7), 0x01FF); + assert_eq!(name_len_code(10), 0x003F); + assert_eq!(name_len_code(12), 0x000F); + assert_eq!(name_len_code(16), 0x0000); + } + + #[test] + fn repeater_and_split_records() { + // NCARC 447.275 −5 MHz TSQL 100.0, exactly as the radio stores it. + let mut c = chan(447.275, Some(442.275)); + c.tone_mode = Some("TSQL".into()); + c.ctcss_uplink = Some(100.0); + c.ctcss_downlink = Some(100.0); + assert_eq!( + encode_record(&ec(c)), + [0x83, 0x02, 0x84, 0x47, 0x27, 0x20, 0, 0, 0, 0x0C, 0, 0x0F, 0, 0x64, 0, 0] + ); + // ISS 437.800 / 145.990, Tone 67.0 — too wide for a shift, so Split. + let mut c = chan(437.8, Some(145.99)); + c.tone_mode = Some("Tone".into()); + c.ctcss_uplink = Some(67.0); + assert_eq!( + encode_record(&ec(c)), + [0x83, 0x04, 0x04, 0x37, 0x80, 0x10, 0x01, 0x45, 0x99, 0x00, 0, 0x0F, 0, 0x64, 0, 0] + ); + } + + #[test] + fn modes_power_and_dcs() { + let mut c = chan(447.825, Some(442.825)); + c.mode = Some("NFM".into()); + c.tone_mode = Some("DTCS".into()); + c.dcs_code = Some("073".into()); + c.ctcss_uplink = Some(88.5); + let r = encode_record(&ec(c)); + assert_eq!(r[1], 0x22); + assert_eq!(r[5] >> 4, 4); + assert_eq!(r[10], 0x0E); // 073 is index 14 (0x0E); see DTCS_CODES + let mut c = chan(118.4, None); + c.mode = Some("AM".into()); + c.power = Some("Low".into()); + let r = encode_record(&ec(c)); + assert_eq!((r[0], r[1], r[9] >> 6, r[13]), (0x80, 0x10, 2, 0)); + let mut c = chan(147.0, Some(147.6)); + c.mode = Some("YSF".into()); + c.power = Some("Med".into()); + let r = encode_record(&ec(c)); + assert_eq!((r[1], r[9] >> 6, r[11]), (0x03, 1, 0x4F)); + } + + #[test] + fn rejects_other_files() { + assert!(validate_backup(&vec![0u8; IMAGE_LEN]).is_err()); + assert!(validate_backup(&[0u8; 100]).is_err()); + let mut ok = vec![0xFFu8; IMAGE_LEN]; + ok[..6].copy_from_slice(MAGIC); + assert!(validate_backup(&ok).is_ok()); + } +} diff --git a/src-tauri/src/radios/yaesu_ftm300/settings.rs b/src-tauri/src/radios/yaesu_ftm300/settings.rs new file mode 100644 index 0000000..003c8d9 --- /dev/null +++ b/src-tauri/src/radios/yaesu_ftm300/settings.rs @@ -0,0 +1,285 @@ +//! FTM-300 Set Mode settings: decode from, and apply to, `CLNFTM300D.dat`. +//! +//! The field table and the profile form's schema are both generated from one +//! measurement (`scratchpad/ftm300/rts/gen_settings.py`, fed by the +//! bit-signature campaign `plan.py` / `runpass.py` / `solve.py`), so the decoder +//! and the form cannot drift. Every address was measured on RT Systems' own +//! writes of this radio's file; none is inferred from CHIRP or another radio. +//! +//! This radio packs settings several to a byte (the solver found booleans and +//! small enums sharing bytes all over the `0x80-0xFF` block), so every kind is a +//! bit field and every write is read-modify-write: the other bits in the byte +//! are someone else's setting. + +use serde_json::{Map, Value}; + +pub(crate) struct SF { + pub key: &'static str, + /// File offset into `CLNFTM300D.dat`. + pub byte: u32, + pub kind: SK, +} + +pub(crate) enum SK { + /// One bit. `active_low` stores ON as 0 — measured, never assumed. + Bool { shift: u8, active_low: bool }, + /// `width` bits starting at `shift`, mapped to labels by stored value. + Enum { + shift: u8, + width: u8, + labels: &'static [(u32, &'static str)], + }, + /// Big-endian u16 mapped to labels by stored value. + Enum16 { labels: &'static [(u32, &'static str)] }, + /// Fixed-length ASCII, padded with `pad` (0xFF for the radio call sign, + /// 0xCA for the APRS call sign — both read off Tim's backup). `punct` + /// allows `-` and `/`, which the radio call sign takes and APRS does not. + Text { len: u32, pad: u8, punct: bool }, + /// One byte holding the number itself (the SmartBeaconing parameters). + /// The schema carries the manual's range; `settings_bounds` enforces it. + Uint, +} + +include!("ftm300_settings_table.rs"); + +fn mask(width: u8) -> u32 { + (1u32 << width) - 1 +} + +fn label_for(labels: &[(u32, &str)], raw: u32) -> Value { + match labels.iter().find(|(v, _)| *v == raw) { + Some((_, l)) => Value::String((*l).to_string()), + // An unknown stored value comes back as `#N` rather than a wrong label, + // so a write of an untouched profile cannot normalise it away. Not a + // bare number: several enums here are LABELLED with numbers that are not + // their raw values (DTMF channel "1".."9" is raw 0..8), and "9" would + // read back as the label for raw 8. + None => Value::String(format!("#{raw}")), + } +} + +fn field_len(f: &SF) -> usize { + match &f.kind { + SK::Enum16 { .. } => 2, + SK::Text { len, .. } => *len as usize, + _ => 1, + } +} + +fn decode_field(img: &[u8], f: &SF) -> Value { + let at = f.byte as usize; + match &f.kind { + SK::Bool { shift, active_low } => Value::Bool(((img[at] >> shift) & 1 == 1) != *active_low), + SK::Enum { shift, width, labels } => { + label_for(labels, (img[at] as u32 >> shift) & mask(*width)) + } + SK::Enum16 { labels } => label_for(labels, u16::from_be_bytes([img[at], img[at + 1]]) as u32), + SK::Uint => Value::from(img[at]), + SK::Text { len, pad, .. } => { + let raw = &img[at..at + *len as usize]; + let cut = raw.iter().position(|&b| b == *pad || b == 0).unwrap_or(raw.len()); + Value::String(String::from_utf8_lossy(&raw[..cut]).trim_end().to_string()) + } + } +} + +/// Decode every known setting, shaped like the profile form. +pub(crate) fn decode_settings(img: &[u8]) -> Value { + let mut out = Map::new(); + for f in FTM300_SETTINGS_FIELDS { + if f.byte as usize + field_len(f) > img.len() { + continue; + } + out.insert(f.key.to_string(), decode_field(img, f)); + } + Value::Object(out) +} + +fn raw_for(labels: &[(u32, &str)], v: &Value) -> Option { + let s = v.as_str()?; + labels + .iter() + .find(|(_, l)| *l == s) + .map(|(raw, _)| *raw) + // An unknown raw value handed back by `label_for` round-trips as itself. + .or_else(|| s.strip_prefix('#')?.parse().ok()) +} + +/// Whether `s` fits a text field as the radio stores it: at most `len` +/// characters, every one from the field's charset. Checked, never filtered — +/// stripping the `-` out of "WW8L-9" would write a different call sign. +fn text_fits(s: &str, len: u32, punct: bool) -> bool { + s.chars().count() <= len as usize + && s.chars().all(|c| c.is_ascii_alphanumeric() || (punct && (c == '-' || c == '/'))) +} + +/// Write a profile's settings into the image, returning how many fields were +/// applied. Unknown keys and wrongly-shaped values are skipped, never guessed; +/// a field already holding the value is left untouched (no bytes rewritten). +pub(crate) fn apply_settings(img: &mut [u8], settings: &Map) -> usize { + let mut written = 0; + for f in FTM300_SETTINGS_FIELDS { + let Some(v) = settings.get(f.key) else { + continue; + }; + let at = f.byte as usize; + if at + field_len(f) > img.len() { + continue; + } + if decode_field(img, f) == *v { + written += 1; + continue; + } + let ok = match &f.kind { + SK::Bool { shift, active_low } => match v.as_bool() { + Some(b) => { + let bit = b != *active_low; + img[at] = (img[at] & !(1 << shift)) | ((bit as u8) << shift); + true + } + None => false, + }, + SK::Enum { shift, width, labels } => match raw_for(labels, v) { + Some(raw) if raw <= mask(*width) => { + let m = (mask(*width) << shift) as u8; + img[at] = (img[at] & !m) | ((raw << shift) as u8 & m); + true + } + _ => false, + }, + SK::Enum16 { labels } => match raw_for(labels, v) { + Some(raw) if raw <= 0xFFFF => { + img[at..at + 2].copy_from_slice(&(raw as u16).to_be_bytes()); + true + } + _ => false, + }, + SK::Uint => match v.as_u64() { + Some(n) if n <= 0xFF => { + img[at] = n as u8; + true + } + _ => false, + }, + // A value the field cannot hold is skipped, not cut down to fit. + SK::Text { len, pad, punct } => match v.as_str() { + Some(s) if text_fits(s, *len, *punct) => { + let s: Vec = s.to_ascii_uppercase().into_bytes(); + let field = &mut img[at..at + *len as usize]; + field.fill(*pad); + field[..s.len()].copy_from_slice(&s); + true + } + _ => false, + }, + }; + written += usize::from(ok); + } + written +} + +#[cfg(test)] +mod tests { + use super::*; + + fn schema() -> Vec { + serde_json::from_str(include_str!("../../ftm300_settings_schema.json")).unwrap() + } + + /// The generator emits both halves; this is the guard that they still agree. + #[test] + fn table_and_schema_agree() { + let fields: Vec = schema().into_iter().filter(|f| f["type"] != "section").collect(); + assert_eq!(fields.len(), FTM300_SETTINGS_FIELDS.len()); + for (s, f) in fields.iter().zip(FTM300_SETTINGS_FIELDS) { + assert_eq!(s["key"], f.key); + match &f.kind { + SK::Bool { .. } => assert_eq!(s["type"], "boolean", "{}", f.key), + SK::Enum { labels, .. } | SK::Enum16 { labels } => { + let opts: Vec<&str> = s["options"].as_array().unwrap().iter().map(|o| o.as_str().unwrap()).collect(); + let want: Vec<&str> = labels.iter().map(|(_, l)| *l).collect(); + assert_eq!(opts, want, "{}", f.key); + } + SK::Text { .. } => assert_eq!(s["type"], "text", "{}", f.key), + SK::Uint => assert_eq!(s["type"], "integer", "{}", f.key), + } + } + } + + /// Every value of every field written and read back, and nothing else in + /// the image moved — the shared-byte fields must not trample each other. + #[test] + fn every_value_round_trips_alone() { + let base: Vec = (0..super::super::sd_image::IMAGE_LEN).map(|i| (i * 37 % 251) as u8).collect(); + for f in FTM300_SETTINGS_FIELDS { + let values: Vec = match &f.kind { + SK::Bool { .. } => vec![Value::Bool(true), Value::Bool(false)], + SK::Enum { labels, .. } | SK::Enum16 { labels } => { + labels.iter().map(|(_, l)| Value::String((*l).into())).collect() + } + SK::Text { .. } => vec![Value::String("WW8L".into())], + SK::Uint => vec![Value::from(1), Value::from(255)], + }; + for v in values { + let mut img = base.clone(); + let mut m = Map::new(); + m.insert(f.key.into(), v.clone()); + assert_eq!(apply_settings(&mut img, &m), 1, "{} {v}", f.key); + assert_eq!(decode_settings(&img)[f.key], v, "{}", f.key); + let moved: Vec = (0..img.len()).filter(|&i| img[i] != base[i]).collect(); + let at = f.byte as usize; + assert!(moved.iter().all(|&i| i >= at && i < at + field_len(f)), "{} moved {moved:x?}", f.key); + } + } + } + + /// Unchanged values touch no bytes: decoding an image and writing the + /// result straight back must be a no-op. + #[test] + fn writing_back_what_was_read_changes_nothing() { + let img: Vec = (0..super::super::sd_image::IMAGE_LEN).map(|i| (i * 91 % 253) as u8).collect(); + let decoded = decode_settings(&img); + let mut out = img.clone(); + apply_settings(&mut out, decoded.as_object().unwrap()); + assert_eq!(out, img); + } + + /// A call sign the field cannot hold is refused whole, never trimmed. + #[test] + fn a_text_value_that_does_not_fit_is_not_written() { + let base = vec![0u8; super::super::sd_image::IMAGE_LEN]; + for (key, bad) in [("aprs-callsign", "WW8L-9"), ("aprs-callsign", "WW8LXYZ"), ("callsign", "WW8L 1")] { + let mut img = base.clone(); + let mut m = Map::new(); + m.insert(key.into(), Value::String(bad.into())); + assert_eq!(apply_settings(&mut img, &m), 0, "{key} {bad}"); + assert_eq!(img, base, "{key} {bad}"); + } + let mut img = base.clone(); + let mut m = Map::new(); + m.insert("callsign".into(), Value::String("ww8l/m".into())); + assert_eq!(apply_settings(&mut img, &m), 1); + assert_eq!(decode_settings(&img)["callsign"], "WW8L/M"); + } + + /// An unknown stored value survives a round trip as itself, even in an + /// enum whose labels are numbers that are not the raw values. + #[test] + fn an_unknown_raw_value_round_trips_as_itself() { + let f = FTM300_SETTINGS_FIELDS.iter().find(|f| f.key == "band-a-dtmf-channel").unwrap(); + let mut img = vec![0u8; super::super::sd_image::IMAGE_LEN]; + img[f.byte as usize] = 9; // labels "1".."9" are raw 0..8 + let v = decode_settings(&img)[f.key].clone(); + assert_eq!(v, "#9"); + let mut other = vec![0u8; super::super::sd_image::IMAGE_LEN]; + let mut m = Map::new(); + m.insert(f.key.into(), v); + apply_settings(&mut other, &m); + assert_eq!(other[f.byte as usize], 9); + } + + #[test] + fn an_aprs_radio_gets_aprs_settings() { + assert!(schema().iter().filter(|f| f["group"] == "APRS").count() >= 1); + } +} diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index a5a0091..e30ed54 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -1191,6 +1191,62 @@ fn models() -> Vec { connection_type: "USB programming cable (no driver needed on macOS)", non_channel_settings_schema: MD380_SETTINGS_SCHEMA, }, + // -------------------------------------------------------- + // Yaesu FTM-300DR (#48) — C4FM + analog FM dual-band mobile, + // 999 memories (plus 50 PMS pairs and 5 Home channels the + // writer leaves alone), 16-character tags, 50 W. + // Programmed from its own microSD card, like the FT5D: + // `yaesu_ftm300_sd` patches the radio's own + // FTM300D/BACKUP/CLNFTM300D.dat in place, and READ FROM SD + // → ALL loads it back; see radios/yaesu_ftm300/. + // NOTES: (a) no banks and no scan lists, so channel lists flatten. + // Its one user group, M-GRP, is a per-memory flag chosen channel by + // channel on the codeplug page (codeplug_memory_group, 0020). (b) tx_bands is + // the DR's US 144–148 / 430–450; the DE is 144–146 / + // 430–440. (c) rx_bands is the manual's coverage with the US + // cellular blocks (824–849, 869–894 MHz) cut out — CHIRP's + // edges, unverified until the ladder's band probe; a + // frequency outside it would be an empty slot on the radio. + // -------------------------------------------------------- + ModelSeed { + manufacturer: "Yaesu", + model: "FTM-300DR", + driver_key: Some("yaesu_ftm300"), + programming_ui: Some("generic"), + display_name: "Yaesu FTM-300DR", + analog_capable: true, + dmr_capable: false, + dstar_capable: false, + ysf_capable: true, + nxdn_capable: false, + p25_capable: false, + m17_capable: false, + aprs_capable: true, + covers_hf: false, + covers_vhf: true, + covers_uhf: true, + covers_220: false, + covers_900: false, + freq_min: 144.0, + freq_max: 450.0, + tx_bands: Some("[[144.0,148.0],[430.0,450.0]]"), + rx_bands: Some("[[108.0,824.0],[849.0,869.0],[894.0,999.99]]"), + memory_channels: 999, + zones_supported: false, + max_zones: None, + channels_per_zone: None, + scan_lists_supported: false, + max_scan_lists: None, + banks_supported: false, + max_name_length: 16, + export_format: "yaesu_ftm300_sd", + connection_type: "microSD", + // GENERATED by scratchpad/ftm300/rts/gen_settings.py — the same spec + // emits radios/yaesu_ftm300/ftm300_settings_table.rs, so the form and + // the decoder cannot drift. Read out of and written back into the + // microSD backup, not over a cable. + non_channel_settings_schema: include_str!("ftm300_settings_schema.json"), + }, ] } diff --git a/src/components/codeplugs/MemoryGroupModal.tsx b/src/components/codeplugs/MemoryGroupModal.tsx new file mode 100644 index 0000000..549a07f --- /dev/null +++ b/src/components/codeplugs/MemoryGroupModal.tsx @@ -0,0 +1,148 @@ +import { useEffect, useMemo, useState } from "react"; +import { Search } from "lucide-react"; +import { Modal } from "../overlays"; +import { Button, TextInput, Spinner } from "../ui"; +import { api, withToast } from "../../lib/api"; +import type { Channel } from "../../lib/types"; +import { loadCodeplugChannelPool } from "../../lib/codeplugPool"; + +const channelName = (c: Channel) => + c.name_long || c.name_short || c.callsign || `CH ${c.id}`; + +/** + * Choose, channel by channel, which of a codeplug's channels go in the radio's + * one user memory group (the FTM-300's M-GRP). Membership is per codeplug. + */ +export function MemoryGroupModal({ + open, + onClose, + title, + codeplugId, + channelListIds, + onChanged, +}: { + open: boolean; + onClose: () => void; + title: string; + codeplugId: number; + channelListIds: number[]; + onChanged: (count: number) => void; +}) { + const [channels, setChannels] = useState([]); + const [members, setMembers] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [busy, setBusy] = useState(false); + const listKey = channelListIds.join(","); + + useEffect(() => { + if (!open) return; + setSearch(""); + setLoading(true); + (async () => { + const pool = await loadCodeplugChannelPool(listKey ? listKey.split(",").map(Number) : []); + const ids = await api.getCodeplugMemoryGroup(codeplugId); + setChannels(pool); + setMembers(new Set(ids)); + setLoading(false); + })(); + }, [open, codeplugId, listKey]); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return channels; + return channels.filter((c) => channelName(c).toLowerCase().includes(q)); + }, [channels, search]); + + // The backend reports only members still in the codeplug, so this is the + // same count the page shows and the export writes. + const inPool = useMemo( + () => channels.filter((c) => members.has(c.id)).length, + [channels, members], + ); + + const set = async (batch: Channel[], member: boolean) => { + if (batch.length === 0) return; + setBusy(true); + const ok = await withToast( + api.setCodeplugMemoryGroup(codeplugId, batch.map((c) => c.id), member), + ); + setBusy(false); + if (ok === undefined) return; + const next = new Set(members); + for (const c of batch) { + if (member) next.add(c.id); + else next.delete(c.id); + } + setMembers(next); + onChanged(channels.filter((c) => next.has(c.id)).length); + }; + + const shownIn = filtered.filter((c) => members.has(c.id)); + const shownOut = filtered.filter((c) => !members.has(c.id)); + + return ( + +
+
+ + setSearch(e.target.value)} + /> +
+ +
+ + {inPool} of {channels.length} channels in the group + +
+ + +
+
+ +
+ {loading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+ {channels.length === 0 ? "This codeplug has no channels yet." : "No channels match your search."} +
+ ) : ( + filtered.map((c) => ( + + )) + )} +
+
+
+ ); +} diff --git a/src/components/codeplugs/ScanListAssignChannelsModal.tsx b/src/components/codeplugs/ScanListAssignChannelsModal.tsx index 939d39b..6c162ea 100644 --- a/src/components/codeplugs/ScanListAssignChannelsModal.tsx +++ b/src/components/codeplugs/ScanListAssignChannelsModal.tsx @@ -5,6 +5,7 @@ import { Modal } from "../overlays"; import { Button, TextInput, Spinner } from "../ui"; import { api, withToast } from "../../lib/api"; import type { Channel, ScanListSummary } from "../../lib/types"; +import { loadCodeplugChannelPool } from "../../lib/codeplugPool"; const channelName = (c: Channel) => c.name_long || c.name_short || c.callsign || `CH ${c.id}`; @@ -47,21 +48,7 @@ export function ScanListAssignChannelsModal({ setSearch(""); setLoading(true); (async () => { - // Codeplug channel pool: every assigned list's channels, first-seen wins - // (mirrors the program builder's dedupe). - const lists = await Promise.all( - channelListIds.map((lid) => api.getChannelListChannels(lid)), - ); - const pool: Channel[] = []; - const seen = new Set(); - for (const list of lists) { - for (const c of list) { - if (!seen.has(c.id)) { - seen.add(c.id); - pool.push(c); - } - } - } + const pool = await loadCodeplugChannelPool(channelListIds); const rows = await api.getCodeplugChannelScanLists(codeplugId); setChannels(pool); setAssigned(new Map(rows.map((r) => [r.channel_id, r.scan_list_id]))); diff --git a/src/components/profiles/ProfileEditor.tsx b/src/components/profiles/ProfileEditor.tsx index 617e62d..7c6f908 100644 --- a/src/components/profiles/ProfileEditor.tsx +++ b/src/components/profiles/ProfileEditor.tsx @@ -235,6 +235,7 @@ const CARD_SETTINGS_READERS: Record< yaesu_ft5d_sd: api.readFt5dSettingsFromBackup, icom_id52_icf: api.readId52SettingsFromCard, kenwood_thd75_sd: api.readThd75SettingsFromCard, + yaesu_ftm300_sd: api.readFtm300SettingsFromBackup, }; /** diff --git a/src/lib/api.ts b/src/lib/api.ts index 52c2393..1457fc7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -350,6 +350,11 @@ export const api = { invoke("set_channel_scan_list", { codeplugId, channelId, scanListId }), clearChannelScanList: (codeplugId: number, channelId: number) => invoke("clear_channel_scan_list", { codeplugId, channelId }), + // The radio's one user memory group (the FTM-300's M-GRP), chosen per channel. + getCodeplugMemoryGroup: (codeplugId: number) => + invoke("get_codeplug_memory_group", { codeplugId }), + setCodeplugMemoryGroup: (codeplugId: number, channelIds: number[], member: boolean) => + invoke("set_codeplug_memory_group", { codeplugId, channelIds, member }), // ---- export ---- exportPreview: (codeplugId: number) => @@ -390,6 +395,9 @@ export const api = { // card. Same decoded shape again. See radios/kenwood_thd75/settings.rs. readThd75SettingsFromCard: (path: string) => invoke("read_thd75_settings_from_card", { path }), + // The FTM-300's CLNFTM300D.dat; same shape as the FT5D's. See radios/yaesu_ftm300/settings.rs. + readFtm300SettingsFromBackup: (path: string) => + invoke("read_ftm300_settings_from_backup", { path }), // A card radio's settings alone, patched into its own file on the card — // every memory left as the file has it. The radio loads it from its menu. writeCardSettings: (profileId: number, path: string) => diff --git a/src/lib/codeplugPool.ts b/src/lib/codeplugPool.ts new file mode 100644 index 0000000..761cb8a --- /dev/null +++ b/src/lib/codeplugPool.ts @@ -0,0 +1,22 @@ +import { api } from "./api"; +import type { Channel } from "./types"; + +/** + * A codeplug's channel pool: every assigned channel list's channels, in list + * order, first-seen wins — the same dedupe the export and the program builder + * use, so a checklist built on it offers exactly the channels that get written. + */ +export async function loadCodeplugChannelPool(channelListIds: number[]): Promise { + const lists = await Promise.all(channelListIds.map((id) => api.getChannelListChannels(id))); + const pool: Channel[] = []; + const seen = new Set(); + for (const list of lists) { + for (const c of list) { + if (!seen.has(c.id)) { + seen.add(c.id); + pool.push(c); + } + } + } + return pool; +} diff --git a/src/lib/radioProgramming.ts b/src/lib/radioProgramming.ts index a3d0171..b900b24 100644 --- a/src/lib/radioProgramming.ts +++ b/src/lib/radioProgramming.ts @@ -197,6 +197,26 @@ const MEDIA_WRITES: Record = { carries: "your APRS and D-STAR call signs, your call sign history, your Bluetooth device name, and any APRS positions stored in the radio — beacon objects are held to 1/10000 of a minute, which for a home station is a street address", }, + // The fourth card radio: one fixed file, patched in place like the FT5D's. + // Menu path is the FTM-300 Advance Manual's (Setup Menu: SD CARD). ALL is + // the backup the writer is measured against; what MEMORY alone reads back + // out of the same file is unverified. + yaesu_ftm300_sd: { + action: "Write to SD card…", + pickTitle: "Select FTM300D/BACKUP/CLNFTM300D.dat on the radio’s microSD card", + filterName: "FTM-300 backup", + extensions: ["dat"], + before: + "On the radio, save everything to the card: press and hold [F(SETUP)] → SD CARD → 1 BACKUP → WRITE TO SD → ALL → OK. Then put the card in this computer and pick FTM300D/BACKUP/CLNFTM300D.dat below.", + after: + "Put the card back in the radio and load it: press and hold [F(SETUP)] → SD CARD → 1 BACKUP → READ FROM SD → ALL → OK.", + found: + "It already holds a backup, so there is nothing to pick — just check it is current (the radio writes it with [F(SETUP)] → SD CARD → 1 BACKUP → WRITE TO SD → ALL).", + preserves: + "The FTM-300 has no banks, so channel lists become one memory list in order. Everything else already on the radio — Home and PMS channels, APRS, GPS, WIRES-X and every Set Mode item — is left untouched. The first write saves your untouched file beside it as CLNFTM300D.dat.orig; later writes never overwrite that pristine copy.", + carries: + "your call sign, your APRS call sign and SSID, a fixed My Position if you have set one, and the APRS station list and messages the radio has heard — including other stations' positions", + }, }; /// How a model is programmed from removable media, or null if it is a cable @@ -211,6 +231,26 @@ export function mediaWriteForFormat(format: string | null): MediaWrite | null { return (format && MEDIA_WRITES[format]) || null; } +/// A radio with one user memory group that each memory is either in or out of +/// (the FTM-300's M-GRP). The codeplug page shows a box for it and the export +/// carries the chosen channels. Keyed on the format, like `MEDIA_WRITES`. +export interface MemoryGroup { + title: string; + /// One line under the box's channel count. + help: string; +} + +const MEMORY_GROUPS: Record = { + yaesu_ftm300_sd: { + title: "M-GRP (Memory Group)", + help: "The radio's one user group. Pick channels one by one; on the radio, press and hold [BAND] in the memory list to see them.", + }, +}; + +export function memoryGroupFor(model: RadioModel | null): MemoryGroup | null { + return MEMORY_GROUPS[model?.export_format ?? ""] ?? null; +} + /// Fetch a driver's capability flags. They are derived from the Rust trait /// impls and never change at runtime, so results are cached per key for the /// life of the process and each key is fetched at most once. diff --git a/src/pages/Codeplugs.tsx b/src/pages/Codeplugs.tsx index afa2b66..151401c 100644 --- a/src/pages/Codeplugs.tsx +++ b/src/pages/Codeplugs.tsx @@ -44,6 +44,8 @@ import { ExportDialog } from "../components/codeplugs/ExportDialog"; import { programDialogFor } from "../components/codeplugs/programDialogs"; import { AssignModal, type AssignItem } from "../components/codeplugs/AssignModal"; import { ScanListAssignChannelsModal } from "../components/codeplugs/ScanListAssignChannelsModal"; +import { MemoryGroupModal } from "../components/codeplugs/MemoryGroupModal"; +import { memoryGroupFor } from "../lib/radioProgramming"; // ============================================================ // Codeplug grid @@ -208,6 +210,8 @@ export function CodeplugDetail() { const [editOpen, setEditOpen] = useState(false); const [addChannelOpen, setAddChannelOpen] = useState(false); const [addScanOpen, setAddScanOpen] = useState(false); + const [memoryGroupCount, setMemoryGroupCount] = useState(0); + const [memoryGroupOpen, setMemoryGroupOpen] = useState(false); const [assignScanList, setAssignScanList] = useState( null, ); @@ -250,6 +254,15 @@ export function CodeplugDetail() { // `radio_models.programming_ui`, never by the model name — see // `components/codeplugs/programDialogs.ts` (Chunk 3.7). const ProgramDialog = programDialogFor(model?.programming_ui ?? null); + // The radio's one user memory group (the FTM-300's M-GRP), if it has one. + const memoryGroup = memoryGroupFor(model ?? null); + // Loaded only for a radio that has one. Re-read when the channel lists change: + // the count is of members still in the codeplug. + const listIdsKey = channelLists.map((l) => l.id).join(","); + useEffect(() => { + if (!memoryGroup) return; + api.getCodeplugMemoryGroup(codeplugId).then((ids) => setMemoryGroupCount(ids.length)); + }, [memoryGroup, codeplugId, listIdsKey]); const totalChannels = useMemo( () => channelLists.reduce((sum, l) => sum + l.channel_count, 0), @@ -523,6 +536,29 @@ export function CodeplugDetail() { )} + {memoryGroup && ( + } + action={ + + } + > +
+
+ {memoryGroupCount === 0 + ? "No channels in the group." + : `${memoryGroupCount} channel${memoryGroupCount === 1 ? "" : "s"} in the group`} +
+

+ {memoryGroup.help} +

+
+
+ )} + {/* Channel lists — full width */}
{}} /> + {memoryGroup && ( + setMemoryGroupOpen(false)} + title={memoryGroup.title} + codeplugId={codeplugId} + channelListIds={channelLists.map((l) => l.id)} + onChanged={setMemoryGroupCount} + /> + )} ); }