diff --git a/README.md b/README.md index ad0cf17..c8fa9da 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ card — or exported as CSV for tools that expect it. | **AnyTone AT-D890UV** | DMR + Analog | VHF / UHF | Direct USB — channels, zones, scan lists, settings, call-sign DB | 4000 channels; full DMR: zones, talkgroups, 308k-entry caller-ID database | | **Yaesu FT5D** | C4FM (System Fusion) + Analog | VHF / UHF TX, wideband RX | microSD — patches the radio's own backup file | 900 channels in 24 banks; channels, banks, and menu settings | | **Icom ID-52** | D-STAR + Analog | VHF / UHF TX, 108–174 / 225–479 MHz RX | microSD — patches the radio's own `.icf` file | 1000 memories in 100 groups; memories and menu settings restore in one operation | +| **Kenwood TH-D72** | APRS + Analog | 2 m / 70 cm TX, 118–174 / 320–524 MHz RX | Direct USB — read, write, settings | 1000 memories; 113 menu settings over the radio's own `MU` command | | **Kenwood TH-D75** | D-STAR + APRS + Analog | VHF / 1.25 m / UHF TX, 0.1–524 MHz RX | microSD — patches the radio's own `.d75` file | 1000 memories in 30 groups; memories and menu settings, including the APRS setup | +| **Binteradio BT-9000** | Analog FM/NFM | 18–64 / 136–174 / 200–260 / 400–520 MHz TX, 18–520 MHz RX | Direct USB — read, write, settings | 960 channels in 15 fixed zones; 42 menu settings. Also sold as the Radtel RT-950 Pro, Bajeton BJ-9000 and Tenway TP-900 Pro — the radio reports itself as `RT-950` | Direct USB programming reads the radio's current image, applies your changes, backs up the original, writes, and can verify the result byte-for-byte. @@ -35,8 +37,13 @@ radio restores it from its own menu with no vendor software involved. Because th the radio's settings as well as its memories, the codeplug and the radio profile travel together. Channels the radio can hear but not transmit on (GMRS, marine, NOAA, air band, 220) are -programmed **receive-only** rather than dropped, and frequencies outside the receiver's real -coverage are excluded with a reason rather than silently written to an empty slot. +programmed **receive-only** rather than dropped — where the radio has a per-channel transmit +inhibit, the app sets it — and frequencies outside the receiver's real coverage are excluded with +a reason rather than silently written to an empty slot. + +Band limits are measured on the radio, not copied from its manual. The BT-9000's 220 MHz +transmit capability, for instance, appears in no published source for it and was found by +programming a channel there and keying up. ## Future development @@ -51,8 +58,8 @@ settings together, then verify on the actual radio before shipping. |-------|-------|-------| | **AnyTone AT-D578UV** | DMR + Analog mobile | [#47](https://github.com/ww8l/codeplug-magic/issues/47) | | **AnyTone AT-D868UV** | DMR + Analog handheld | [#51](https://github.com/ww8l/codeplug-magic/issues/51) | -| **BTECH / Btrianium BT-9000** | Analog mobile | [#43](https://github.com/ww8l/codeplug-magic/issues/43) | | **Icom ID-51** | D-STAR + Analog handheld | [#50](https://github.com/ww8l/codeplug-magic/issues/50) | +| **Kenwood TM-D710** | APRS + Analog mobile | [#113](https://github.com/ww8l/codeplug-magic/issues/113) | | **Icom ID-5100** | D-STAR + Analog mobile | [#49](https://github.com/ww8l/codeplug-magic/issues/49) | | **Icom IC-9100** | HF / VHF / UHF base | [#45](https://github.com/ww8l/codeplug-magic/issues/45) | | **Icom IC-7610** | HF / 6 m SDR base | [#46](https://github.com/ww8l/codeplug-magic/issues/46) | diff --git a/package.json b/package.json index 6ca0b31..5bbfcf7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ww8l-codeplug-magic", "private": true, - "version": "26.8.29", + "version": "26.9.2", "license": "GPL-3.0-only", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c6948bb..425a4bc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5894,7 +5894,7 @@ dependencies = [ [[package]] name = "ww8l-codeplug-magic" -version = "26.8.29" +version = "26.9.2" dependencies = [ "chrono", "csv", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f24a6bd..23a0d21 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ww8l-codeplug-magic" -version = "26.8.29" +version = "26.9.2" description = "WW8L Codeplug Magic - master codeplug database for amateur radio operators" authors = ["WW8L"] edition = "2021" diff --git a/src-tauri/src/bt9000_settings_schema.json b/src-tauri/src/bt9000_settings_schema.json new file mode 100644 index 0000000..d7340a5 --- /dev/null +++ b/src-tauri/src/bt9000_settings_schema.json @@ -0,0 +1,668 @@ +[ + { + "key": "section-vox", + "label": "VOX", + "type": "section" + }, + { + "key": "vox-level", + "label": "VOX Level", + "type": "integer", + "min": 1, + "max": 9 + }, + { + "key": "vox-delay", + "label": "VOX Delay", + "type": "select", + "options": [ + "0.5 sec", + "0.6 sec", + "0.7 sec", + "0.8 sec", + "0.9 sec", + "1.0 sec", + "1.1 sec", + "1.2 sec", + "1.3 sec", + "1.4 sec", + "1.5 sec", + "1.6 sec", + "1.7 sec", + "1.8 sec", + "1.9 sec", + "2.0 sec" + ] + }, + { + "key": "vox-switch", + "label": "VOX Switch", + "type": "boolean" + }, + { + "key": "section-radio", + "label": "Radio", + "type": "section" + }, + { + "key": "squelch", + "label": "SQL", + "type": "integer", + "min": 1, + "max": 9 + }, + { + "key": "battery-save", + "label": "Battery Save", + "type": "select", + "options": [ + "OFF", + "Normal", + "Super", + "DEEP" + ] + }, + { + "key": "standby-set", + "label": "Standby Set", + "type": "boolean" + }, + { + "key": "tot", + "label": "TOT", + "type": "select", + "options": [ + "OFF", + "30sec", + "60sec", + "90sec", + "120sec", + "150sec", + "180sec", + "210sec", + "240sec" + ] + }, + { + "key": "scan-mode", + "label": "Scan Mode", + "type": "select", + "options": [ + "Time", + "Carrier", + "Search" + ] + }, + { + "key": "sos-mode", + "label": "SOS Mode", + "type": "select", + "options": [ + "On Site", + "Send Sound", + "Send Code" + ] + }, + { + "key": "tall", + "label": "TALL", + "type": "boolean" + }, + { + "key": "rp-ste", + "label": "RP-STE", + "type": "select", + "options": [ + "OFF", + "100ms", + "200ms", + "300ms", + "400ms", + "500ms", + "600ms", + "700ms", + "800ms", + "900ms", + "1000ms" + ] + }, + { + "key": "rpt-rl", + "label": "RPT-RL", + "type": "select", + "options": [ + "OFF", + "100ms", + "200ms", + "300ms", + "400ms", + "500ms", + "600ms", + "700ms", + "800ms", + "900ms", + "1000ms" + ] + }, + { + "key": "roger", + "label": "ROGRE", + "type": "select", + "options": [ + "OFF", + "Beep", + "Tone 1200" + ] + }, + { + "key": "r-tone", + "label": "R-TONE", + "type": "select", + "options": [ + "1000hz", + "1450hz", + "1750hz", + "2100hz" + ] + }, + { + "key": "ab-rpt-mode", + "label": "AB RPT-Mode", + "type": "boolean" + }, + { + "key": "rpt-speaker", + "label": "RPT-Speaker", + "type": "boolean" + }, + { + "key": "section-vfo-ch", + "label": "VFO&CH", + "type": "section" + }, + { + "key": "mdf-a", + "label": "MDF-A", + "type": "select", + "options": [ + "NAME", + "FREQUENCY", + "CHANNEL NUM." + ] + }, + { + "key": "mdf-b", + "label": "MDF-B", + "type": "select", + "options": [ + "NAME", + "FREQUENCY", + "CHANNEL NUM." + ] + }, + { + "key": "mdf-c", + "label": "MDF-C", + "type": "select", + "options": [ + "NAME", + "FREQUENCY", + "CHANNEL NUM." + ] + }, + { + "key": "section-setting", + "label": "Setting", + "type": "section" + }, + { + "key": "backlight", + "label": "Back Light", + "type": "select", + "options": [ + "Bright", + "5 sec", + "10 sec", + "15 sec", + "20 sec", + "30 sec", + "1 min", + "2 min", + "3 min" + ] + }, + { + "key": "beep", + "label": "Beep Prompt", + "type": "boolean" + }, + { + "key": "voice", + "label": "Voice", + "type": "boolean" + }, + { + "key": "language", + "label": "Language", + "type": "select", + "options": [ + "English", + "Chinese" + ] + }, + { + "key": "keypad-lock", + "label": "Keypad Lock", + "type": "select", + "options": [ + "OFF", + "5 sec", + "10 sec", + "15 sec" + ] + }, + { + "key": "power-on-display", + "label": "Power On Display", + "type": "select", + "options": [ + "Picture", + "Voltage" + ] + }, + { + "key": "menu-timeout", + "label": "Menu OutTime", + "type": "select", + "options": [ + "5 sec", + "10 sec", + "15 sec", + "20 sec", + "25 sec", + "30 sec", + "35 sec", + "40 sec", + "45 sec", + "50 sec", + "60 sec" + ] + }, + { + "key": "section-bluetooth", + "label": "Bluetooth", + "type": "section" + }, + { + "key": "bluetooth", + "label": "Bluetooth", + "type": "boolean" + }, + { + "key": "section-signaling", + "label": "Signaling", + "type": "section" + }, + { + "key": "dtmfst", + "label": "DTMFST", + "type": "select", + "options": [ + "OFF", + "DT-ST", + "ANI-ST", + "DT+ANI" + ] + }, + { + "key": "section-user-key", + "label": "User Key", + "type": "section" + }, + { + "key": "pf1-short", + "label": "PF1", + "type": "select", + "options": [ + "RADIO", + "MONI", + "SCAN", + "SEARCH", + "SOS", + "SPECTRUM", + "Beacon TX" + ] + }, + { + "key": "pf1-long", + "label": "Long press PF1", + "type": "select", + "options": [ + "RADIO", + "MONI", + "SCAN", + "SEARCH", + "SOS", + "SPECTRUM", + "Beacon TX" + ] + }, + { + "key": "pf2-short", + "label": "PF2", + "type": "select", + "options": [ + "RADIO", + "MONI", + "SCAN", + "SEARCH", + "SOS", + "SPECTRUM", + "Beacon TX" + ] + }, + { + "key": "pf2-long", + "label": "Long press PF2", + "type": "select", + "options": [ + "RADIO", + "MONI", + "SCAN", + "SEARCH", + "SOS", + "SPECTRUM", + "Beacon TX" + ] + }, + { + "key": "key0-long", + "label": "key [0] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key1-long", + "label": "key [1] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key2-long", + "label": "key [2] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key3-long", + "label": "key [3] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key4-long", + "label": "key [4] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key5-long", + "label": "key [5] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key6-long", + "label": "key [6] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key7-long", + "label": "key [7] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key8-long", + "label": "key [8] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + }, + { + "key": "key9-long", + "label": "key [9] long press", + "type": "select", + "options": [ + "NONE", + "RADIO", + "VOX", + "SEARCH", + "SPECTRUM", + "NOAA", + "SCAN QT", + "SQUELCH", + "FREQ STEP", + "TX POWER", + "CH-MEMORY", + "ZONE SELECT", + "STANDBY SET", + "CTCSS DCS", + "FREQ OFFSET", + "FREQ DIR", + "RX MODULATION", + "TONE TX", + "TRANSFER", + "GPS SWITCH", + "APRS SWITCH", + "ROGER" + ] + } +] diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index f85edf2..b2b6046 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -1323,6 +1323,162 @@ mod tests { let _ = std::fs::remove_file(&db_path); } + /// Issue #43, the step-4 gate: run the app's OWN pipeline against a real + /// database and count what came out against what went in. The BT-9000's + /// encoder has unit tests and a hardware ladder, and neither of those goes + /// through `resolve_codeplug_slots` — the thing that decides which channels + /// reach the radio at all, what they are called, and which slot each lands + /// in. + /// + /// What this pins for that radio specifically: + /// + /// * an out-of-band channel is EXCLUDED, not silently dropped or written — + /// this radio stores whatever it is handed, so the pipeline is the only + /// thing standing between a 222 MHz channel and a memory that keys up + /// there; + /// * names are cut to 12 characters, the radio's real field width; + /// * slots are dense and sequential from 0, which is what makes the zones + /// work at all — they are index arithmetic (`slot / 64 + 1`) and there is + /// nowhere in the image to store a zone name; + /// * and the image decodes back to exactly the channels that went in. + #[tokio::test] + async fn a_bt9000_codeplug_reaches_the_image_through_the_apps_own_pipeline() { + let dir = std::env::temp_dir().join(format!("cpm_bt9000_{}", std::process::id())); + let db_path = dir.join("test.sqlite3"); + let _ = std::fs::remove_file(&db_path); + let pool = crate::db::init_pool(&db_path).await.expect("init_pool"); + + // Four channels. The third is inside the radio's coverage on RX but its + // repeater INPUT is not, and the fourth is out of band at both ends. + sqlx::query( + "INSERT INTO channels (id, name_long, name_short, rx_freq, offset, duplex, mode, source) + VALUES (1, 'A Very Long Repeater Name', NULL, 146.940, 0.6, '-', 'FM', 'manual'), + (2, 'Simplex', 'SIMP', 146.520, NULL, NULL, 'FM', 'manual'), + (3, 'UHF Machine', 'UHF', 442.000, 5.0, '+', 'FM', 'manual'), + (4, '220 Repeater', '220', 223.500, 1.6, '-', 'FM', 'manual'), + (5, 'Airband', 'AIR', 108.000, NULL, NULL, 'FM', 'manual'), + (6, 'Way Out', 'OUT', 1200.000, NULL, NULL, 'FM', 'manual')", + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query("INSERT INTO channel_lists (id, name) VALUES (10, 'Local')") + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO channel_list_entries (channel_list_id, channel_id, position) + VALUES (10, 1, 0), (10, 2, 1), (10, 3, 2), (10, 4, 3), (10, 5, 4), (10, 6, 5)", + ) + .execute(&pool) + .await + .unwrap(); + + let model_id: (i64,) = + sqlx::query_as("SELECT id FROM radio_models WHERE model = 'BT-9000'") + .fetch_one(&pool) + .await + .expect("the BT-9000 is seeded"); + // A codeplug reaches its model through a radio PROFILE, which is also + // how the settings this radio now carries get to the same place. + sqlx::query( + "INSERT INTO radio_profiles (id, display_name, radio_model_id, non_channel_settings) + VALUES (1, 'BT-9000 test', ?1, '{\"squelch\": 4}')", + ) + .bind(model_id.0) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO codeplugs (id, name, radio_profile_id) VALUES (1, 'Test', 1)") + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO codeplug_channel_lists (codeplug_id, channel_list_id, position) VALUES (1, 10, 0)") + .execute(&pool) + .await + .unwrap(); + + let (model, slots) = resolve_codeplug_slots(&pool, 1).await.expect("resolve slots"); + assert_eq!(model.driver_key.as_deref(), Some("binteradio_bt9000")); + + // 6 in, 5 out. Each of the three verdicts is represented, and all three + // come from frequencies keyed on the real radio in s128: + // + // * 223.500 is INCLUDED — the radio keys there, which the manual never + // mentions and only the PTT could establish; + // * 108.000 is RECEIVE-ONLY — it takes AM and refuses the PTT, so it + // is programmed rather than dropped, with transmit disabled; + // * 1200.000 is EXCLUDED — outside the receiver as well, and the VFO + // will not even go there. + assert_eq!(slots.len(), 5, "only the 1200 MHz channel should be dropped"); + assert!( + slots.iter().any(|s| s.channel.rx_freq == 223.500), + "223.500 must reach the radio — it was keyed on the real one" + ); + assert!( + slots.iter().all(|s| s.channel.rx_freq != 1200.0), + "1200.000 is outside the receiver and must be dropped" + ); + assert!( + matches!( + channel_fit(&slots.iter().find(|s| s.channel.rx_freq == 108.0).unwrap().channel, &model), + ChannelFit::ReceiveOnly(_) + ), + "108.000 receives AM and refuses the PTT, so it is receive-only" + ); + + // Dense and sequential, which is what makes zone = slot / 64 + 1 mean + // anything on a radio with no zone names. + for (i, s) in slots.iter().enumerate() { + assert_eq!(s.slot, i, "slots must be dense from 0"); + assert!( + s.name.chars().count() <= 12, + "{:?} is longer than the radio's 12-character field", + s.name + ); + } + + // Through the driver's own image builder and back out again. + let driver = crate::radios::registry::driver_for_model(&model) + .expect("driver") + .as_image_programmer() + .expect("image programmer"); + let base = vec![0u8; crate::radios::binteradio_bt9000::IMAGE_LEN]; + let image = driver.build_image(&model, &slots, &base).expect("build_image"); + let decoded = crate::radios::binteradio_bt9000::decode_channels(&image); + + assert_eq!(decoded.len(), slots.len(), "channels in the image"); + + // ★ The receive-only memory goes out with byte 15 bit 1 CLEAR. That bit + // is measured: on the real radio a channel written this way refuses the + // PTT while its neighbour keys normally. Before it was measured this + // driver set it unconditionally, and a receive-only channel would have + // been programmed able to transmit on a radio that keys wherever it is + // told. + let air = slots.iter().position(|s| s.channel.rx_freq == 108.0).unwrap(); + assert_eq!(image[air * 32 + 15] & 0x02, 0x00, "108.000 must be TX-disabled"); + let two_m = slots.iter().position(|s| s.channel.rx_freq == 146.520).unwrap(); + assert_eq!(image[two_m * 32 + 15] & 0x02, 0x02, "146.520 must be TX-enabled"); + for (d, s) in decoded.iter().zip(&slots) { + // `trim_end` is not slack: the shared truncator cuts to the field + // width without regard for where words end, so a long name can + // arrive as "A Very Long " — 12 characters ending in a space — and + // this driver's decoder trims trailing blanks on the way back. The + // round trip is stable apart from that space, which is invisible on + // the radio. Asserted rather than papered over so the day the + // truncator learns to trim, this says so. + assert_eq!(d.name, s.name.trim_end(), "name round trip"); + assert_eq!(d.zone, 1, "the first 64 slots are zone 1"); + } + // The repeater's transmit frequency is the shifted one, not the input + // frequency repeated -- the pipeline resolves the shift, not the driver. + assert_eq!(decoded[0].rx_mhz, 146.940); + assert_eq!(decoded[0].tx_mhz, 146.340); + + let _ = std::fs::remove_file(&db_path); + } + /// Issue #83: CHIRP reads the Offset column of a `split` row as the /// absolute TRANSMIT FREQUENCY, not a shift. Writing the magnitude there /// gave a 33 cm pair (927.5 RX / 902.5 TX) the row `split, 25.000000` — a diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index acedb34..9cdb67d 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -59,8 +59,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 and Kenwood - // TH-D72. (0015 removed + // AT-D890UV, Yaesu FT5D, Icom ID-52, Kenwood TH-D75, Kenwood TH-D72 + // and the Binteradio BT-9000. (0015 removed // the Vero VR-N76 placeholder.) None of the last three has a migration // of its own — seeding INSERTs new (manufacturer, model) rows, so a new // model reaches existing databases on the next startup without one. @@ -69,8 +69,9 @@ mod tests { .await .unwrap(); assert_eq!( - count.0, 7, - "expected the UV-5R, TD-H3, AT-D890UV, FT5D, ID-52, TH-D75 and TH-D72 seeded models" + count.0, 8, + "expected the UV-5R, TD-H3, AT-D890UV, FT5D, ID-52, TH-D75, TH-D72 and BT-9000 \ + seeded models" ); let models: Vec<(String,)> = @@ -81,7 +82,7 @@ mod tests { let names: Vec<&str> = models.iter().map(|m| m.0.as_str()).collect(); assert_eq!( names, - vec!["AT-D890UV", "FT5D", "ID-52", "TD-H3", "TH-D72", "TH-D75", "UV-5R"] + vec!["AT-D890UV", "BT-9000", "FT5D", "ID-52", "TD-H3", "TH-D72", "TH-D75", "UV-5R"] ); // Seeding twice must remain idempotent. @@ -90,7 +91,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count2.0, 7, "seeding should be idempotent"); + assert_eq!(count2.0, 8, "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/radios/baofeng_uv5r/mod.rs b/src-tauri/src/radios/baofeng_uv5r/mod.rs index 5545a41..ee99d33 100644 --- a/src-tauri/src/radios/baofeng_uv5r/mod.rs +++ b/src-tauri/src/radios/baofeng_uv5r/mod.rs @@ -186,6 +186,14 @@ impl ImageRestorer for BaofengUv5r { } impl ImageProgrammer for BaofengUv5r { + /// The UV-5R is the only radio here that answers yes. It has no standalone + /// settings-write path — `SettingsWriter` is deliberately not implemented — + /// so the profile's settings ride out inside the image this uploads, which + /// is exactly why the two halves are separate traits. + fn carries_profile_settings(&self) -> bool { + true + } + fn download_image(&self, port: &str) -> Result<(RadioIdentity, Vec), String> { let mut p = open_port(port)?; let (matched, ident) = ident_radio(&mut *p)?; diff --git a/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs b/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs new file mode 100644 index 0000000..342f2ac --- /dev/null +++ b/src-tauri/src/radios/binteradio_bt9000/bt9000_settings_table.rs @@ -0,0 +1,436 @@ +//! BT-9000 settings field table — GENERATED, do not edit. +//! +//! Source: `scratchpad/binteradio_bt9000/MEASURED.md`, via +//! `gen_bt9000_settings.py`. The profile-form schema at +//! `src/bt9000_settings_schema.json` comes from the same parse, and +//! `settings.rs` asserts the two still describe the same fields. +//! +//! Every field here is graded `screen` in the sheet: its encoding was +//! settled on the radio's own screen, not taken from the source +//! inventory or the manual. That bar is higher here than on other +//! radios in this crate because THIS RADIO VALIDATES NOTHING — it +//! stored 127 in four fields whose maxima are 9, 2, 3 and 1 — so a +//! wrong encoding is stored rather than refused, and this table is the +//! only thing standing between the operator and a bad value. +//! +//! 42 field(s). The sheet's other rows are measured but not +//! settled; see its Tally section for what is still owed. + +/// How a field's value is carried in the byte. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) enum Enc { + /// Stored byte is the value: an enum index, or the displayed number. + Direct, + /// Stored byte is the displayed number minus one. Real on this radio: + /// SQL at 0x00 stores the level, VOX Level at 0x02 stores level − 1, + /// and they sit two bytes apart with the same printed "Level 1-9". + Minus1, +} + +/// What the form draws, and what the encoder may write. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) enum Kind { + /// `0 = OFF`, `1 = ON`. + Bool, + /// Zero-based index into `options`, which is in STORED order. + Enum, + /// Displayed number, inclusive of both bounds. + Int { lo: u8, hi: u8 }, +} + +/// One settings field. +pub(crate) struct SF { + pub key: &'static str, + pub label: &'static str, + /// This radio's own menu path. NOT the RT-950 Pro manual's: every + /// item in the Radio group is numbered one higher here, because the + /// BT-9000 inserts Work Band at Radio → 1. + pub menu: &'static str, + /// Offset within the function block (file offset = 0x7900 + addr). + pub addr: usize, + pub kind: Kind, + pub enc: Enc, + /// Enum labels in stored-index order; empty for the other kinds. + pub options: &'static [&'static str], +} + +pub(crate) const FIELDS: [SF; 42] = [ + SF { + key: "vox-level", + label: "VOX Level", + menu: "VOX → 2. VOX Level", + addr: 0x02, + kind: Kind::Int { lo: 1, hi: 9 }, + enc: Enc::Minus1, + options: &[], + }, + SF { + key: "vox-delay", + label: "VOX Delay", + menu: "VOX → 3. VOX Delay", + addr: 0x20, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["0.5 sec", "0.6 sec", "0.7 sec", "0.8 sec", "0.9 sec", "1.0 sec", "1.1 sec", "1.2 sec", "1.3 sec", "1.4 sec", "1.5 sec", "1.6 sec", "1.7 sec", "1.8 sec", "1.9 sec", "2.0 sec"], + }, + SF { + key: "vox-switch", + label: "VOX Switch", + menu: "VOX → 1. VOX Switch", + addr: 0x28, + kind: Kind::Bool, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "squelch", + label: "SQL", + menu: "Radio → 2. SQL", + addr: 0x00, + kind: Kind::Int { lo: 1, hi: 9 }, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "battery-save", + label: "Battery Save", + menu: "Radio → 7. Battery Save", + addr: 0x01, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["OFF", "Normal", "Super", "DEEP"], + }, + SF { + key: "standby-set", + label: "Standby Set", + menu: "Radio → 6. Standby Set", + addr: 0x04, + kind: Kind::Bool, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "tot", + label: "TOT", + menu: "Radio → 9. TOT", + addr: 0x05, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["OFF", "30sec", "60sec", "90sec", "120sec", "150sec", "180sec", "210sec", "240sec"], + }, + SF { + key: "scan-mode", + label: "Scan Mode", + menu: "Radio → 12. Scan Mode", + addr: 0x0A, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["Time", "Carrier", "Search"], + }, + SF { + key: "sos-mode", + label: "SOS Mode", + menu: "Radio → 18. SOS Mode", + addr: 0x11, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["On Site", "Send Sound", "Send Code"], + }, + SF { + key: "tall", + label: "TALL", + menu: "Radio → 10. TALL", + addr: 0x14, + kind: Kind::Bool, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "rp-ste", + label: "RP-STE", + menu: "Radio → 14. RP-STE", + addr: 0x15, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["OFF", "100ms", "200ms", "300ms", "400ms", "500ms", "600ms", "700ms", "800ms", "900ms", "1000ms"], + }, + SF { + key: "rpt-rl", + label: "RPT-RL", + menu: "Radio → 15. RPT-RL", + addr: 0x16, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["OFF", "100ms", "200ms", "300ms", "400ms", "500ms", "600ms", "700ms", "800ms", "900ms", "1000ms"], + }, + SF { + key: "roger", + label: "ROGRE", + menu: "Radio → 13. ROGRE", + addr: 0x17, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["OFF", "Beep", "Tone 1200"], + }, + SF { + key: "r-tone", + label: "R-TONE", + menu: "Radio → 11. R-TONE", + addr: 0x1E, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["1000hz", "1450hz", "1750hz", "2100hz"], + }, + SF { + key: "ab-rpt-mode", + label: "AB RPT-Mode", + menu: "Radio → 16. AB RPT-Mode", + addr: 0x26, + kind: Kind::Bool, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "rpt-speaker", + label: "RPT-Speaker", + menu: "Radio → 17. RPT-Speaker", + addr: 0x3A, + kind: Kind::Bool, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "mdf-a", + label: "MDF-A", + menu: "VFO&CH → 1. MDF-A", + addr: 0x0D, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NAME", "FREQUENCY", "CHANNEL NUM."], + }, + SF { + key: "mdf-b", + label: "MDF-B", + menu: "VFO&CH → 2. MDF-B", + addr: 0x0E, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NAME", "FREQUENCY", "CHANNEL NUM."], + }, + SF { + key: "mdf-c", + label: "MDF-C", + menu: "VFO&CH → 3. MDF-C", + addr: 0x0F, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NAME", "FREQUENCY", "CHANNEL NUM."], + }, + SF { + key: "backlight", + label: "Back Light", + menu: "Setting → 4. Back Light", + addr: 0x03, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["Bright", "5 sec", "10 sec", "15 sec", "20 sec", "30 sec", "1 min", "2 min", "3 min"], + }, + SF { + key: "beep", + label: "Beep Prompt", + menu: "Setting → 1. Beep Prompt", + addr: 0x06, + kind: Kind::Bool, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "voice", + label: "Voice", + menu: "Setting → 2. Voice", + addr: 0x07, + kind: Kind::Bool, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "language", + label: "Language", + menu: "Setting → 7. Language", + addr: 0x08, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["English", "Chinese"], + }, + SF { + key: "keypad-lock", + label: "Keypad Lock", + menu: "Setting → 3. Keypad Lock", + addr: 0x10, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["OFF", "5 sec", "10 sec", "15 sec"], + }, + SF { + key: "power-on-display", + label: "Power On Display", + menu: "Setting → 6. Power On Display", + addr: 0x1C, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["Picture", "Voltage"], + }, + SF { + key: "menu-timeout", + label: "Menu OutTime", + menu: "Setting → 5. Menu OutTime", + addr: 0x21, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["5 sec", "10 sec", "15 sec", "20 sec", "25 sec", "30 sec", "35 sec", "40 sec", "45 sec", "50 sec", "60 sec"], + }, + SF { + key: "bluetooth", + label: "Bluetooth", + menu: "Bluetooth", + addr: 0x1D, + kind: Kind::Bool, + enc: Enc::Direct, + options: &[], + }, + SF { + key: "dtmfst", + label: "DTMFST", + menu: "Signaling → 6. DTMFST", + addr: 0x09, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["OFF", "DT-ST", "ANI-ST", "DT+ANI"], + }, + SF { + key: "pf1-short", + label: "PF1", + menu: "User Key → 1. PF1", + addr: 0x29, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["RADIO", "MONI", "SCAN", "SEARCH", "SOS", "SPECTRUM", "Beacon TX"], + }, + SF { + key: "pf1-long", + label: "Long press PF1", + menu: "User Key → 2. Long press PF1", + addr: 0x2A, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["RADIO", "MONI", "SCAN", "SEARCH", "SOS", "SPECTRUM", "Beacon TX"], + }, + SF { + key: "pf2-short", + label: "PF2", + menu: "User Key → 3. PF2", + addr: 0x2B, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["RADIO", "MONI", "SCAN", "SEARCH", "SOS", "SPECTRUM", "Beacon TX"], + }, + SF { + key: "pf2-long", + label: "Long press PF2", + menu: "User Key → 4. Long press PF2", + addr: 0x2C, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["RADIO", "MONI", "SCAN", "SEARCH", "SOS", "SPECTRUM", "Beacon TX"], + }, + SF { + key: "key0-long", + label: "key [0] long press", + menu: "User Key → key [0] long press", + addr: 0x3B, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key1-long", + label: "key [1] long press", + menu: "User Key → key [1] long press", + addr: 0x3C, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key2-long", + label: "key [2] long press", + menu: "User Key → key [2] long press", + addr: 0x3D, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key3-long", + label: "key [3] long press", + menu: "User Key → key [3] long press", + addr: 0x3E, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key4-long", + label: "key [4] long press", + menu: "User Key → key [4] long press", + addr: 0x3F, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key5-long", + label: "key [5] long press", + menu: "User Key → key [5] long press", + addr: 0x40, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key6-long", + label: "key [6] long press", + menu: "User Key → key [6] long press", + addr: 0x41, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key7-long", + label: "key [7] long press", + menu: "User Key → key [7] long press", + addr: 0x42, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key8-long", + label: "key [8] long press", + menu: "User Key → key [8] long press", + addr: 0x43, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, + SF { + key: "key9-long", + label: "key [9] long press", + menu: "User Key → key [9] long press", + addr: 0x44, + kind: Kind::Enum, + enc: Enc::Direct, + options: &["NONE", "RADIO", "VOX", "SEARCH", "SPECTRUM", "NOAA", "SCAN QT", "SQUELCH", "FREQ STEP", "TX POWER", "CH-MEMORY", "ZONE SELECT", "STANDBY SET", "CTCSS DCS", "FREQ OFFSET", "FREQ DIR", "RX MODULATION", "TONE TX", "TRANSFER", "GPS SWITCH", "APRS SWITCH", "ROGER"], + }, +]; diff --git a/src-tauri/src/radios/binteradio_bt9000/dcs.rs b/src-tauri/src/radios/binteradio_bt9000/dcs.rs new file mode 100644 index 0000000..d0f3494 --- /dev/null +++ b/src-tauri/src/radios/binteradio_bt9000/dcs.rs @@ -0,0 +1,241 @@ +//! The radio's DCS code table, generated from the reference implementation +//! and hardware-checked at both ends (issue #43, 2026-09-01). +//! +//! A channel stores DCS as `index_into_this_table + 1` in the first tone byte, +//! second byte zero. That makes the *order* load-bearing: a table off by one +//! entry puts a different, entirely valid-looking tone on every DCS channel, +//! and no byte-level round-trip can catch it. +//! +//! Verified on the radio by writing both ends of the table and reading the +//! radio's own screen: index 1 shows `D023N`, index 210 shows `D754I`. + +/// `(code, inverted)` — `false` is normal polarity (`N`), `true` inverted (`I`). +pub(crate) const DCS_TABLE: [(u16, bool); 210] = [ + (23, false), + (25, false), + (26, false), + (31, false), + (32, false), + (36, false), + (43, false), + (47, false), + (51, false), + (53, false), + (54, false), + (65, false), + (71, false), + (72, false), + (73, false), + (74, false), + (114, false), + (115, false), + (116, false), + (122, false), + (125, false), + (131, false), + (132, false), + (134, false), + (143, false), + (145, false), + (152, false), + (155, false), + (156, false), + (162, false), + (165, false), + (172, false), + (174, false), + (205, false), + (212, false), + (223, false), + (225, false), + (226, false), + (243, false), + (244, false), + (245, false), + (246, false), + (251, false), + (252, false), + (255, false), + (261, false), + (263, false), + (265, false), + (266, false), + (271, false), + (274, false), + (306, false), + (311, false), + (315, false), + (325, false), + (331, false), + (332, false), + (343, false), + (346, false), + (351, false), + (356, false), + (364, false), + (365, false), + (371, false), + (411, false), + (412, false), + (413, false), + (423, false), + (431, false), + (432, false), + (445, false), + (446, false), + (452, false), + (454, false), + (455, false), + (462, false), + (464, false), + (465, false), + (466, false), + (503, false), + (506, false), + (516, false), + (523, false), + (526, false), + (532, false), + (546, false), + (565, false), + (606, false), + (612, false), + (624, false), + (627, false), + (631, false), + (632, false), + (645, false), + (654, false), + (662, false), + (664, false), + (703, false), + (712, false), + (723, false), + (731, false), + (732, false), + (734, false), + (743, false), + (754, false), + (23, true), + (25, true), + (26, true), + (31, true), + (32, true), + (36, true), + (43, true), + (47, true), + (51, true), + (53, true), + (54, true), + (65, true), + (71, true), + (72, true), + (73, true), + (74, true), + (114, true), + (115, true), + (116, true), + (122, true), + (125, true), + (131, true), + (132, true), + (134, true), + (143, true), + (145, true), + (152, true), + (155, true), + (156, true), + (162, true), + (165, true), + (172, true), + (174, true), + (205, true), + (212, true), + (223, true), + (225, true), + (226, true), + (243, true), + (244, true), + (245, true), + (246, true), + (251, true), + (252, true), + (255, true), + (261, true), + (263, true), + (265, true), + (266, true), + (271, true), + (274, true), + (306, true), + (311, true), + (315, true), + (325, true), + (331, true), + (332, true), + (343, true), + (346, true), + (351, true), + (356, true), + (364, true), + (365, true), + (371, true), + (411, true), + (412, true), + (413, true), + (423, true), + (431, true), + (432, true), + (445, true), + (446, true), + (452, true), + (454, true), + (455, true), + (462, true), + (464, true), + (465, true), + (466, true), + (503, true), + (506, true), + (516, true), + (523, true), + (526, true), + (532, true), + (546, true), + (565, true), + (606, true), + (612, true), + (624, true), + (627, true), + (631, true), + (632, true), + (645, true), + (654, true), + (662, true), + (664, true), + (703, true), + (712, true), + (723, true), + (731, true), + (732, true), + (734, true), + (743, true), + (754, true), +]; + +/// The stored byte for a DCS code, or `None` if this radio has no such code. +/// Returns the 1-based table position: index 0 is reserved for "no tone". +pub(crate) fn dcs_to_byte(code: u16, inverted: bool) -> Option { + DCS_TABLE + .iter() + .position(|&(c, i)| c == code && i == inverted) + .map(|p| (p + 1) as u8) +} + +/// Decode a stored DCS byte back to `(code, inverted)`. +pub(crate) fn byte_to_dcs(byte: u8) -> Option<(u16, bool)> { + if byte == 0 { + return None; + } + DCS_TABLE.get(byte as usize - 1).copied() +} diff --git a/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs b/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs new file mode 100644 index 0000000..ea87c01 --- /dev/null +++ b/src-tauri/src/radios/binteradio_bt9000/hw_ladder.rs @@ -0,0 +1,325 @@ +//! THROWAWAY (issue #43): hardware ladder steps 3, 4 and 5 for the BT-9000. +//! +//! Steps 1 and 2 — identity write and a one-name write — were run with the +//! scratch tooling and passed; they proved the container and that there is no +//! checksum. What they did NOT prove is that *this driver's* encoder produces +//! an image the radio accepts, so everything here goes through the shipping +//! code: [`patch_image`], [`handshake`], [`upload`], [`download`]. A harness +//! with its own encoder would show the radio accepts something; it would not +//! show that this driver can program it. +//! +//! ```sh +//! CPM_BT9000_PORT=/dev/cu.usbserial-10 \ +//! cargo test --lib binteradio_bt9000::hw_ladder -- --ignored --nocapture +//! ``` +//! +//! ⚠ All three tests WRITE to the radio. Each takes its own backup first and prints +//! the path. ⚠ And an ACK from this radio does not mean a commit — every +//! assertion below is made against a fresh read-back, never against the write. + +use std::path::PathBuf; + +use super::*; +use crate::models::Channel; + +fn port() -> String { + std::env::var("CPM_BT9000_PORT").expect("set CPM_BT9000_PORT to the radio's serial port") +} + +fn backup_dir() -> PathBuf { + PathBuf::from(std::env::var("CPM_BT9000_DIR").unwrap_or_else(|_| ".".to_string())) +} + +fn chan(rx: f64, tx: f64) -> Channel { + Channel { + rx_freq: rx, + tx_freq: Some(tx), + mode: Some("FM".to_string()), + power: Some("High".to_string()), + ..Default::default() + } +} + +fn slot(slot: usize, name: &str, rx: f64, tx: f64) -> SlotChannel { + SlotChannel { slot, name: name.to_string(), channel: chan(rx, tx) } +} + +/// Read, back up, patch with `slots`, write, and read back. Returns the image +/// the radio holds afterwards. +fn program(slots: &[SlotChannel], tag: &str) -> Vec { + // A DELIBERATELY permissive model, so every probe goes out transmit-enabled. + // The seeded model would mark an out-of-band probe receive-only and write it + // with the PTT disabled — which is correct for a codeplug and would defeat + // the band probe, whose entire question is whether the radio keys there. + let model = RadioModel { + analog_capable: true, + tx_bands: Some("[[1.0,1000.0]]".to_string()), + rx_bands: Some("[[1.0,1000.0]]".to_string()), + freq_min: Some(1.0), + freq_max: Some(1000.0), + ..Default::default() + }; + let port = port(); + let mut p = open_port(&port).expect("open the port"); + + let hs = handshake(&mut *p).expect("handshake"); + println!(" model {:?}, F blob {}", hs.model, hex(&hs.probe)); + let base = download(&mut *p, &hs).expect("download"); + + let backup = backup_dir().join(format!( + "bt9000-hwladder-{tag}-{}.img", + chrono::Local::now().format("%Y%m%d-%H%M%S") + )); + std::fs::write(&backup, &base).expect("write the backup"); + println!(" backup: {}", backup.display()); + + let mut image = base.clone(); + patch_image(&mut image, slots, &model); + + std::thread::sleep(SETTLE); + let hs = handshake(&mut *p).expect("re-handshake before writing"); + upload(&mut *p, &hs, &image).expect("upload"); + + std::thread::sleep(SETTLE); + let hs = handshake(&mut *p).expect("re-handshake before reading back"); + let after = download(&mut *p, &hs).expect("read back"); + + // Only the regions we own. The VFO journal is the radio's, and comparing it + // would report a difference after every single write. + for seg in WRITE_SEGMENTS { + let r = seg.file_offset..seg.file_offset + seg.length; + assert_eq!( + image[r.clone()], + after[r], + "segment {} did not come back as written", + seg.name + ); + } + after +} + +/// Ladder step 3 — a full codeplug, spread so that **every zone** is exercised. +/// +/// The step-3 rule is to check the memory list in every zone, not just the one +/// the radio powers up on. Here that is mechanical: this radio's zones are +/// index arithmetic, so a channel in the first and last slot of each of the 15 +/// zones proves the whole 960-slot map at once. +#[test] +#[ignore = "writes to a real BT-9000 on the cable"] +fn step3_full_codeplug_reaches_every_zone() { + let mut slots = Vec::new(); + for zone in 0..ZONE_COUNT { + let base = zone * CHANNELS_PER_ZONE; + // First and last slot of the zone, on distinguishable frequencies so a + // misplaced channel is visible on the radio rather than merely absent. + slots.push(slot(base, &format!("Z{:02}FIRST", zone + 1), 145.0 + zone as f64 * 0.1, 145.0 + zone as f64 * 0.1)); + slots.push(slot( + base + CHANNELS_PER_ZONE - 1, + &format!("Z{:02}LAST", zone + 1), + 440.0 + zone as f64 * 0.1, + 440.0 + zone as f64 * 0.1, + )); + } + let after = program(&slots, "step3"); + + let decoded = decode_channels(&after); + assert_eq!(decoded.len(), slots.len(), "channel count on the radio"); + + for zone in 1..=ZONE_COUNT { + let in_zone: Vec<_> = decoded.iter().filter(|c| c.zone == zone).collect(); + assert_eq!(in_zone.len(), 2, "zone {zone} should hold 2 channels"); + println!( + " zone {zone:2}: {} @ {:.4} {} @ {:.4}", + in_zone[0].name, in_zone[0].rx_mhz, in_zone[1].name, in_zone[1].rx_mhz + ); + } + println!("\n CHECK ON THE RADIO: every one of the {ZONE_COUNT} zones holds ZFIRST and ZLAST."); +} + +/// Ladder step 4 — the band probe, and ★ the measurement that turned out to be +/// impossible from the image. +/// +/// The usual shape of this step is: write a channel at each band edge, read +/// back, and see which became a silently empty slot. On most radios an +/// out-of-coverage frequency is dropped, and that is how coverage gets mapped. +/// +/// **Not on this one.** All 13 probes survive, 27.5 MHz and 580 MHz included — +/// frequencies no source claims this radio can even receive. That is the same +/// behaviour measured in the settings block, where it stored `127` in fields +/// whose maxima are 9, 2, 3 and 1: **this radio validates nothing it is +/// written.** It is a store, not a filter. +/// +/// So a passing run here proves the encoder and transport round-trip cleanly, +/// and says *nothing whatever* about band coverage. `tx_bands` can only be +/// widened by selecting each channel on the radio and confirming it tunes and +/// keys up. Until then the seed stays deliberately narrow: an over-claimed +/// `tx_bands` writes a memory the radio cannot use while reporting success. +#[test] +#[ignore = "writes to a real BT-9000 on the cable"] +fn step4_band_probe() { + // (frequency, what it tests) + let probes: &[(f64, &str)] = &[ + (27.500, "CB — the vendor's web copy claims TX here"), + (50.125, "6 m — inside the claimed 18-64 of the reference driver"), + (108.000, "airband bottom — RX only if present at all"), + (136.000, "VHF low edge, manual-stated"), + (145.100, "2 m, known good (the radio shipped with it)"), + (174.000, "VHF high edge, manual-stated"), + (200.000, "F-blob third pair, low edge"), + (223.500, "1.25 m — the band the 'Work Band' menu hints at"), + (260.000, "F-blob third pair, high edge"), + (400.000, "UHF low edge, manual-stated"), + (431.100, "70 cm, known good (the radio shipped with it)"), + (520.000, "UHF high edge, manual-stated"), + (580.000, "above every claim — expected to fail"), + ]; + let slots: Vec = probes + .iter() + .enumerate() + .map(|(i, (mhz, _))| slot(i, &format!("B{:03}", *mhz as u32), *mhz, *mhz)) + .collect(); + + let after = program(&slots, "step4"); + let decoded = decode_channels(&after); + + println!("\n landed frequency note"); + let mut landed = Vec::new(); + for (i, (mhz, note)) in probes.iter().enumerate() { + let got = decoded.iter().find(|c| c.index == i); + let ok = got.map(|c| (c.rx_mhz - mhz).abs() < 1e-6).unwrap_or(false); + if ok { + landed.push(*mhz); + } + println!( + " {:^6} {mhz:>9.3} {note}{}", + if ok { "yes" } else { "NO" }, + match got { + Some(c) if !ok => format!(" [stored as {:.3}]", c.rx_mhz), + None => " [slot is empty]".to_string(), + _ => String::new(), + } + ); + } + println!( + "\n {} of {} probes survived the round trip.", + landed.len(), + probes.len() + ); + + // The two frequencies the radio itself shipped with must always survive. + assert!(landed.contains(&145.100), "2 m round-trip"); + assert!(landed.contains(&431.100), "70 cm round-trip"); + + // ★ The finding, asserted so it cannot quietly stop being true: this radio + // accepts EVERYTHING, so the image is not a band filter and this test can + // never map coverage. If a probe ever does fail to survive, the radio has + // started validating and the band question becomes answerable here — which + // is worth knowing loudly rather than passing silently. + assert_eq!( + landed.len(), + probes.len(), + "this radio has always stored every frequency written to it, in or out of band; \ + a failure here means that changed and the band map can now be measured" + ); + println!( + " ★ Every probe survived, 27.5 and 580 MHz included. This radio stores what it is\n \ + given without validating it, so the IMAGE cannot settle tx_bands. Only selecting\n \ + each channel on the radio and keying up can." + ); +} + +/// Ladder step 5 — the settings spot check, through the driver's own +/// `SettingsWriter` rather than the scratch Python. +/// +/// The Python tooling in `scratchpad/binteradio_bt9000/` has already put values +/// in this block and read them back, so this is not asking whether the radio +/// stores settings — it is asking whether *this driver's* narrowed write does. +/// That is a different question, and it is the one step 3 had to be re-run to +/// answer for channels. +/// +/// What it proves, all against a fresh read-back and never against an ACK: +/// +/// 1. `write_settings` reaches the radio at all. +/// 2. It writes **only the function block** — every other segment is compared +/// against the pre-write backup and must be untouched. On a driver whose +/// whole-image `upload` would rewrite 960 channel records to change a +/// squelch level, that is the assertion that matters. +/// 3. `read_settings` decodes back exactly what was asked for, including the +/// two "Level 1-9" fields two bytes apart that store their values +/// differently. +/// +/// ⚠ It restores the settings it found before returning, so the radio is left +/// as it was even though the backup would also serve. +#[test] +#[ignore = "writes to a real BT-9000 on the cable"] +fn step5_settings_write_through_the_driver() { + use crate::radios::driver::{SettingsReader, SettingsWriter}; + use serde_json::json; + + let port = port(); + let dir = backup_dir(); + let schema = crate::seed::BT9000_SETTINGS_SCHEMA; + + // 1. What the radio holds now, decoded by the shipping reader, plus a whole + // image to compare every untouched segment against later. + let before = DRIVER.read_settings(&port, schema).expect("read settings"); + println!(" before: {}", before.settings); + let base = before.backup.clone(); + + // 2. A value in every settled field that is NOT what the radio has, so a + // field that did not move is distinguishable from one that did. + let want = json!({ + "squelch": if before.settings["squelch"] == json!(3) { 7 } else { 3 }, + "vox-level": if before.settings["vox-level"] == json!(4) { 8 } else { 4 }, + "power-on-display": + if before.settings["power-on-display"] == json!("Voltage") { "Picture" } else { "Voltage" }, + }); + println!(" writing: {want}"); + + std::thread::sleep(SETTLE); + let report = DRIVER + .write_settings(&port, &want, schema, &dir) + .expect("write settings"); + println!( + " wrote {} field(s), verified {:?}, backup {}", + report.fields_written, report.verified, report.backup_path + ); + assert_eq!(report.fields_written, 3, "every settled field should be written"); + // ⚠ This radio acknowledges blocks it does not always commit — the APRS + // block answers 0x06 forever and never changes — so the driver's own + // read-back verdict is the claim under test, not a formality. + assert_eq!(report.verified, Some(true), "the driver's read-back must confirm the write"); + + // 3. Read it back independently of the write session. + std::thread::sleep(SETTLE); + let after = DRIVER.read_settings(&port, schema).expect("read settings back"); + println!(" after: {}", after.settings); + for key in ["squelch", "vox-level", "power-on-display"] { + assert_eq!(after.settings[key], want[key], "{key} did not come back as written"); + } + + // 4. ★ Nothing outside the function block moved. This is the assertion that + // a narrowed write exists for: the operator's 960 channels, the VFO, the + // DTMF codes and the modulation memories are all still exactly as read. + for seg in READ_SEGMENTS { + if seg.name == "function" { + continue; + } + let r = seg.file_offset..seg.file_offset + seg.length; + assert_eq!( + base[r.clone()], + after.backup[r], + "segment {} changed during a SETTINGS write", + seg.name + ); + } + println!(" ★ every segment but `function` is byte-identical to the pre-write image."); + + // 5. Put back what the radio had, so the campaign's own baseline survives. + std::thread::sleep(SETTLE); + let restored = DRIVER + .write_settings(&port, &before.settings, schema, &dir) + .expect("restore the original settings"); + assert_eq!(restored.verified, Some(true), "restore must verify"); + println!(" restored the settings the radio started with."); +} diff --git a/src-tauri/src/radios/binteradio_bt9000/mod.rs b/src-tauri/src/radios/binteradio_bt9000/mod.rs new file mode 100644 index 0000000..5e321a9 --- /dev/null +++ b/src-tauri/src/radios/binteradio_bt9000/mod.rs @@ -0,0 +1,1335 @@ +//! Binteradio BT-9000 (issue #43) — clone-mode cable radio. +//! +//! ## What this radio is +//! +//! One badge on an OEM platform sold as the Radtel RT-950 Pro, Bajeton BJ-9000 +//! and Tenway TP-900 Pro. The platform's own name is in the protocol: the clone +//! session opens with the ASCII string `PROGRAMBT9000U`. The radio nonetheless +//! reports its model as `RT-950`, so [`MODEL_TOKEN`] is what the handshake +//! checks — never the badge on the case. +//! +//! 960 channels in 15 fixed zones of 64. Zones have **no names in the radio**: +//! membership is `index / 64`, and the vendor CPS keeps zone labels only in its +//! own `.dat` file. There is nowhere in the clone image to put them. +//! +//! ## Protocol +//! +//! 115200 8N1. Handshake, then a negotiated 4-byte XOR key obfuscates every +//! 0x80-byte payload. Read `0x52` / write `0x57` over six segments, plus an +//! APRS block reached by `0x54` / `0x55` in its own address space. +//! +//! ## Three things measured on the radio that the published map got wrong +//! +//! 1. **A block ACK can take 15 seconds.** The first write here died at +//! `0x8080` with a 3 s timeout and nothing wrong with the data — a flash +//! erase at a segment boundary. See [`ACK_TIMEOUT`]. +//! 2. **`0x8080`–`0x80FF` is a firmware journal, not VFO storage.** Writing it +//! makes the radio append a snapshot of its own VFO state there and discard +//! ours. [`WRITE_SEGMENTS`] stops the VFO segment at `0x80` for this reason, +//! and [`assert_writable`] refuses the address outright. +//! 3. **The APRS block is not writable by any sequence found so far.** Its +//! payload must go unobfuscated to draw any response at all, and even then +//! the `0x06` it answers is a lie — the block never changes. APRS is +//! therefore READ-ONLY here; see [`APRS_WRITE_UNPROVEN`]. +//! +//! ⚠ **An ACK from this radio does not mean the data landed.** Every claim in +//! this module was verified by reading the image back, never by the ACK. +//! +//! ⚠ **The radio does not validate settings writes.** It stored `127` in four +//! fields whose real maxima are 9, 2, 3 and 1. There is no hardware backstop; +//! every bound has to be enforced here. + +pub(crate) mod bt9000_settings_table; +pub(crate) mod dcs; +pub(crate) mod settings; +#[cfg(test)] +mod hw_ladder; + +use std::time::Duration; + +use serde::Serialize; +use serialport::{ClearBuffer, SerialPort}; + +use crate::commands::export::SlotChannel; +use crate::models::{Channel, RadioModel}; +use crate::radios::driver::{ + CodeplugProgramReport, DecodedChannelSample, ImageProgramRequest, ImageProgrammer, + ImageRestorer, RadioDriver, RadioIdentity, +}; + +const BAUD: u32 = 115_200; + +/// Ordinary read timeout. Generous next to the other drivers because this radio +/// answers a block header only after it has served the whole 0x80-byte payload. +const TIMEOUT: Duration = Duration::from_secs(3); + +/// How long a *write* block ACK may take. Measured, not guessed: at 3 s the +/// identity write died at `0x8080`; at 15 s the identical write completed. The +/// stall is a flash erase at a segment boundary, so it is rare but real, and a +/// driver that gives up early leaves the radio half-programmed. +const ACK_TIMEOUT: Duration = Duration::from_secs(15); + +const HANDSHAKE: &[u8] = b"PROGRAMBT9000U"; + +/// Block commands. Read and write are distinct opcodes in both the main space +/// and the APRS one, and a `Segment` carries whichever its table is for. +const CMD_READ: u8 = 0x52; +const CMD_READ_APRS: u8 = 0x54; +const CMD_WRITE: u8 = 0x57; +const ACK: u8 = 0x06; +const END: u8 = b'E'; +const BLOCK: usize = 0x80; + +/// What the radio answers to `M`, whatever the badge says. A Binteradio-branded +/// BT-9000 reports `RT-950`. +pub(crate) const MODEL_TOKEN: &str = "RT-950"; + +/// Exact clone payload length. A longer buffer is refused: streaming a full +/// `0x0000`–`0xFFFF` dump into the clone space is what permanently degraded the +/// transmit path of another radio on this platform. +pub(crate) const IMAGE_LEN: usize = 33_152; + +const CHANNEL_COUNT: usize = 960; +const ENTRY_LEN: usize = 32; +const NAME_LEN: usize = 12; +pub(crate) const CHANNELS_PER_ZONE: usize = 64; +pub(crate) const ZONE_COUNT: usize = CHANNEL_COUNT / CHANNELS_PER_ZONE; // 15 + +/// The 960 memories are 15 fixed zones of 64, with no zone names anywhere in +/// the image. Held as an invariant so a future edit to any one of these three +/// cannot quietly disagree with the other two. +const _: () = assert!(ZONE_COUNT * CHANNELS_PER_ZONE == CHANNEL_COUNT); + +// ============================================================ +// Segment tables +// ============================================================ + +/// One contiguous clone region. `name` exists because "channels" and "aprs" +/// both start at address `0x0000` — in different command spaces — so an address +/// alone does not identify a segment. +#[derive(Clone, Copy)] +pub(crate) struct Segment { + pub name: &'static str, + pub command: u8, + /// Address as the radio sees it, inside this command's space. + pub address: u16, + /// Offset of this segment's bytes within the assembled image. + pub file_offset: usize, + pub length: usize, +} + +/// Read layout. Reads are permissive on this radio — it will serve any address +/// to `0x52` — so this table defines the image, not the radio's limits. +pub(crate) const READ_SEGMENTS: [Segment; 7] = [ + Segment { name: "channels", command: 0x52, address: 0x0000, file_offset: 0x0000, length: 0x7800 }, + Segment { name: "vfo", command: 0x52, address: 0x8000, file_offset: 0x7800, length: 0x0100 }, + Segment { name: "function", command: 0x52, address: 0x9000, file_offset: 0x7900, length: 0x0100 }, + Segment { name: "dtmf", command: 0x52, address: 0xA000, file_offset: 0x7A00, length: 0x0200 }, + Segment { name: "mod_param", command: 0x52, address: 0xB000, file_offset: 0x7C00, length: 0x0200 }, + Segment { name: "mod_names", command: 0x52, address: 0xD000, file_offset: 0x7E00, length: 0x0300 }, + Segment { name: "aprs", command: 0x54, address: 0x0000, file_offset: 0x8100, length: 0x0080 }, +]; + +/// Write layout. Deliberately NOT the read layout: +/// +/// - `vfo` stops at `0x80`. The second block is the firmware journal. +/// - `aprs` is absent. It does not commit, and a control that runs and fails is +/// worse than one that is not offered. Established over two sessions and eight +/// attempts: a plain payload is acknowledged with `0x06` in 0.0 s and changes +/// nothing (waiting 16 s rather than 2 does not help), an obfuscated one draws +/// `0x54` — the APRS *read* opcode — rather than an ACK, the block is still +/// unchanged after a POWER CYCLE, and the space holds only the one 0x80 block +/// so the write is not partial. Everything untried needs guessed command +/// bytes, which is desk work on the vendor CPS rather than radio work. +pub(crate) const WRITE_SEGMENTS: [Segment; 6] = [ + Segment { name: "channels", command: 0x57, address: 0x0000, file_offset: 0x0000, length: 0x7800 }, + Segment { name: "vfo", command: 0x57, address: 0x8000, file_offset: 0x7800, length: 0x0080 }, + Segment { name: "function", command: 0x57, address: 0x9000, file_offset: 0x7900, length: 0x0100 }, + Segment { name: "dtmf", command: 0x57, address: 0xA000, file_offset: 0x7A00, length: 0x0200 }, + Segment { name: "mod_param", command: 0x57, address: 0xB000, file_offset: 0x7C00, length: 0x0200 }, + Segment { name: "mod_names", command: 0x57, address: 0xD000, file_offset: 0x7E00, length: 0x0300 }, +]; + +/// The bytes of `seg` a read-back may legitimately be compared against. +/// +/// ⚠ Two ranges inside [`WRITE_SEGMENTS`] are FIRMWARE-OWNED and move on their +/// own, so comparing them reports a mismatch on a write that landed perfectly: +/// +/// - the `vfo` journal, already excluded by that segment stopping at `0x80`; +/// - the back half of `function`, `0x9080`-`0x90FF`, a shadow copy of the live +/// settings (`+0xD0` onward is byte-for-byte the live block). +/// +/// This exists as a helper because the first fix for it was applied to ONE of +/// the three comparisons in this driver. The other two — the restore and the +/// codeplug read-back — kept telling the operator their write had not landed, +/// and the restore is the path somebody reaches for when things have already +/// gone wrong. +pub(crate) fn comparable(seg: Segment) -> std::ops::Range { + let len = if seg.name == "function" { FUNCTION_LIVE_LEN } else { seg.length }; + seg.file_offset..seg.file_offset + len +} + +/// Refuse a segment whose opcode does not belong to the transport being asked +/// to carry it. +/// +/// A [`Segment`] carries its own command byte, and [`READ_SEGMENTS`] and +/// [`WRITE_SEGMENTS`] describe the SAME blocks with different ones. Handing a +/// write segment to the reader would put write opcodes on the wire with nothing +/// behind them, on the platform where a desynchronised write stream has already +/// permanently degraded one radio's transmit. Cheap to check, so it is checked +/// rather than left to the caller picking the right constant. +fn check_commands(segments: &[Segment], allowed: &[u8], verb: &str) -> Result<(), String> { + for seg in segments { + if !allowed.contains(&seg.command) { + return Err(format!( + "internal error: refusing to {verb} segment {} with command 0x{:02X}, \ + which is not a {verb} command", + seg.name, seg.command + )); + } + } + Ok(()) +} + +/// Address ranges that must never be written in the `0x52`/`0x57` space. +/// +/// `0x7800`–`0x7FFF` is the gap the vendor CPS skips. `0x8080`–`0x80FF` is the +/// VFO journal. Both were implicated in the damage report on this platform. +fn forbidden(address: u16) -> bool { + (0x7800..0x8000).contains(&address) || (0x8080..0x8100).contains(&address) +} + +/// Panics if any write segment reaches a forbidden address. Called by a test, +/// so the table cannot drift back to the unsafe layout unnoticed. +#[cfg(test)] +fn assert_writable() { + for seg in WRITE_SEGMENTS { + if seg.command != 0x57 { + continue; + } + for off in (0..seg.length).step_by(BLOCK) { + let addr = seg.address + off as u16; + assert!( + !forbidden(addr), + "write segment {} reaches forbidden address 0x{addr:04X}", + seg.name + ); + } + } +} + +// ============================================================ +// XOR obfuscation +// ============================================================ + +/// The radio's 20 keystream symbols. The negotiation frame we send picks one; +/// the radio derives the same choice from the same bytes. +const ENCRYPT_STRINGS: [&[u8; 4]; 20] = [ + b"BHT ", b"CO 7", b"A ES", b" EIY", b"M PQ", + b"XN Y", b"RVB ", b" HQP", b"W RC", b"MS N", + b" SAT", b"K DH", b"ZO R", b"C SL", b"6RB ", + b" JCG", b"PN V", b"J PK", b"EK L", b"I LZ", +]; + +/// Build the 25-byte `SEND` frame and the key it selects. +/// +/// The vendor software randomises this. We do not, deliberately: the radio +/// derives the key from bytes we choose, so a fixed frame is as valid as a +/// random one and makes a session reproducible when something goes wrong. That +/// two *different* keys decoded the same radio image byte-for-byte is what +/// proved this derivation correct in the first place. +fn encryption_frame() -> ([u8; 25], [u8; 4]) { + let mut frame = [0u8; 25]; + frame[0..4].copy_from_slice(b"SEND"); + // Low nibble 0, high nibble 1 -> the selector lands on frame[5]. + frame[4] = 0x10; + frame[5] = 1; // table row 1, "CO 7" + let code = frame[4]; + let idx = if code & 0x20 != 0 { + (code as usize - 0x20) * 2 + 1 + } else { + (code as usize - 0x10) * 2 + } + 1; + let key = *ENCRYPT_STRINGS[frame[4 + idx] as usize]; + (frame, key) +} + +/// Apply the keystream. Symmetric — the same call encodes and decodes. +/// +/// The skips are the radio's own rule, not an optimisation: a key byte of +/// `0x20`, or a payload byte of `0x00`/`0xFF`/`k`/`k ^ 0xFF`, passes through +/// untouched. Getting this wrong corrupts an image while still round-tripping +/// against itself, which is why it was checked against two different keys. +fn apply_xor(payload: &mut [u8], key: &[u8; 4]) { + for (i, value) in payload.iter_mut().enumerate() { + let k = key[i % 4]; + if k != 0x20 && *value != 0x00 && *value != 0xFF && *value != k && *value != (k ^ 0xFF) { + *value ^= k; + } + } +} + +// ============================================================ +// Serial protocol +// ============================================================ + +pub(crate) fn open_port(port: &str) -> Result, String> { + serialport::new(port, BAUD) + .data_bits(serialport::DataBits::Eight) + .parity(serialport::Parity::None) + .stop_bits(serialport::StopBits::One) + .flow_control(serialport::FlowControl::None) + .timeout(TIMEOUT) + .open() + .map_err(|e| format!("could not open {port}: {e}")) +} + +fn read_exact(p: &mut dyn SerialPort, n: usize) -> Result, String> { + let mut buf = vec![0u8; n]; + std::io::Read::read_exact(p, &mut buf) + .map_err(|e| format!("timed out reading {n} bytes from the radio: {e}"))?; + Ok(buf) +} + +fn write_all(p: &mut dyn SerialPort, data: &[u8]) -> Result<(), String> { + std::io::Write::write_all(p, data).map_err(|e| format!("serial write failed: {e}")) +} + +/// What the radio reports during the clone handshake. +pub(crate) struct Handshake { + /// The 12-byte model string, trimmed. `RT-950` on every unit seen. + pub model: String, + /// The 16-byte blob the `F` probe returns. Its leading bytes read as packed + /// BCD band edges (`0136 0174 0400 0520 0200 0260 …`), which would make it + /// the radio's own band table — **unproven**, so it is carried as evidence + /// rather than parsed. + pub probe: Vec, + key: [u8; 4], +} + +/// Open a clone session. Harmless: reads nothing but identity. +pub(crate) fn handshake(p: &mut dyn SerialPort) -> Result { + let _ = p.clear(ClearBuffer::All); + + write_all(p, HANDSHAKE)?; + if read_exact(p, 1)?[0] != ACK { + return Err("radio did not acknowledge the clone handshake".into()); + } + + write_all(p, b"F")?; + let probe = read_exact(p, 16)?; + + write_all(p, b"M")?; + let raw = read_exact(p, 12)?; + let model = String::from_utf8_lossy(&raw) + .trim_matches(|c: char| c == '\0' || c == ' ') + .to_string(); + if model != MODEL_TOKEN { + return Err(format!( + "expected a {MODEL_TOKEN} (the BT-9000 reports that model); radio said {model:?}" + )); + } + + let (frame, key) = encryption_frame(); + write_all(p, &frame)?; + if read_exact(p, 1)?[0] != ACK { + return Err("radio did not accept the keystream negotiation".into()); + } + + Ok(Handshake { model, probe, key }) +} + +/// Read the whole clone image. Always exactly [`IMAGE_LEN`] bytes. +pub(crate) fn download(p: &mut dyn SerialPort, hs: &Handshake) -> Result, String> { + download_segments(p, hs, &READ_SEGMENTS) +} + +/// Read `segments` only, into an otherwise-zeroed full-length image. +/// +/// The buffer stays [`IMAGE_LEN`] so that a caller reading one segment indexes +/// it with the same offsets as one reading the whole radio. Bytes outside +/// `segments` are zero and must not be written back — [`upload_segments`] with +/// the matching segment list is the only safe partner for this. +pub(crate) fn download_segments( + p: &mut dyn SerialPort, + hs: &Handshake, + segments: &[Segment], +) -> Result, String> { + let mut image = vec![0u8; IMAGE_LEN]; + check_commands(segments, &[CMD_READ, CMD_READ_APRS], "read")?; + for seg in segments.iter().copied() { + for off in (0..seg.length).step_by(BLOCK) { + let addr = seg.address + off as u16; + let header = [seg.command, (addr >> 8) as u8, addr as u8, BLOCK as u8]; + write_all(p, &header)?; + let reply = read_exact(p, 4 + BLOCK)?; + let start = seg.file_offset + off; + let slice = &mut image[start..start + BLOCK]; + slice.copy_from_slice(&reply[4..]); + apply_xor(slice, &hs.key); + } + } + write_all(p, &[END])?; + Ok(image) +} + +/// The function-configuration segment, on its own. +/// +/// A settings write must not go out through the whole-image [`upload`]: that +/// rewrites all 960 channel records to change a squelch level, taking four +/// minutes instead of a third of a second and putting the operator's memories +/// at risk for a change that never touched them. Narrowing a transport that has +/// the reach to write everything is a deliberate act, not an optimisation. +pub(crate) const SETTINGS_SEGMENTS: [Segment; 1] = [WRITE_SEGMENTS[2]]; + +/// The same block, for READING. Deliberately a separate constant drawn from +/// [`READ_SEGMENTS`]: a `Segment` carries its command byte, and this radio's +/// read and write commands are different (`0x52` vs `0x57`). Handing the write +/// segment to [`download_segments`] would put a WRITE opcode on the wire with +/// no payload behind it — on the platform that has already had a radio's +/// transmit permanently degraded by a desynchronised write stream. +pub(crate) const SETTINGS_READ_SEGMENTS: [Segment; 1] = [READ_SEGMENTS[2]]; + +/// Where the function block sits in the assembled image, and how long it is. +pub(crate) const FUNCTION_OFFSET: usize = 0x7900; +pub(crate) const FUNCTION_LEN: usize = 0x0100; + +/// How much of the function block is LIVE settings. +/// +/// ⚠ Measured on the radio (s128), and the rest of the segment is not padding: +/// +/// | range | what | +/// |---|---| +/// | `0x00-0x45` | the settings the menus edit | +/// | `0x46-0x7F` | `0xFF` filler | +/// | `0x80-0xFF` | a **firmware-maintained SHADOW** — `+0xD0` onward is byte-for-byte the live block | +/// +/// The shadow moves on its own, exactly like the VFO journal at radio `0x8080`. +/// So a settings write can only be VERIFIED across the live area; comparing the +/// whole segment reports a mismatch on bytes the radio owns and we never set. +pub(crate) const FUNCTION_LIVE_LEN: usize = 0x46; + +/// Write an image back. Only [`WRITE_SEGMENTS`] is addressed, so the CPS gap, +/// the VFO journal and the APRS block are never touched. +/// +/// Aborts on the first block the radio does not acknowledge. That is not +/// caution for its own sake: streaming past a missing ACK desynchronises the +/// radio's write pointer, and doing so is what damaged a radio on this platform. +pub(crate) fn upload(p: &mut dyn SerialPort, hs: &Handshake, image: &[u8]) -> Result<(), String> { + upload_segments(p, hs, image, &WRITE_SEGMENTS) +} + +/// Write `segments` of `image` back, and nothing else. +/// +/// `segments` must be drawn from [`WRITE_SEGMENTS`] — the address guard below +/// is the backstop, not the policy. The whole image is still required as the +/// argument so that every offset means the same thing everywhere in this +/// driver, and so a caller cannot hand over a buffer that has been shifted. +pub(crate) fn upload_segments( + p: &mut dyn SerialPort, + hs: &Handshake, + image: &[u8], + segments: &[Segment], +) -> Result<(), String> { + if image.len() != IMAGE_LEN { + return Err(format!( + "refusing to write a {}-byte image; a BT-9000 clone is exactly {IMAGE_LEN} bytes", + image.len() + )); + } + p.set_timeout(ACK_TIMEOUT) + .map_err(|e| format!("could not extend the serial timeout for writing: {e}"))?; + + check_commands(segments, &[CMD_WRITE], "write")?; + for seg in segments.iter().copied() { + for off in (0..seg.length).step_by(BLOCK) { + let addr = seg.address + off as u16; + if forbidden(addr) { + return Err(format!( + "internal error: refusing to write 0x{addr:04X}, a firmware-managed address" + )); + } + let start = seg.file_offset + off; + let mut payload = image[start..start + BLOCK].to_vec(); + apply_xor(&mut payload, &hs.key); + let header = [seg.command, (addr >> 8) as u8, addr as u8, BLOCK as u8]; + write_all(p, &header)?; + write_all(p, &payload)?; + let ack = read_exact(p, 1)?[0]; + if ack != ACK { + return Err(format!( + "radio rejected the block at 0x{addr:04X} (answered 0x{ack:02X}); \ + write stopped there" + )); + } + } + } + write_all(p, &[END])?; + let _ = p.set_timeout(TIMEOUT); + Ok(()) +} + +// ============================================================ +// Container +// ============================================================ + +/// Reject anything that is not a BT-9000 clone image before a byte of it +/// reaches the radio. +pub(crate) fn validate_image(image: &[u8]) -> Result<(), String> { + if image.len() != IMAGE_LEN { + return Err(format!( + "not a BT-9000 image: {} bytes, expected exactly {IMAGE_LEN}", + image.len() + )); + } + Ok(()) +} + +// ============================================================ +// Channel records +// ============================================================ + +#[derive(Serialize, PartialEq, Debug, Clone)] +pub struct Bt9000DecodedChannel { + pub index: usize, + /// 1-based zone, `index / 64 + 1`. The radio stores no zone names. + pub zone: usize, + pub name: String, + pub rx_mhz: f64, + pub tx_mhz: f64, + pub rx_tone: String, + pub tx_tone: String, + pub power: String, + pub narrow: bool, + pub tx_enabled: bool, +} + +/// `0 = High, 1 = Middle, 2 = Low` — confirmed on the radio's own screen. +/// Not the order the manual prints them in, which is the usual trap. +const POWER_LEVELS: [&str; 3] = ["High", "Middle", "Low"]; + +/// Decode a frequency: packed BCD, **least-significant byte first**, in units of +/// 10 Hz. `00 00 51 14` is 145.100 MHz. The published note reads these +/// big-endian, which is wrong for this radio. +fn lbcd_to_hz(b: &[u8]) -> u64 { + let mut v: u64 = 0; + for byte in b.iter().rev() { + v = v * 100 + u64::from((byte >> 4) * 10 + (byte & 0x0F)); + } + v * 10 +} + +fn hz_to_lbcd(hz: u64) -> [u8; 4] { + let mut units = hz / 10; + let mut out = [0u8; 4]; + for slot in out.iter_mut() { + let pair = (units % 100) as u8; + *slot = ((pair / 10) << 4) | (pair % 10); + units /= 100; + } + out +} + +/// Decode a two-byte tone field. +/// +/// `00 00` is off. A zero *second* byte means DCS, and the first byte is a +/// 1-based index into [`dcs::DCS_TABLE`]. Otherwise it is a little-endian u16 +/// of Hz×10. The two cannot collide: the lowest CTCSS tone, 67.0 Hz, is 670, +/// whose high byte is already non-zero. +fn decode_tone(raw: &[u8]) -> String { + match (raw[0], raw[1]) { + (0, 0) => "—".to_string(), + (idx, 0) => match dcs::byte_to_dcs(idx) { + Some((code, inverted)) => format!("DTCS {code:03} {}", if inverted { "I" } else { "N" }), + None => "—".to_string(), + }, + (lo, hi) => { + let value = u16::from(lo) | (u16::from(hi) << 8); + if value == 0xFFFF { + "—".to_string() + } else { + format!("T {:.1}", f64::from(value) / 10.0) + } + } + } +} + +fn encode_ctcss(hz: f64) -> [u8; 2] { + let v = (hz * 10.0).round() as u16; + [v as u8, (v >> 8) as u8] +} + +fn encode_dcs(code: &str, inverted: bool) -> Option<[u8; 2]> { + let numeric: u16 = code.trim().parse().ok()?; + dcs::dcs_to_byte(numeric, inverted).map(|b| [b, 0x00]) +} + +pub(crate) fn decode_channels(image: &[u8]) -> Vec { + let mut out = Vec::new(); + for i in 0..CHANNEL_COUNT { + let rec = &image[i * ENTRY_LEN..(i + 1) * ENTRY_LEN]; + // An empty slot reads as all-0xFF in its frequency field. + if rec[0..4] == [0xFF; 4] { + continue; + } + let rx = lbcd_to_hz(&rec[0..4]); + if rx == 0 { + continue; + } + let flags = rec[15]; + out.push(Bt9000DecodedChannel { + index: i, + zone: i / CHANNELS_PER_ZONE + 1, + name: decode_name(&rec[20..32]), + rx_mhz: rx as f64 / 1e6, + tx_mhz: lbcd_to_hz(&rec[4..8]) as f64 / 1e6, + rx_tone: decode_tone(&rec[8..10]), + tx_tone: decode_tone(&rec[10..12]), + power: POWER_LEVELS[usize::from(rec[14] & 0x0F).min(2)].to_string(), + narrow: flags & 0x40 != 0, + tx_enabled: flags & 0x02 != 0, + }); + } + out +} + +/// Names are plain ASCII. **Two sentinels, not one**: a channel that has never +/// been named is twelve `0x00` bytes, while a named channel is padded with +/// `0xFF`. The published note mentions only `0xFF`, and an encoder that pads a +/// blank channel with it is not reproducing what the radio writes. +fn decode_name(raw: &[u8]) -> String { + raw.iter() + .take_while(|&&b| b != 0xFF && b != 0x00) + .map(|&b| b as char) + .collect::() + .trim_end() + .to_string() +} + +fn name_bytes(name: &str) -> [u8; NAME_LEN] { + let mut out = [0xFFu8; NAME_LEN]; + if name.is_empty() { + return [0x00; NAME_LEN]; + } + for (slot, ch) in out.iter_mut().zip(name.chars().take(NAME_LEN)) { + *slot = if ch.is_ascii() && !ch.is_control() { ch as u8 } else { b' ' }; + } + out +} + +/// `0 = High, 1 = Middle, 2 = Low`, confirmed on the radio's screen for all +/// three. Note this is the reverse of the TD-H3's mapping in this same crate — +/// the reason each driver measures its own rather than sharing a helper. +fn power_index(c: &Channel) -> u8 { + match c.power.as_deref() { + 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") + || p.eq_ignore_ascii_case("Mid") + || p.eq_ignore_ascii_case("Middle") => + { + 1 + } + _ => 0, + } +} + +fn tone_off() -> [u8; 2] { + [0x00, 0x00] +} + +/// DCS for one direction. An unknown code falls back to no tone rather than to +/// a neighbouring table entry: a silently *different* valid tone is worse on a +/// repeater than no tone at all. +fn tone_dtcs(code: &Option, inverted: bool) -> [u8; 2] { + code.as_deref() + .and_then(|c| encode_dcs(c, inverted)) + .unwrap_or_else(tone_off) +} + +/// Same tone-mode vocabulary the other drivers use, so a channel means the same +/// thing on every radio in the library. Returns `(rx, tx)`. +fn encode_tones(c: &Channel) -> ([u8; 2], [u8; 2]) { + let pol = c.dcs_polarity.as_bytes(); + let tx_rev = pol.first() == Some(&b'R'); + let rx_rev = pol.get(1) == Some(&b'R'); + let ctcss = |t: Option| t.map(encode_ctcss).unwrap_or_else(tone_off); + + let mode = c.tone_mode.as_deref().unwrap_or("off"); + if mode.eq_ignore_ascii_case("Tone") { + (tone_off(), ctcss(c.ctcss_uplink)) + } else if mode.eq_ignore_ascii_case("TSQL") { + let t = ctcss(c.ctcss_downlink); + (t, t) + } else if mode.eq_ignore_ascii_case("DTCS") { + ( + tone_dtcs(&c.dcs_code, rx_rev), + tone_dtcs(&c.dcs_code, tx_rev), + ) + } else if mode.eq_ignore_ascii_case("Cross") { + let (txmode, rxmode) = c.cross_mode.split_once("->").unwrap_or(("", "")); + let tx = if txmode.eq_ignore_ascii_case("Tone") { + ctcss(c.ctcss_uplink) + } else if txmode.eq_ignore_ascii_case("DTCS") { + tone_dtcs(&c.dcs_code, tx_rev) + } else { + tone_off() + }; + let rx = if rxmode.eq_ignore_ascii_case("Tone") { + ctcss(c.ctcss_downlink) + } else if rxmode.eq_ignore_ascii_case("DTCS") { + tone_dtcs(&c.dcs_rx_code, rx_rev) + } else { + tone_off() + }; + (rx, tx) + } else { + (tone_off(), tone_off()) + } +} + +/// Build one 32-byte channel record. +/// +/// The TX shift is carried entirely by the stored TX frequency — there is no +/// separate direction field, confirmed against the radio (a −0.600 repeater +/// channel differs from a simplex one only in bytes 4-7). +/// `tx_enable` clears byte 15 bit 1 for a receive-only memory. +/// +/// ⚠ That bit is **measured**, not inherited: a channel written with it clear +/// refuses the PTT while its neighbour with the bit set keys normally, checked +/// on the radio (s128). It was a source claim until then, which is why this +/// driver spent a release setting it unconditionally and guarding the gap with +/// a test instead of guessing. +fn encode_channel(c: &Channel, name: &str, tx_hz: u64, tx_enable: bool) -> [u8; ENTRY_LEN] { + let mut m = [0u8; ENTRY_LEN]; + + m[0..4].copy_from_slice(&hz_to_lbcd((c.rx_freq * 1e6).round() as u64)); + m[4..8].copy_from_slice(&hz_to_lbcd(tx_hz)); + + let (rx_tone, tx_tone) = encode_tones(c); + m[8..10].copy_from_slice(&rx_tone); + m[10..12].copy_from_slice(&tx_tone); + + // 12 = signalling group, 13 = PTT-ID. Both left at "none": neither is a + // channel property this app models, and the radio's own default is 0. + m[14] = power_index(c) & 0x0F; // high nibble is the scrambler, left off + + // bit 1 = TX enable, bit 6 = narrow. Everything else (FHSS, encryption, + // busy lockout, scan-add, AM) stays off — measured defaults, not guesses. + // + // ⚠ Narrow ONLY on an explicit narrow mode. `mode` is nullable in the + // schema and reachable from a CSV import with no mode column, and + // `export::channel_fit` resolves a NULL to "FM" when it decides the channel + // is programmable — so treating NULL as *not* FM narrowed a channel that + // the fit logic had just admitted as wide FM. The two now agree. + // + // ⚠ AM is a known gap, deliberately left. This radio receives AM and byte + // 15 bit 0 is claimed to select it, but that bit has never been measured + // here, and an AM channel inside 136-174 MHz therefore goes out as wide FM. + // `scratchpad/binteradio_bt9000/SCREEN-CHECK.md` carries the measurement; + // guessing the bit is how radios get damaged on this platform. + let narrow = matches!(c.mode.as_deref(), Some(m) if m.eq_ignore_ascii_case("NFM")); + m[15] = if tx_enable { 0x02 } else { 0x00 } | if narrow { 0x40 } else { 0x00 }; + + // 16-19 = FHSS code, left zero. + m[20..32].copy_from_slice(&name_bytes(name)); + m +} + +/// Patch resolved channel slots into a freshly-read image. +/// +/// Only the channel segment is touched. Slots the codeplug does not fill are +/// **cleared to the radio's own empty form** rather than left alone, so +/// programming a shorter codeplug does not leave stale channels behind. +pub(crate) fn patch_image(image: &mut [u8], slots: &[SlotChannel], model: &RadioModel) { + for i in 0..CHANNEL_COUNT { + image[i * ENTRY_LEN..(i + 1) * ENTRY_LEN].fill(0xFF); + } + for s in slots { + if s.slot >= CHANNEL_COUNT { + continue; + } + let tx_hz = (crate::commands::export::tx_frequency(&s.channel) * 1e6).round() as u64; + // A channel the radio can hear but not transmit on is programmed with + // the PTT disabled rather than dropped — and rather than left + // transmit-enabled, which on a radio that validates nothing would hand + // the operator a memory that keys up out of band. + let tx_enable = !matches!( + crate::commands::export::channel_fit(&s.channel, model), + crate::commands::export::ChannelFit::ReceiveOnly(_) + ); + let rec = encode_channel(&s.channel, &s.name, tx_hz, tx_enable); + image[s.slot * ENTRY_LEN..(s.slot + 1) * ENTRY_LEN].copy_from_slice(&rec); + } +} + + +// ============================================================ +// Driver +// ============================================================ + +pub(crate) struct BinteradioBt9000; + +/// Registry entry (see `radios/registry.rs`). +pub(crate) static DRIVER: BinteradioBt9000 = BinteradioBt9000; + +impl RadioDriver for BinteradioBt9000 { + fn key(&self) -> &'static str { + "binteradio_bt9000" + } + + fn display_name(&self) -> &'static str { + "Binteradio BT-9000" + } + + fn baud(&self) -> u32 { + BAUD + } + + fn identify(&self, port: &str) -> Result { + let mut p = open_port(port)?; + let hs = handshake(&mut *p)?; + // Leave the session cleanly. An aborted clone session leaves bytes in + // the radio's buffer that surface as bogus answers to the NEXT command + // — two early reads here returned 0x54 and 0x52 for exactly that + // reason, and were briefly mistaken for protocol findings. + let _ = write_all(&mut *p, &[END]); + Ok(RadioIdentity { + matched: hs.model.clone(), + ident_hex: hex(&hs.probe), + ident_ascii: Some(hs.model), + }) + } + + fn as_image_programmer(&self) -> Option<&dyn ImageProgrammer> { + Some(self) + } + + fn as_image_restorer(&self) -> Option<&dyn ImageRestorer> { + Some(self) + } + + fn as_settings_reader(&self) -> Option<&dyn crate::radios::driver::SettingsReader> { + Some(self) + } + + fn as_settings_writer(&self) -> Option<&dyn crate::radios::driver::SettingsWriter> { + Some(self) + } +} + +/// Putting a backup back on the radio. +/// +/// Offered because this driver's own error messages promise it. Every failure +/// path in here hands the operator a pre-write backup and tells them it can go +/// back over the same cable — and until this existed, no control in the app +/// could do that, on the one radio in this crate whose platform has a +/// documented unit with permanently degraded transmit. +/// +/// It is not new risk: hardware ladder step 1 was exactly this operation — a +/// byte-identical image written back and read back — and it passed. +impl ImageRestorer for BinteradioBt9000 { + /// Refuse a file that is not a BT-9000 clone image before a byte of it + /// reaches the radio. + /// + /// Length is what separates the formats in `radio-backups/`, which holds + /// images for every radio this app talks to and is where the picker opens. + /// 33,152 bytes is this radio's exact clone payload and is shared with none + /// of the others. + /// + /// ⚠ The check is deliberately shape-only, and does NOT try to prove which + /// unit the image came from. Restoring a backup taken from another radio of + /// the same model is a normal thing to do, and this is the path reached for + /// after a bad write. There is also nothing in the image to key on: it + /// carries no serial number, no ident prefix and — measured in s127 — no + /// checksum anywhere in its 33,152 bytes. + fn check_restore_image(&self, image: &[u8]) -> Result<(), String> { + if image.len() != IMAGE_LEN { + return Err(format!( + "this file is {} bytes — a BT-9000 backup is exactly {IMAGE_LEN}. Pick a \ + .img taken from a BT-9000 (radio-backups/ also holds images for other \ + radios, which must not be written to this one).", + image.len() + )); + } + Ok(()) + } + + /// Write the whole backup back in one session, then read it back. + /// + /// ⚠ The read-back is not decoration on this radio: it acknowledges blocks + /// it does not always commit, so an all-ACKs write is not evidence. A + /// restore that cannot be confirmed says so rather than reporting success — + /// this is the path somebody reaches for when a write has already gone + /// wrong, and it is the worst possible place to be optimistic. + fn restore_image(&self, port: &str, image: &[u8]) -> Result<(), String> { + self.check_restore_image(image)?; + let mut p = open_port(port)?; + let hs = handshake(&mut *p)?; + upload(&mut *p, &hs, image)?; + + std::thread::sleep(SETTLE); + let hs = handshake(&mut *p)?; + let back = download(&mut *p, &hs)?; + for seg in WRITE_SEGMENTS { + let r = comparable(seg); + if image[r.clone()] != back[r] { + return Err(format!( + "the radio acknowledged the restore but segment {} read back \ + differently. The radio does NOT hold this backup. Power-cycle it \ + and try the restore again.", + seg.name + )); + } + } + Ok(()) + } +} + +impl ImageProgrammer for BinteradioBt9000 { + fn download_image(&self, port: &str) -> Result<(RadioIdentity, Vec), String> { + let mut p = open_port(port)?; + let hs = handshake(&mut *p)?; + let image = download(&mut *p, &hs)?; + Ok(( + RadioIdentity { + matched: hs.model.clone(), + ident_hex: hex(&hs.probe), + ident_ascii: Some(hs.model), + }, + image, + )) + } + + fn decode_sample(&self, image: &[u8]) -> Vec { + decode_channels(image).into_iter().map(decoded_to_sample).collect() + } + + fn upload_image(&self, port: &str, image: &[u8]) -> Result<(), String> { + validate_image(image)?; + let mut p = open_port(port)?; + let hs = handshake(&mut *p)?; + upload(&mut *p, &hs, image) + } + + fn build_image( + &self, + model: &RadioModel, + channels: &[SlotChannel], + base: &[u8], + ) -> Result, String> { + validate_image(base)?; + if channels.len() > CHANNEL_COUNT { + return Err(format!( + "{} channels exceed the BT-9000's {CHANNEL_COUNT} memories.", + channels.len() + )); + } + let mut image = base.to_vec(); + patch_image(&mut image, channels, model); + Ok(image) + } + + /// Download + back up, patch channels into that image, write it back, read + /// back and verify. + /// + /// `req.settings` is ignored on purpose: a channel program leaves every + /// setting exactly as the radio had it. That matters more here than on most + /// radios — this one does not validate a settings write, so an unintended + /// value would be stored rather than rejected. + fn program_codeplug( + &self, + port: &str, + req: &ImageProgramRequest, + ) -> Result { + if req.channels.len() > CHANNEL_COUNT { + return Err(format!( + "Codeplug has {} programmable channels, but the BT-9000 holds only {CHANNEL_COUNT}.", + req.channels.len() + )); + } + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let slug = slug_label(req.label); + let backup_path = req.backup_dir.join(if slug.is_empty() { + format!("bt9000-prewrite-{stamp}.img") + } else { + format!("bt9000-prewrite-{slug}-{stamp}.img") + }); + + let mut p = open_port(port)?; + + // 1. Download + back up. + let hs = handshake(&mut *p)?; + let mut image = download(&mut *p, &hs)?; + std::fs::write(&backup_path, &image) + .map_err(|e| format!("could not write backup {}: {e}", backup_path.display()))?; + + // 2. Patch channels into the image we just read, so every byte we do + // not own goes back exactly as it came. + let channels_written = req.channels.len(); + patch_image(&mut image, req.channels, req.model); + + let restore_hint = |e: String| { + crate::radios::driver::with_restore_hint( + e, + &backup_path, + "Keep that file. It is the only copy of what was on the radio before \ + this write, and it can be uploaded back over the same cable.", + ) + }; + + // 3. Write. A fresh session: the radio needs a moment to settle after a + // full read before it will answer again. + std::thread::sleep(SETTLE); + let hs = handshake(&mut *p).map_err(|e| restore_hint(e.to_string()))?; + upload(&mut *p, &hs, &image).map_err(restore_hint)?; + + // 4. Read back and verify. Non-fatal: every block was acknowledged. + // ⚠ But an ACK on this radio does not prove a commit, so the + // read-back is the only real evidence and its absence is reported. + std::thread::sleep(SETTLE); + let (verified, note) = match verify_after_write(&mut *p, &image) { + Ok(result) => result, + Err(e) => ( + false, + Some(format!( + "Write completed, but read-back verification could not run ({e}). \ + This radio acknowledges blocks it does not always commit, so \ + power-cycle it and use Download to confirm before trusting it." + )), + ), + }; + + Ok(CodeplugProgramReport { + channels_written, + slots_cleared: CHANNEL_COUNT - channels_written, + settings_written: None, + verified: Some(verified), + note, + backup_path: backup_path.to_string_lossy().to_string(), + channels: decode_channels(&image).into_iter().map(decoded_to_sample).collect(), + zones_written: 0, + zones_cleared: 0, + scan_lists_written: 0, + scan_lists_cleared: 0, + contacts_written: 0, + contacts_cleared: 0, + expected_path: None, + windows_written: Vec::new(), + skipped: Vec::new(), + warnings: Vec::new(), + }) + } +} + +/// How long the radio needs between a completed session and the next one. A +/// read issued immediately after a write times out; measured at three seconds, +/// given headroom here. +const SETTLE: Duration = Duration::from_secs(5); + +/// Read the image back and compare only the regions we actually wrote. +/// +/// The VFO journal, the function block's shadow half and the APRS block are all +/// excluded because the radio owns them: comparing them would report a +/// difference on every single write. See [`comparable`]. +fn verify_after_write( + p: &mut dyn SerialPort, + expected: &[u8], +) -> Result<(bool, Option), String> { + let hs = handshake(p)?; + let actual = download(p, &hs)?; + let mut mismatched = Vec::new(); + for seg in WRITE_SEGMENTS { + let range = comparable(seg); + if expected[range.clone()] != actual[range] { + mismatched.push(seg.name); + } + } + if mismatched.is_empty() { + Ok((true, None)) + } else { + Ok(( + false, + Some(format!( + "Read-back does not match what was written ({}). The radio \ + acknowledged every block, which on this model is not proof of a \ + commit — restore from the backup and try again.", + mismatched.join(", ") + )), + )) + } +} + +fn decoded_to_sample(c: Bt9000DecodedChannel) -> DecodedChannelSample { + DecodedChannelSample { + index: c.index, + name: c.name, + rx_mhz: c.rx_mhz, + shift: Some(if !c.tx_enabled { + "RX-only".to_string() + } else if (c.tx_mhz - c.rx_mhz).abs() < 1e-9 { + String::new() + } else { + format!("{:+.3}", c.tx_mhz - c.rx_mhz) + }), + tone: c.rx_tone, + power: c.power, + mode: Some(if c.narrow { "NFM".into() } else { "FM".into() }), + } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02X}")).collect() +} + +/// Filesystem-safe slug for a codeplug label, so several codeplugs for one +/// radio stay distinguishable among the backups. +fn slug_label(label: &str) -> String { + label + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::() + .trim_matches('-') + .to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Two channel records taken verbatim off Tim's radio on 2026-09-01, plus + /// the three the encoding probe wrote and the radio confirmed on its own + /// screen. These are the anchor: everything else in this module is checked + /// against bytes the radio actually authored. + const RADIO_CH1: [u8; 32] = [ + 0x00, 0x00, 0x51, 0x14, 0x00, 0x00, 0x51, 0x14, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x50, 0x4C, 0x55, 0x47, + 0x37, 0x33, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + ]; + /// TONEA: 146.520 simplex, CTCSS 88.5 both, High, Wide. + const PROBE_TONEA: [u8; 32] = [ + 0x00, 0x20, 0x65, 0x14, 0x00, 0x20, 0x65, 0x14, 0x75, 0x03, 0x75, 0x03, + 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x54, 0x4F, 0x4E, 0x45, + 0x41, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + ]; + /// TONEB: 146.940 / −0.600, DCS 023N both, Low, Narrow. + const PROBE_TONEB: [u8; 32] = [ + 0x00, 0x40, 0x69, 0x14, 0x00, 0x40, 0x63, 0x14, 0x01, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x02, 0x42, 0x00, 0x00, 0x00, 0x00, 0x54, 0x4F, 0x4E, 0x45, + 0x42, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + ]; + /// TONEC: 442.000 simplex, RX CTCSS 141.3 / TX DCS 754I, Middle, Wide. + const PROBE_TONEC: [u8; 32] = [ + 0x00, 0x00, 0x20, 0x44, 0x00, 0x00, 0x20, 0x44, 0x85, 0x05, 0xD2, 0x00, + 0x00, 0x00, 0x01, 0x02, 0x00, 0x00, 0x00, 0x00, 0x54, 0x4F, 0x4E, 0x45, + 0x43, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + ]; + + fn image_with(records: &[(usize, [u8; 32])]) -> Vec { + let mut image = vec![0xFFu8; IMAGE_LEN]; + for (slot, rec) in records { + image[slot * ENTRY_LEN..(slot + 1) * ENTRY_LEN].copy_from_slice(rec); + } + image + } + + /// The step-3 gate: decode the radio's own bytes and get the values the + /// radio shows on its screen. Every field here was read off the radio. + #[test] + fn decodes_the_radios_own_records() { + let image = image_with(&[ + (0, RADIO_CH1), + (1, PROBE_TONEA), + (2, PROBE_TONEB), + (3, PROBE_TONEC), + ]); + let ch = decode_channels(&image); + assert_eq!(ch.len(), 4); + + assert_eq!(ch[0].name, "PLUG73"); + assert_eq!(ch[0].rx_mhz, 145.100); + assert_eq!(ch[0].zone, 1); + + assert_eq!(ch[1].name, "TONEA"); + assert_eq!(ch[1].rx_mhz, 146.520); + assert_eq!(ch[1].rx_tone, "T 88.5"); + assert_eq!(ch[1].power, "High"); + assert!(!ch[1].narrow); + + assert_eq!(ch[2].rx_mhz, 146.940); + assert_eq!(ch[2].tx_mhz, 146.340); + assert_eq!(ch[2].rx_tone, "DTCS 023 N"); + assert_eq!(ch[2].power, "Low"); + assert!(ch[2].narrow); + + assert_eq!(ch[3].rx_tone, "T 141.3"); + assert_eq!(ch[3].tx_tone, "DTCS 754 I"); + assert_eq!(ch[3].power, "Middle"); + } + + /// The most valuable test in the process: re-encode the radio's own records + /// from decoded values and get the radio's own bytes back. + #[test] + fn frequency_codec_round_trips_the_radios_bytes() { + for rec in [RADIO_CH1, PROBE_TONEA, PROBE_TONEB, PROBE_TONEC] { + let rx = lbcd_to_hz(&rec[0..4]); + let tx = lbcd_to_hz(&rec[4..8]); + assert_eq!(hz_to_lbcd(rx), rec[0..4], "rx re-encode"); + assert_eq!(hz_to_lbcd(tx), rec[4..8], "tx re-encode"); + } + } + + /// The probe wrote both ends of the 210-entry table and the radio displayed + /// `D023N` and `D754I`. Lock that, because an off-by-one here puts a + /// different but entirely plausible tone on every DCS channel. + #[test] + fn dcs_table_ends_match_the_radio() { + assert_eq!(dcs::dcs_to_byte(23, false), Some(1)); + assert_eq!(dcs::dcs_to_byte(754, true), Some(210)); + assert_eq!(dcs::byte_to_dcs(1), Some((23, false))); + assert_eq!(dcs::byte_to_dcs(210), Some((754, true))); + assert_eq!(dcs::byte_to_dcs(0), None); + assert_eq!(dcs::DCS_TABLE.len(), 210); + } + + /// CTCSS and DCS share two bytes and are told apart by the high byte being + /// zero. Prove they cannot collide across the radio's whole CTCSS range: + /// the lowest tone, 67.0 Hz, already has a non-zero high byte. + #[test] + fn ctcss_and_dcs_encodings_cannot_collide() { + for tenths in 670..=2541 { + let raw = encode_ctcss(f64::from(tenths) / 10.0); + assert_ne!(raw[1], 0, "CTCSS {tenths} would decode as DCS"); + } + assert_eq!(encode_ctcss(88.5), [0x75, 0x03]); + assert_eq!(encode_ctcss(141.3), [0x85, 0x05]); + } + + /// Blank and named channels use *different* pad bytes on this radio. + /// A channel with no mode is wide FM, because that is what + /// `export::channel_fit` decided when it let the channel through. The two + /// used to disagree, and the encoder silently narrowed it. + #[test] + fn a_null_mode_encodes_as_wide_fm() { + let wide = Channel { rx_freq: 146.52, ..Default::default() }; + assert_eq!(encode_channel(&wide, "NOMODE", 146_520_000, true)[15] & 0x40, 0x00); + + let fm = Channel { rx_freq: 146.52, mode: Some("FM".into()), ..Default::default() }; + assert_eq!(encode_channel(&fm, "FM", 146_520_000, true)[15] & 0x40, 0x00); + + let nfm = Channel { rx_freq: 146.52, mode: Some("NFM".into()), ..Default::default() }; + assert_eq!(encode_channel(&nfm, "NFM", 146_520_000, true)[15] & 0x40, 0x40); + + // Round-trips through the decoder, which knows only these two. + for (mode, want) in [(None, "FM"), (Some("FM"), "FM"), (Some("NFM"), "NFM")] { + let c = Channel { + rx_freq: 146.52, + mode: mode.map(str::to_string), + ..Default::default() + }; + let rec = encode_channel(&c, "X", 146_520_000, true); + let mut image = vec![0xFFu8; IMAGE_LEN]; + image[..ENTRY_LEN].copy_from_slice(&rec); + assert_eq!(decode_channels(&image)[0].narrow, want == "NFM", "{mode:?}"); + } + } + + /// ⚠ MEASURED on the radio (s128), not inherited: a channel written with + /// byte 15 bit 1 clear refuses the PTT while its neighbour with the bit set + /// keys normally. Before that check this driver set the bit unconditionally, + /// because clearing an unverified bit on this platform is how radios have + /// been damaged. + #[test] + fn a_receive_only_channel_is_written_with_the_ptt_disabled() { + let c = Channel { rx_freq: 146.52, mode: Some("FM".into()), ..Default::default() }; + assert_eq!(encode_channel(&c, "TX", 146_520_000, true)[15] & 0x02, 0x02); + assert_eq!(encode_channel(&c, "RX", 146_520_000, false)[15] & 0x02, 0x00); + // The bandwidth bit is independent of it. + let n = Channel { mode: Some("NFM".into()), ..c.clone() }; + assert_eq!(encode_channel(&n, "RX", 146_520_000, false)[15], 0x40); + } + + #[test] + fn names_use_the_radios_two_sentinels() { + assert_eq!(name_bytes(""), [0x00; NAME_LEN]); + assert_eq!(&name_bytes("PLUG73")[..], &RADIO_CH1[20..32]); + assert_eq!(decode_name(&RADIO_CH1[20..32]), "PLUG73"); + assert_eq!(decode_name(&[0x00; NAME_LEN]), ""); + } + + /// The guard that keeps a future edit from restoring the segment table that + /// damaged a radio on this platform. + #[test] + fn write_segments_never_reach_a_firmware_managed_address() { + assert_writable(); + assert!(forbidden(0x7800)); + assert!(forbidden(0x7FFF)); + assert!(forbidden(0x8080)); + assert!(forbidden(0x80FF)); + assert!(!forbidden(0x8000)); + assert!(!forbidden(0x807F)); + } + + /// The read layout defines the image; the write layout must be a strict + /// subset of it, never reaching a byte the read never filled. + #[test] + fn segment_tables_agree() { + assert_eq!( + READ_SEGMENTS.iter().map(|s| s.length).sum::(), + IMAGE_LEN + ); + for w in WRITE_SEGMENTS { + let r = READ_SEGMENTS + .iter() + .find(|r| r.name == w.name) + .expect("every write segment is also read"); + assert_eq!(r.file_offset, w.file_offset); + assert!(w.length <= r.length, "{} writes more than it reads", w.name); + assert!(w.file_offset + w.length <= IMAGE_LEN); + } + assert!( + !WRITE_SEGMENTS.iter().any(|s| s.name == "aprs"), + "the BT-9000's APRS block is read-only: 0x55 answers 0x06 and never commits" + ); + } + + /// Two different keys decoded the same radio image byte-for-byte, which is + /// what proved this rule. Lock its symmetry and its skip conditions. + #[test] + fn xor_is_symmetric_and_skips_what_the_radio_skips() { + let (_, key) = encryption_frame(); + assert_eq!(&key, b"CO 7"); + let original: Vec = (0..=255u8).collect(); + let mut buf = original.clone(); + apply_xor(&mut buf, &key); + apply_xor(&mut buf, &key); + assert_eq!(buf, original, "XOR must be its own inverse"); + + // 0x00 and 0xFF pass through untouched wherever they appear. + let mut sentinels = vec![0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF]; + apply_xor(&mut sentinels, &key); + assert_eq!(sentinels, vec![0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF]); + } + + #[test] + fn image_length_is_enforced() { + assert!(validate_image(&vec![0u8; IMAGE_LEN]).is_ok()); + assert!(validate_image(&vec![0u8; IMAGE_LEN + 1]).is_err()); + assert!(validate_image(&vec![0u8; 0x10000]).is_err()); + } + + #[test] + fn zones_are_positional_only() { + let mut records = Vec::new(); + for slot in [0usize, 63, 64, 959] { + records.push((slot, RADIO_CH1)); + } + let image = image_with(&records); + let ch = decode_channels(&image); + assert_eq!(ch[0].zone, 1); + assert_eq!(ch[1].zone, 1); + assert_eq!(ch[2].zone, 2); + assert_eq!(ch[3].zone, ZONE_COUNT); + } +} diff --git a/src-tauri/src/radios/binteradio_bt9000/settings.rs b/src-tauri/src/radios/binteradio_bt9000/settings.rs new file mode 100644 index 0000000..fa65ac6 --- /dev/null +++ b/src-tauri/src/radios/binteradio_bt9000/settings.rs @@ -0,0 +1,606 @@ +//! BT-9000 non-channel settings, read from and written to the function block. +//! +//! ## The write is deliberately narrow +//! +//! Settings live in one 256-byte segment at radio `0x9000`. The driver *can* +//! write the whole 33 KB clone image, and doing so to change a squelch level +//! would rewrite all 960 channel records — four minutes instead of a third of a +//! second, with the operator's memories in the write path for a change that +//! never touched them. So this module uses [`super::SETTINGS_SEGMENTS`] and +//! addresses nothing else. A transport that gains reach has to be narrowed on +//! purpose. +//! +//! ## An ACK is not a commit +//! +//! This radio answers `0x06` to blocks it does not always commit — the APRS +//! block acknowledges every write and never changes, verified four times. So a +//! settings write is followed by a read-back in the same session, and the +//! read-back is the only evidence reported. It costs 0.35 s here. +//! +//! ## The radio validates nothing +//! +//! It stored `127` in four function fields whose real maxima are 9, 2, 3 and 1, +//! and read them back unchanged. There is no hardware backstop: every bound has +//! to be enforced here, because a value written out of range is *stored*, not +//! rejected. [`encode_field`] is that backstop and refuses rather than clamps — +//! a silently adjusted value is how an operator ends up transmitting on a +//! setting they did not choose. +//! +//! ## What is NOT here +//! +//! Three fields of the ~43 the sheet names. That is not the radio's limit, it +//! is the measurement's: `scratchpad/binteradio_bt9000/MEASURED.md` grades every +//! row, and only rows settled on the radio's own screen are emitted. The rest +//! are measured but not settled — an option list confirmed at a single index +//! cannot tell the printed order from a reversed one, which is exactly how the +//! TH-D75 shipped a control that wrote Volume Link when the operator picked +//! Level 1. `SCREEN-CHECK.md` is the runbook that closes the gap. + +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::radios::driver::{SettingsCapture, SettingsReader, SettingsWriteReport, SettingsWriter}; + +use super::bt9000_settings_table::{Enc, Kind, FIELDS, SF}; +use super::{ + download_segments, handshake, open_port, upload_segments, FUNCTION_LEN, FUNCTION_OFFSET, + FUNCTION_LIVE_LEN, READ_SEGMENTS, SETTINGS_READ_SEGMENTS, SETTINGS_SEGMENTS, SETTLE, +}; + +// ============================================================ +// Encode / decode one field +// ============================================================ + +/// The field's value as the form carries it: a number for `Int`, the option +/// label for `Enum`, a bool for `Bool`. +fn decode_field(f: &SF, block: &[u8]) -> Value { + let raw = block[f.addr]; + match f.kind { + Kind::Bool => json!(raw != 0), + Kind::Enum => match f.options.get(raw as usize) { + Some(label) => json!(label), + // The radio stores whatever it was handed, including by some other + // tool. Surface the raw byte rather than inventing a label or + // failing the whole read for one field. + None => json!(format!("(unknown value {raw})")), + }, + Kind::Int { lo, hi } => { + let shown = match f.enc { + Enc::Direct => i32::from(raw), + Enc::Minus1 => i32::from(raw) + 1, + }; + if (i32::from(lo)..=i32::from(hi)).contains(&shown) { + json!(shown) + } else { + json!(null) + } + } + } +} + +/// Why a value could not be written. +#[derive(Debug)] +pub(crate) enum Reject { + /// A `select` label this app's option list does not carry. That is not an + /// error: it is how a value read off a radio this app cannot name survives + /// a round trip (see `settings_bounds`' own note on selects). The field is + /// left exactly as the radio has it, and the caller gets a note. + Unnameable(String), + /// The wrong shape, or outside the field's range. This radio would store + /// either one — it stored 127 in a field whose maximum is 1 — so the write + /// stops here rather than being clamped into something plausible. + Invalid(String), +} + +/// The byte to store. +/// +/// Refuses out-of-range rather than clamping: this radio stores a clamped value +/// just as happily as the right one, and the operator would never see the +/// difference. +fn encode_field(f: &SF, v: &Value) -> Result { + match f.kind { + Kind::Bool => v + .as_bool() + .map(u8::from) + .ok_or_else(|| Reject::Invalid(format!("{}: expected true or false, got {v}", f.key))), + Kind::Enum => { + let s = v.as_str().ok_or_else(|| { + Reject::Invalid(format!("{}: expected one of its options, got {v}", f.key)) + })?; + f.options + .iter() + .position(|o| *o == s) + .map(|i| i as u8) + .ok_or_else(|| { + Reject::Unnameable(format!( + "{} [{}] holds {s:?}, which this app cannot name; left as the radio had it", + f.label, f.menu + )) + }) + } + Kind::Int { lo, hi } => { + let n = v + .as_i64() + .ok_or_else(|| Reject::Invalid(format!("{}: expected a number, got {v}", f.key)))?; + if !(i64::from(lo)..=i64::from(hi)).contains(&n) { + return Err(Reject::Invalid(format!( + "{}: {n} is outside {lo}..={hi}", + f.key + ))); + } + Ok(match f.enc { + Enc::Direct => n as u8, + Enc::Minus1 => (n - 1) as u8, + }) + } + } +} + +/// Decode every field out of a full clone image. +pub(crate) fn decode_settings(image: &[u8]) -> Value { + let block = &image[FUNCTION_OFFSET..FUNCTION_OFFSET + FUNCTION_LEN]; + let mut out = serde_json::Map::new(); + for f in &FIELDS { + out.insert(f.key.to_string(), decode_field(f, block)); + } + Value::Object(out) +} + +/// Patch the profile's settings into `image`'s function block. Returns how many +/// fields were written, and a note for each one that was deliberately left +/// alone. +/// +/// Named `apply_profile_settings` rather than `apply_settings` to sit in the +/// right architectural bucket, and `radios/wiring.rs` keys on the difference. +/// `apply_settings` belongs to the CARD radios, whose settings ride out inside +/// an exported file — there the export path has to call the encoder, and twice +/// it did not, which is the guard that test exists to be. This radio is shaped +/// like the TD-H3 instead: settings go over the cable through `SettingsWriter` +/// as their own acknowledged operation, so the caller below IS the write path. +/// +/// A key the profile does not carry is left exactly as the radio had it — this +/// is a patch, not a replace. On a radio that stores anything, writing a default +/// over a field the operator set and this app never measured would be a silent +/// change to a working radio. +pub(crate) fn apply_profile_settings( + image: &mut [u8], + settings: &Value, +) -> Result<(usize, Vec), String> { + let obj = settings + .as_object() + .ok_or_else(|| "profile settings are not a JSON object".to_string())?; + let (mut written, mut notes) = (0, Vec::new()); + for f in &FIELDS { + let Some(v) = obj.get(f.key) else { continue }; + // ⚠ A CLEARED number input is stored as `""`, not null — the form's + // number field maps an empty box to the empty string. Treated as + // "leave it alone" like a missing key, because the alternative is what + // it used to do: reject the whole write, so clearing one field stopped + // every OTHER field from reaching the radio too. + if v.is_null() || v.as_str() == Some("") { + continue; + } + match encode_field(f, v) { + Ok(byte) => { + image[FUNCTION_OFFSET + f.addr] = byte; + written += 1; + } + Err(Reject::Unnameable(note)) => notes.push(note), + Err(Reject::Invalid(e)) => return Err(e), + } + } + Ok((written, notes)) +} + +// ============================================================ +// SettingsReader / SettingsWriter +// ============================================================ + +impl SettingsReader for super::BinteradioBt9000 { + /// Read the whole clone image and decode the settings out of it. + /// + /// The *read* is the full image rather than the one segment, because the + /// command layer saves the capture as the session's backup and a + /// function-block-only file is not a backup of anything. Reading everything + /// is harmless; it is the write that has to be narrow. + fn read_settings(&self, port: &str, _schema_json: &str) -> Result { + let mut p = open_port(port)?; + let hs = handshake(&mut *p)?; + let image = download_segments(&mut *p, &hs, &READ_SEGMENTS)?; + Ok(SettingsCapture { + settings: decode_settings(&image), + backup: image, + backup_ext: "img", + }) + } +} + +impl SettingsWriter for super::BinteradioBt9000 { + /// Read + back up the whole image, patch the settings into its function + /// block, write **only that block**, then read it back and compare. + fn write_settings( + &self, + port: &str, + settings: &Value, + schema_json: &str, + backup_dir: &Path, + ) -> Result { + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let backup_path = backup_dir.join(format!("bt9000-presettings-{stamp}.img")); + + let mut p = open_port(port)?; + + // 1. Read and back up everything, so the file beside the write is a + // whole radio and not just the part being changed. + let hs = handshake(&mut *p)?; + let mut image = download_segments(&mut *p, &hs, &READ_SEGMENTS)?; + std::fs::write(&backup_path, &image) + .map_err(|e| format!("could not write backup {}: {e}", backup_path.display()))?; + + // 2. Patch. The shared range check runs first so a stale profile value + // is dropped with a note rather than blocking the write; this + // driver's own encoder is then the backstop, because the radio is + // not one. + let mut settings = settings.clone(); + let mut notes = crate::radios::settings_bounds::strip_out_of_range( + schema_json, + &mut settings, + ); + let (fields_written, skipped) = apply_profile_settings(&mut image, &settings)?; + notes.extend(skipped); + let expected: Vec = + image[FUNCTION_OFFSET..FUNCTION_OFFSET + FUNCTION_LEN].to_vec(); + + // 3. Write the function segment alone. Fresh session: this radio needs + // a moment after a full read before it answers again. + std::thread::sleep(SETTLE); + let hs = handshake(&mut *p).map_err(|e| { + crate::radios::driver::with_restore_hint( + e, + &backup_path, + "Nothing was written. That file is the radio as it was read.", + ) + .to_string() + })?; + upload_segments(&mut *p, &hs, &image, &SETTINGS_SEGMENTS).map_err(|e| { + crate::radios::driver::with_restore_hint( + e, + &backup_path, + "Keep that file. It is the only copy of what was on the radio \ + before this write, and it can be uploaded back over the same cable.", + ) + })?; + + // 4. Read the block back, and RETRY ONCE if it disagrees. + // + // Measured on the radio (s128): a settings write acknowledged every + // block and did not commit, and an identical second write landed + // perfectly. The read-back is what caught it, so acting on the + // result is the point of having one — reporting "it did not take, + // try again" while holding a proven-idempotent 0.35 s write in hand + // is worse for the operator and no safer. + // + // Exactly one retry. A radio that fails twice is not flaky, and + // hammering a write path on this platform is how radios have been + // damaged. + std::thread::sleep(SETTLE); + // ⚠ Verify ONCE and keep the answer. Calling it again for the report + // meant a proven-committed write could still be announced as + // unverified, because the second call is a fresh handshake and download + // on a radio this driver documents as needing seconds to settle and as + // wedging its handshake. Only an actual retry earns a second look. + let mut outcome = verify(&mut *p, &expected); + if matches!(outcome, Ok((false, _))) { + let hs = handshake(&mut *p).map_err(|e| { + crate::radios::driver::with_restore_hint( + e, + &backup_path, + "The first write did not commit and the radio would not answer for \ + a retry. Keep that file — it is the radio as it was read.", + ) + .to_string() + })?; + upload_segments(&mut *p, &hs, &image, &SETTINGS_SEGMENTS).map_err(|e| { + crate::radios::driver::with_restore_hint( + e, + &backup_path, + "The first write did not commit and the retry failed. Keep that \ + file — it is the radio as it was before either attempt.", + ) + })?; + notes.push( + "The radio acknowledged the first write without committing it, so it \ + was written a second time." + .to_string(), + ); + std::thread::sleep(SETTLE); + outcome = verify(&mut *p, &expected); + } + let (verified, verify_note) = match outcome { + Ok(v) => v, + Err(e) => ( + false, + Some(format!( + "Settings written, but read-back verification could not run ({e}). \ + This radio acknowledges blocks it does not always commit, so \ + power-cycle it and use Read to confirm before trusting it." + )), + ), + }; + notes.extend(verify_note); + let note = (!notes.is_empty()).then(|| notes.join(" ")); + + Ok(SettingsWriteReport { + fields_written, + verified: Some(verified), + note, + backup_path: backup_path.to_string_lossy().to_string(), + expected_path: None, + windows_written: Vec::new(), + }) + } +} + +/// Re-read the function block and compare it with what was sent. +fn verify( + p: &mut dyn serialport::SerialPort, + expected: &[u8], +) -> Result<(bool, Option), String> { + let hs = handshake(p)?; + let back = download_segments(p, &hs, &SETTINGS_READ_SEGMENTS)?; + // ⚠ Compare the LIVE area only. `0x80-0xFF` of this segment is a + // firmware-maintained shadow of the settings, and it moves on its own — + // comparing the whole 256 bytes reported a mismatch on a restore that had + // in fact landed perfectly (measured on the radio, s128). Everything this + // driver writes lives below `0x46`. + let live = super::comparable(SETTINGS_READ_SEGMENTS[0]); + let got = &back[live]; + let expected = &expected[..FUNCTION_LIVE_LEN]; + if got == expected { + return Ok((true, None)); + } + let bad: Vec = FIELDS + .iter() + .filter(|f| got[f.addr] != expected[f.addr]) + .map(|f| { + format!( + "{} [{}] (wrote {}, read {})", + f.label, f.menu, expected[f.addr], got[f.addr] + ) + }) + .collect(); + let detail = if bad.is_empty() { + "the differences are outside the fields this app writes".to_string() + } else { + bad.join(", ") + }; + Ok(( + false, + Some(format!( + "The radio acknowledged the write but read back differently: {detail}. \ + The settings on the radio are NOT what was sent." + )), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The table and the form schema come from one parse of one sheet, and a + /// drift between them is invisible at runtime: a form field with no table + /// entry silently does nothing, and a table entry with no form field is a + /// setting nobody can reach. + #[test] + fn table_and_schema_describe_the_same_fields() { + let schema: Vec = serde_json::from_str(crate::seed::BT9000_SETTINGS_SCHEMA).unwrap(); + let form: Vec<&str> = schema + .iter() + .filter(|f| f["type"] != "section") + .map(|f| f["key"].as_str().unwrap()) + .collect(); + let table: Vec<&str> = FIELDS.iter().map(|f| f.key).collect(); + assert_eq!(form, table, "regenerate both with gen_bt9000_settings.py"); + } + + /// Every enum option in the schema must exist in the table in the same + /// order, because the table's index IS the byte written to the radio. + #[test] + fn schema_options_match_the_stored_order() { + let schema: Vec = serde_json::from_str(crate::seed::BT9000_SETTINGS_SCHEMA).unwrap(); + for f in &FIELDS { + if f.kind != Kind::Enum { + continue; + } + let entry = schema + .iter() + .find(|e| e["key"] == f.key) + .unwrap_or_else(|| panic!("{} missing from the schema", f.key)); + let opts: Vec<&str> = entry["options"] + .as_array() + .unwrap() + .iter() + .map(|o| o.as_str().unwrap()) + .collect(); + assert_eq!(opts, f.options, "{}: schema and table disagree", f.key); + } + } + + /// The narrowed write must address the function segment and nothing else. + /// Both constants are INDEXES into their tables, so a reordering of either + /// would silently retarget every settings write at the channel records. + #[test] + fn settings_segments_address_only_the_function_block() { + for (segs, cmd, what) in [ + (&SETTINGS_SEGMENTS[..], 0x57u8, "write"), + (&SETTINGS_READ_SEGMENTS[..], 0x52u8, "read"), + ] { + assert_eq!(segs.len(), 1, "{what}"); + let seg = segs[0]; + assert_eq!(seg.name, "function", "{what}"); + assert_eq!(seg.address, 0x9000, "{what}"); + assert_eq!(seg.file_offset, FUNCTION_OFFSET, "{what}"); + assert_eq!(seg.length, FUNCTION_LEN, "{what}"); + // ⚠ The read and write tables describe the SAME block with + // different opcodes. Reading with the write segment would put a + // write command on the wire with nothing behind it, on a platform + // where a desynchronised write stream has permanently degraded a + // radio's transmit. + assert_eq!(seg.command, cmd, "{what} segment carries the wrong opcode"); + } + } + + /// And each transport refuses a segment from the other table outright, so a + /// future caller reaching for the wrong constant gets an error instead of + /// the wire. Checked before any I/O, which is why it needs no port. + #[test] + fn a_transport_refuses_a_segment_from_the_other_table() { + let err = super::super::check_commands(&SETTINGS_SEGMENTS, &[0x52, 0x54], "read") + .expect_err("reading with the write segment must be refused"); + assert!(err.contains("not a read command"), "{err}"); + + let err = super::super::check_commands(&SETTINGS_READ_SEGMENTS, &[0x57], "write") + .expect_err("writing with the read segment must be refused"); + assert!(err.contains("not a write command"), "{err}"); + + // The pairings the driver actually uses are accepted. + super::super::check_commands(&SETTINGS_READ_SEGMENTS, &[0x52, 0x54], "read").unwrap(); + super::super::check_commands(&SETTINGS_SEGMENTS, &[0x57], "write").unwrap(); + } + + /// ⚠ Every field must live inside the area the read-back compares. + /// + /// `verify` truncates to `FUNCTION_LIVE_LEN` (0x46) and then indexes + /// `FIELDS` addresses into that slice. The highest address today is 0x44 — + /// one byte of headroom — and the sheet still has rows owed. A field at 0x46 + /// or above would never be verified AND would panic inside the + /// mismatch-reporting loop, which only runs when a write failed to commit. + #[test] + fn every_field_lives_inside_the_verified_area() { + for f in &FIELDS { + assert!( + f.addr < FUNCTION_LIVE_LEN, + "{} is at 0x{:02X}, outside the 0x{:02X} bytes `verify` compares", + f.key, + f.addr, + FUNCTION_LIVE_LEN + ); + } + } + + /// Round-trip every field through the form's own representation. + #[test] + fn every_field_round_trips() { + let mut image = vec![0u8; super::super::IMAGE_LEN]; + for f in &FIELDS { + let probes: Vec = match f.kind { + Kind::Bool => vec![json!(false), json!(true)], + Kind::Enum => f.options.iter().map(|o| json!(o)).collect(), + Kind::Int { lo, hi } => (lo..=hi).map(|n| json!(n)).collect(), + }; + for v in probes { + let raw = encode_field(f, &v).unwrap(); + image[FUNCTION_OFFSET + f.addr] = raw; + let back = decode_field(f, &image[FUNCTION_OFFSET..FUNCTION_OFFSET + FUNCTION_LEN]); + assert_eq!(back, v, "{} did not round-trip through byte {raw}", f.key); + } + } + } + + /// ⚠ The radio stores whatever it is handed — 127 into a field whose + /// maximum is 1 — so a value the form should never produce has to be + /// refused HERE. Nothing downstream will catch it. + #[test] + fn out_of_range_is_refused_not_clamped() { + for f in &FIELDS { + let bad = match f.kind { + Kind::Bool => json!("yes"), + Kind::Enum => json!(42), + Kind::Int { hi, .. } => json!(i64::from(hi) + 1), + }; + match encode_field(f, &bad) { + Err(Reject::Invalid(e)) => assert!(e.contains(f.key), "{}: {e}", f.key), + other => panic!("{} accepted {bad}: {other:?}", f.key), + } + } + } + + /// A select label this app cannot name must SKIP, not fail. That is how a + /// value some other tool put on the radio survives being read into a + /// profile and written back — the byte keeps whatever the radio had. + #[test] + fn an_unnameable_select_leaves_the_radio_alone() { + let f = FIELDS + .iter() + .find(|f| f.kind == Kind::Enum) + .expect("a settled enum field"); + assert!(matches!( + encode_field(f, &json!("something this app has never heard of")), + Err(Reject::Unnameable(_)) + )); + + let mut image = vec![0x77u8; super::super::IMAGE_LEN]; + let (written, notes) = + apply_profile_settings(&mut image, &json!({f.key: "something else"})).unwrap(); + assert_eq!(written, 0); + assert_eq!(notes.len(), 1); + assert_eq!(image[FUNCTION_OFFSET + f.addr], 0x77, "the byte was touched"); + } + + /// An index the radio holds that this app has no label for must survive + /// being decoded, or the round trip above has nothing to carry. + #[test] + fn an_unmapped_stored_value_decodes_to_something_writable_back() { + let f = FIELDS.iter().find(|f| f.kind == Kind::Enum).unwrap(); + let mut image = vec![0u8; super::super::IMAGE_LEN]; + image[FUNCTION_OFFSET + f.addr] = 200; + let decoded = decode_settings(&image); + assert!(matches!( + encode_field(f, &decoded[f.key]), + Err(Reject::Unnameable(_)) + )); + } + + /// The two "Level 1-9" fields two bytes apart do NOT share a convention: + /// SQL stores the level, VOX Level stores the level minus one. Both were + /// read off the radio's screen; a generator that assumed one rule for both + /// would ship one of them silently off by one. + #[test] + fn the_two_level_fields_keep_their_different_conventions() { + let sql = FIELDS.iter().find(|f| f.key == "squelch").unwrap(); + let vox = FIELDS.iter().find(|f| f.key == "vox-level").unwrap(); + assert_eq!(encode_field(sql, &json!(9)).unwrap(), 9); + assert_eq!(encode_field(vox, &json!(7)).unwrap(), 6); + } + + /// Clearing one field in the form must not stop the others reaching the + /// radio. The form stores an empty number box as `""`, and rejecting that + /// as "expected a number" used to fail the entire write. + #[test] + fn a_cleared_field_is_skipped_not_fatal() { + let mut image = vec![0u8; super::super::IMAGE_LEN]; + let (n, notes) = apply_profile_settings( + &mut image, + &json!({"squelch": "", "vox-level": 5, "power-on-display": null}), + ) + .expect("a cleared field must not fail the write"); + assert_eq!(n, 1, "vox-level should still have been written"); + assert!(notes.is_empty()); + assert_eq!(image[FUNCTION_OFFSET + 0x02], 4, "vox-level 5 stores as 4"); + } + + /// A key the profile does not carry must come back off the radio untouched. + #[test] + fn unknown_keys_are_left_alone() { + let mut image = vec![0x5Au8; super::super::IMAGE_LEN]; + let (n, notes) = apply_profile_settings(&mut image, &json!({"squelch": 4})).unwrap(); + assert_eq!(n, 1); + assert!(notes.is_empty()); + assert_eq!(image[FUNCTION_OFFSET], 4); + // Every other byte of the block is as it was read. + for i in 1..FUNCTION_LEN { + assert_eq!(image[FUNCTION_OFFSET + i], 0x5A, "byte 0x{i:02X} was touched"); + } + } +} diff --git a/src-tauri/src/radios/driver.rs b/src-tauri/src/radios/driver.rs index 851d409..664e9cc 100644 --- a/src-tauri/src/radios/driver.rs +++ b/src-tauri/src/radios/driver.rs @@ -192,6 +192,22 @@ pub(crate) trait ImageProgrammer: Send + Sync { port: &str, req: &ImageProgramRequest, ) -> Result; + + /// Whether `program_codeplug` writes the profile's non-channel settings + /// alongside the channels. + /// + /// It is NOT implied by `req.settings` being populated — the command layer + /// fills that for every driver, and most deliberately ignore it. A radio + /// with its own `SettingsWriter` generally treats settings as a separate, + /// explicitly-acknowledged operation and leaves the radio's own settings + /// alone during a channel program (the TD-H3 and the BT-9000 both do). + /// + /// Declared rather than inferred because the generic Program dialog states + /// in a safety banner what the operation will change, and that sentence was + /// promising settings on five of the six radios it serves. + fn carries_profile_settings(&self) -> bool { + false + } } /// Attach the pre-write backup to an error raised DURING the write phase. @@ -591,6 +607,10 @@ pub struct DriverCapabilities { pub write_settings: bool, pub write_channels: bool, pub program_codeplug: bool, + /// Whether a codeplug program also writes the profile's settings. Only the + /// UV-5R does: it has no standalone settings-write path, so its settings + /// ride out inside the image `program_codeplug` uploads. + pub programs_settings: bool, pub write_callsign_db: bool, pub export: bool, pub diagnostics: bool, @@ -608,6 +628,9 @@ impl DriverCapabilities { write_settings: driver.as_settings_writer().is_some(), write_channels: driver.as_channel_writer().is_some(), program_codeplug: driver.as_codeplug_programmer().is_some(), + programs_settings: driver + .as_image_programmer() + .is_some_and(ImageProgrammer::carries_profile_settings), write_callsign_db: driver.as_callsign_db_writer().is_some(), export: driver.as_codeplug_exporter().is_some(), diagnostics: driver.as_diagnostics().is_some(), diff --git a/src-tauri/src/radios/icom_id52/mod.rs b/src-tauri/src/radios/icom_id52/mod.rs index 924f496..e386bb8 100644 --- a/src-tauri/src/radios/icom_id52/mod.rs +++ b/src-tauri/src/radios/icom_id52/mod.rs @@ -188,6 +188,7 @@ mod tests { write_settings: false, write_channels: false, program_codeplug: false, + programs_settings: false, write_callsign_db: false, export: true, diagnostics: false, diff --git a/src-tauri/src/radios/kenwood_thd75/mod.rs b/src-tauri/src/radios/kenwood_thd75/mod.rs index 6fe32ae..2c4c09d 100644 --- a/src-tauri/src/radios/kenwood_thd75/mod.rs +++ b/src-tauri/src/radios/kenwood_thd75/mod.rs @@ -124,6 +124,7 @@ mod tests { write_settings: false, write_channels: false, program_codeplug: false, + programs_settings: false, write_callsign_db: false, export: true, diagnostics: false, diff --git a/src-tauri/src/radios/mod.rs b/src-tauri/src/radios/mod.rs index 63b3121..ca5876e 100644 --- a/src-tauri/src/radios/mod.rs +++ b/src-tauri/src/radios/mod.rs @@ -27,6 +27,7 @@ pub(crate) mod anytone_atd890uv; pub(crate) mod baofeng_uv5r; +pub(crate) mod binteradio_bt9000; pub(crate) mod driver; #[cfg(test)] pub(crate) mod fake_port; diff --git a/src-tauri/src/radios/registry.rs b/src-tauri/src/radios/registry.rs index b0a9ac3..1d6c18b 100644 --- a/src-tauri/src/radios/registry.rs +++ b/src-tauri/src/radios/registry.rs @@ -23,8 +23,9 @@ 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; 7] = [ +static DRIVERS: [&dyn RadioDriver; 8] = [ &super::baofeng_uv5r::DRIVER, + &super::binteradio_bt9000::DRIVER, &super::tidradio_tdh3::DRIVER, &super::anytone_atd890uv::DRIVER, &super::yaesu_ft5d::DRIVER, @@ -75,6 +76,7 @@ mod tests { fn every_driver_key_resolves_and_is_unique() { for key in [ "baofeng_uv5r", + "binteradio_bt9000", "tidradio_tdh3", "anytone_atd890uv", "yaesu_ft5d", @@ -114,6 +116,19 @@ mod tests { // `MU` ASCII command — no clone session involved, which is why // it claims both halves where the card radios claim neither. "kenwood_thd72" => (true, true), + // BT-9000: both. Like the TH-D72 it reads and writes its own + // settings, but through a clone session rather than an ASCII + // command — and the WRITE is narrowed to the one 256-byte + // function segment, because the same transport could rewrite + // all 960 channel records to change a squelch level. + // + // ⚠ The schema behind this is deliberately SHORT. It carries + // only the fields whose encoding was settled on the radio's own + // screen; the measurement sheet grades ~43 candidates and the + // generator withholds the rest. A wrong encoding here is not + // caught anywhere downstream — this radio stored 127 in fields + // whose maxima are 9, 2, 3 and 1 (issue #43). + "binteradio_bt9000" => (true, true), _ => (true, true), }; assert_eq!( @@ -174,8 +189,10 @@ mod tests { #[test] fn all_drivers_identify_but_only_clone_radios_download_images() { for d in all_drivers() { - let expect_image = - matches!(d.key(), "baofeng_uv5r" | "tidradio_tdh3" | "kenwood_thd72"); + let expect_image = matches!( + d.key(), + "baofeng_uv5r" | "tidradio_tdh3" | "kenwood_thd72" | "binteradio_bt9000" + ); assert_eq!( d.as_image_programmer().is_some(), expect_image, diff --git a/src-tauri/src/radios/wiring.rs b/src-tauri/src/radios/wiring.rs index 13ffe6c..4e071e3 100644 --- a/src-tauri/src/radios/wiring.rs +++ b/src-tauri/src/radios/wiring.rs @@ -40,6 +40,127 @@ fn production_half(src: &str) -> &str { } /// Every `radios//` folder that holds a driver. +/// The README's "Supported radios" table, and its "Planned" table, as text. +/// +/// ⚠ Line-based on purpose. The first version split the file into paragraphs on +/// `"\n\n"`, which is not how the file looks on a Windows checkout: CRLF made +/// every paragraph boundary `"\r\n\r\n"`, the Planned table was never found, +/// and the fallback block matched a later section that happens to name a shipped +/// radio. Green on macOS and Ubuntu, red on Windows — exactly the kind of defect +/// the three-OS run exists to catch. +fn readme_tables(readme: &str) -> (String, String) { + let line = |l: &str| l.trim_end_matches('\r').to_string(); + let mut supported = String::new(); + let mut planned = String::new(); + let (mut in_supported, mut in_planned) = (false, false); + for raw in readme.lines() { + let l = line(raw); + if l.starts_with("## ") { + in_supported = l.contains("Supported radios"); + in_planned = false; + continue; + } + if l.trim() == "**Planned**" { + in_planned = true; + continue; + } + // A table ends at the first non-table line after it has started. + if in_planned && !planned.is_empty() && !l.starts_with('|') { + in_planned = false; + } + if in_supported && l.starts_with('|') { + supported.push_str(&l); + supported.push('\n'); + } + if in_planned && l.starts_with('|') { + planned.push_str(&l); + planned.push('\n'); + } + } + (supported, planned) +} + +/// Every radio the app can actually program must be listed in the README's +/// "Supported radios" table, and must NOT still be listed under "Planned". +/// +/// ⚠ This exists because it was missed twice. The TH-D72 shipped in v26.8.29 and +/// never reached the table, and the BT-9000 was still sitting under "Planned" +/// under a manufacturer name that is not even its own on the day it shipped. A +/// radio someone owns and cannot tell is supported may as well not be. +/// +/// Keyed on `display_name`, which is what the table prints and what the app +/// shows the operator, so the two cannot drift into describing it differently. +#[test] +fn every_seeded_radio_appears_in_the_readme() { + let readme = std::fs::read_to_string(manifest_dir().join("../README.md")) + .expect("README.md is readable from the crate"); + let (supported, planned) = readme_tables(&readme); + assert!(!supported.is_empty(), "no `## Supported radios` table found"); + + let mut checked = 0; + for d in crate::radios::registry::all_drivers() { + let name = d.display_name(); + checked += 1; + // ⚠ Matched as the bolded cell `**Name**`, not as a bare substring of the + // table. "Icom ID-51" is a substring of the planned "Icom ID-5100", so a + // substring match would report the ID-51 as still planned the day it + // ships (#50), and would accept an ID-5100 row as proof the ID-51 is + // supported. + let cell = format!("**{name}**"); + assert!( + supported.contains(&cell), + "{name} is a working driver but is not in the README's Supported radios \ + table. Someone who owns one cannot tell that it works." + ); + assert!( + !planned.contains(&cell), + "{name} ships AND is still listed under Planned" + ); + } + assert!(checked > 0, "no drivers found — this test would pass vacuously"); +} + +/// The parser itself, against both line endings, because the difference between +/// them is what broke it on Windows while two other platforms said it was fine. +#[test] +fn readme_tables_are_parsed_the_same_with_either_line_ending() { + const DOC: &str = "\ +## Supported radios + +| Radio | Notes | +|---|---| +| **Kept Radio** | works | + +Some prose that mentions Planned Radio in passing. + +## Future development + +**Planned** + +| Radio | Issue | +|---|---| +| **Planned Radio** | #1 | + +Trailing prose naming Kept Radio again. +"; + for (label, doc) in [("LF", DOC.to_string()), ("CRLF", DOC.replace('\n', "\r\n"))] { + let (supported, planned) = readme_tables(&doc); + assert!(supported.contains("**Kept Radio**"), "{label}: supported table lost a row"); + assert!(!supported.contains("**Planned Radio**"), "{label}: tables bled together"); + assert!(planned.contains("**Planned Radio**"), "{label}: planned table not found"); + assert!( + !planned.contains("**Kept Radio**"), + "{label}: planned table ran past its end and swallowed later prose" + ); + // A shorter name must not match a longer one that starts with it — the + // ID-51 / ID-5100 collision this guard would otherwise have. + assert!( + !planned.contains("**Planned Rad**"), + "{label}: a prefix matched a longer name" + ); + } +} + fn driver_dirs() -> Vec { let radios = manifest_dir().join("src/radios"); let mut dirs: Vec = std::fs::read_dir(&radios) diff --git a/src-tauri/src/radios/yaesu_ft5d/mod.rs b/src-tauri/src/radios/yaesu_ft5d/mod.rs index e235009..72a5353 100644 --- a/src-tauri/src/radios/yaesu_ft5d/mod.rs +++ b/src-tauri/src/radios/yaesu_ft5d/mod.rs @@ -283,6 +283,7 @@ mod tests { write_settings: false, write_channels: false, program_codeplug: false, + programs_settings: false, write_callsign_db: false, export: true, diagnostics: false, diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index 4ffe3e7..2a831f7 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -176,6 +176,19 @@ pub const THD75_SETTINGS_SCHEMA: &str = include_str!("thd75_settings_schema.json /// for the six-of-six cross-check that showed the two carry the same values. pub const THD72_SETTINGS_SCHEMA: &str = include_str!("thd72_settings_schema.json"); +/// The BT-9000 profile-settings schema, GENERATED alongside the Rust field +/// table by `scratchpad/binteradio_bt9000/gen_bt9000_settings.py` from that +/// folder's `MEASURED.md`. `radios/binteradio_bt9000/settings.rs` asserts the +/// two halves still describe the same fields. +/// +/// ⚠ It is SHORT on purpose. The sheet grades ~43 candidate fields and this +/// carries only the ones whose encoding was settled on the radio's own screen. +/// This radio validates nothing — it stored 127 in fields whose maxima are 9, +/// 2, 3 and 1 — so an unsettled encoding would be stored rather than refused, +/// and the schema is where that decision is enforced. +pub const BT9000_SETTINGS_SCHEMA: &str = + include_str!("bt9000_settings_schema.json"); + fn models() -> Vec { vec![ @@ -196,12 +209,18 @@ fn models() -> Vec { p25_capable: false, m17_capable: false, aprs_capable: false, - covers_hf: false, + // Updated with the measured bands: `tx_bands` now reaches 18-64 MHz + // (27.500 and 50.125 both keyed, so part of it is HF) and 200-260 + // (223.500 keyed, which is 1.25 m). These flags are display only — + // `tx_capable` reads `tx_bands` — but they are what the band chips + // in the codeplug and profile screens show, and therefore what an + // operator picks a radio by. + covers_hf: true, covers_vhf: true, covers_uhf: true, - covers_220: false, + covers_220: true, covers_900: false, - freq_min: 136.0, + freq_min: 18.0, freq_max: 520.0, tx_bands: None, rx_bands: None, @@ -299,6 +318,108 @@ fn models() -> Vec { ]"#, }, // -------------------------------------------------------- + // 9. Binteradio BT-9000 (issue #43) — analog FM/NFM/AM clone-mode HT. + // One badge on an OEM platform also sold as the Radtel RT-950 Pro, + // Bajeton BJ-9000 and Tenway TP-900 Pro; the radio reports its model + // as "RT-950" whatever the case says. + // + // 960 channels in 15 FIXED zones of 64. Zones carry no names in the + // radio — membership is index/64 and the vendor CPS keeps labels + // only in its own file — so `zones_supported` is false: this app + // would be offering a name the radio cannot store. + // -------------------------------------------------------- + ModelSeed { + manufacturer: "Binteradio", + model: "BT-9000", + driver_key: Some("binteradio_bt9000"), + programming_ui: Some("generic"), + display_name: "Binteradio BT-9000", + analog_capable: true, + dmr_capable: false, + dstar_capable: false, + ysf_capable: false, + nxdn_capable: false, + p25_capable: false, + m17_capable: false, + // The radio HAS APRS and GPS, and its APRS block decodes correctly. + // The flag stays false because the block cannot be WRITTEN: 0x55 + // answers 0x06 and never commits (issue #43, measured four times). + // Claiming the capability would put an APRS form in front of the + // operator that silently does nothing. + aprs_capable: false, + // Updated with the measured bands: `tx_bands` now reaches 18-64 MHz + // (27.500 and 50.125 both keyed, so part of it is HF) and 200-260 + // (223.500 keyed, which is 1.25 m). These flags are display only — + // `tx_capable` reads `tx_bands` — but they are what the band chips + // in the codeplug and profile screens show, and therefore what an + // operator picks a radio by. + covers_hf: true, + covers_vhf: true, + covers_uhf: true, + covers_220: true, + covers_900: false, + freq_min: 18.0, + freq_max: 520.0, + // MEASURED on the radio (s128), one channel per band edge and the + // PTT pressed on each. The image could not answer this — it stores + // every frequency it is given — so each of these was keyed. + // + // | probe | keys? | + // |---|---| + // | 27.500, 50.125 | yes (27.5 needs the 18-64 Work Band selected) | + // | 108.000 | **NO** — receives AM, refuses to transmit | + // | 136.000, 145.100, 174.000 | yes | + // | 200.000, 223.500, 260.000 | yes — the `F` blob's third pair is REAL | + // | 400.000, 431.100, 520.000 | yes | + // | 580.000 | not settable on the VFO at all | + // + // So the radio DOES gate transmit — 108 refusing is what proves the + // other twelve "yes" answers mean something — and 220 MHz is real, + // which the manual never mentions. + // + // ⚠ The spans between tested points are NOT measured: 64-108, + // 108-136, 174-200, 260-400 and 520-580 are excluded because + // nothing was keyed there, not because anything refused. The three + // narrow spans take both of their own edges from a successful key; + // 18-64 takes the range the radio's own Work Band menu declares, + // with 27.5 and 50.125 confirmed inside it. + tx_bands: Some("[[18.0,64.0],[136.0,174.0],[200.0,260.0],[400.0,520.0]]"), + // The receiver is wider than the transmitter, which is why these now + // differ. A channel that lands here but not in `tx_bands` is + // programmed receive-only — byte 15 bit 1 clear — which is measured, + // not assumed: such a channel refuses the PTT while its neighbour + // keys normally. + // + // ⚠ CAPPED AT 520, not at the 999 the Work Band menu advertises. + // The highest frequency anything was confirmed at is 520.000, and + // the next probe up — 580.000 — could not be dialled on the VFO at + // all. Claiming 999 on the strength of a menu label would make a 902 + // MHz repeater (RepeaterBook carries them) `ReceiveOnly` instead of + // `Excluded`: it would take a memory slot and be reported as + // written, on a radio that cannot tune it. That is the + // silently-empty-slot failure this project already recorded once on + // the ID-52, and the old conservative seed did not have it. + rx_bands: Some("[[18.0,520.0]]"), + memory_channels: 960, + zones_supported: false, + max_zones: None, + channels_per_zone: None, + // Per-channel scan-add flag (byte 15 bit 2), not named scan lists. + scan_lists_supported: false, + max_scan_lists: None, + banks_supported: false, + max_name_length: 12, + export_format: "chirp_csv", + connection_type: "Kenwood K1 (2-pin)", + // Generated from scratchpad/binteradio_bt9000/MEASURED.md, and + // carrying only the fields whose encoding was settled on the + // radio's own screen. The sheet names ~43 candidates; the rest are + // measured but not settled, and are listed by the generator on + // stderr rather than emitted. SCREEN-CHECK.md is the runbook that + // closes the gap. + non_channel_settings_schema: BT9000_SETTINGS_SCHEMA, + }, + // -------------------------------------------------------- // 2. TIDRADIO TD-H3 — analog FM/NFM/AM, 200 ch, no zones, 8 char. // Wide-RX (18-600) dual/tri-band HT; TX 136-600 on the unlocked // variant (220 MHz behind the TX-220 toggle). Export only (CHIRP @@ -814,6 +935,60 @@ mod tests { /// ⚠️ `UIS` mirrors the keys of `PROGRAM_DIALOGS` in /// `src/components/codeplugs/programDialogs.ts`; adding a bespoke dialog /// means adding its key in both places. + /// The tripwire this replaces has done its job. + /// + /// It fired if the BT-9000's `rx_bands` and `tx_bands` ever diverged, because + /// the encoder set the per-channel TX-enable bit unconditionally and a + /// receive-only channel would therefore have been programmed able to + /// transmit — on a radio that validates nothing and keys wherever it is + /// told. The bit it was waiting on (byte 15 bit 1) was measured on the radio + /// in s128: a channel written with it clear refuses the PTT while its + /// neighbour with it set keys normally. + /// + /// So the bands now legitimately differ, and what needs guarding is the + /// other half: that the encoder actually honours the distinction. + #[test] + fn bt9000_receive_only_channels_are_possible_and_handled() { + let bt = models() + .into_iter() + .find(|m| m.driver_key == Some("binteradio_bt9000")) + .expect("the BT-9000 is seeded"); + assert_ne!( + bt.rx_bands, bt.tx_bands, + "the BT-9000 receives wider than it transmits — 108 MHz takes AM and \ + refuses the PTT — so these are expected to differ" + ); + // The encoder's own test proves the bit; this proves the schema still + // asks it to be used. + let rx: Vec<[f64; 2]> = serde_json::from_str(bt.rx_bands.unwrap()).unwrap(); + let tx: Vec<[f64; 2]> = serde_json::from_str(bt.tx_bands.unwrap()).unwrap(); + // ⚠ "Some rx band is not fully covered by the UNION of the tx bands." + // + // The first version of this asserted `t != r` among its disjuncts, which + // made it true for any bands that merely differ — so `rx = [[137,173]]` + // strictly INSIDE `tx = [[136,174]]` would have passed while receive-only + // was dead code, which is the exact thing it claims to rule out. A guard + // that cannot fail proves nothing. + let uncovered = rx.iter().any(|r| { + // Walk the rx span, swallowing any tx span that overlaps it. If a gap + // survives, the receiver reaches somewhere the transmitter does not. + let mut lo = r[0]; + let mut spans: Vec<[f64; 2]> = tx.iter().copied().filter(|t| t[1] > r[0] && t[0] < r[1]).collect(); + spans.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap()); + for t in spans { + if t[0] > lo { + return true; + } + lo = lo.max(t[1]); + } + lo < r[1] + }); + assert!( + uncovered, + "rx_bands must cover ground tx_bands does not, or receive-only is dead code" + ); + } + #[test] fn every_seeded_driver_key_resolves_and_pairs_with_a_known_ui() { const UIS: [&str; 3] = ["generic", "tdh3", "anytone"]; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 684fa8f..e041f43 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "WW8L Codeplug Magic", - "version": "26.8.29", + "version": "26.9.2", "identifier": "com.ww8l.codeplugmagic", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/codeplugs/ProgramRadioDialog.tsx b/src/components/codeplugs/ProgramRadioDialog.tsx index ebdec9d..4539e40 100644 --- a/src/components/codeplugs/ProgramRadioDialog.tsx +++ b/src/components/codeplugs/ProgramRadioDialog.tsx @@ -426,9 +426,21 @@ export function ProgramRadioDialog({ Programming downloads a full backup first, then - writes the channels, names, and your profile’s radio settings, and - reads the channels back to verify. Only the values your profile - defines change — everything else is written back as it was read. + writes the channels and names + {caps?.programs_settings + ? " and your profile’s radio settings" + : ""} + , and reads the channels back to verify. Only the values your + profile defines change — everything else is written back as it + was read. + {caps?.programs_settings === false && caps?.write_settings && ( + <> + {" "} + This radio’s settings are written separately — open the + profile under Radios and use{" "} + Write to radio. + + )} )} diff --git a/src/components/profiles/ProfileEditor.tsx b/src/components/profiles/ProfileEditor.tsx index ed641da..0be7fcf 100644 --- a/src/components/profiles/ProfileEditor.tsx +++ b/src/components/profiles/ProfileEditor.tsx @@ -3,7 +3,14 @@ import { confirm as confirmDialog, open as openDialog, } from "@tauri-apps/plugin-dialog"; -import { Trash2, Save, DownloadCloud, RefreshCw, HardDrive } from "lucide-react"; +import { + Trash2, + Save, + DownloadCloud, + UploadCloud, + RefreshCw, + HardDrive, +} from "lucide-react"; import clsx from "clsx"; import { toast } from "sonner"; import { api, withToast } from "../../lib/api"; @@ -120,6 +127,154 @@ function RadioSyncBar({ ); } +/** + * Push this profile's saved settings to the radio. The exact inverse of + * RadioSyncBar, and gated the same way — on the DRIVER's `write_settings` + * capability, never on a model name. + * + * ⚠ It exists because the capability did not have a caller. `SettingsWriter` + * was implemented and hardware-proven on two radios, `write_radio_settings` was + * registered, and the only controls that reached it were the TD-H3's and the + * AnyTone's own program dialogs — so a radio using the generic UI could read + * its settings into this form and had no way to send them back. That is the + * dead-write-path trap one layer up from where it was caught before: the read + * half works, so nothing looks broken. + * + * It writes what is SAVED, not what is typed. The command reads the profile out + * of the database, so an unsaved edit in this form would not go to the radio — + * hence the Save prompt rather than a silent partial write. + */ +function WriteToRadioBar({ + profileId, + modelLabel, + dirty, + neverSaved, +}: { + profileId: number; + modelLabel: string; + dirty: boolean; + /// The profile row carries no settings yet, so there is nothing to send and + /// the command would only error. Treated like `dirty`: same button, same + /// instruction. + neverSaved: boolean; +}) { + const [ports, setPorts] = useState([]); + const [port, setPort] = useState(""); + const [busy, setBusy] = useState(false); + const [confirming, setConfirming] = useState(false); + + const refresh = async () => { + try { + const list = await api.listSerialPorts(); + setPorts(list); + const usb = list.find((p) => p.kind === "usb"); + setPort((cur) => cur || usb?.name || list[0]?.name || ""); + } catch { + /* surfaced on write */ + } + }; + + useEffect(() => { + refresh(); + }, []); + + const upload = async () => { + if (!port) return; + setConfirming(false); + setBusy(true); + const res = await withToast(api.writeRadioSettings(port, profileId), { + error: "Could not write settings to the radio", + }); + setBusy(false); + if (!res) return; + const { toast } = await import("sonner"); + const n = res.fields_written; + const applied = `Wrote ${n} setting${n === 1 ? "" : "s"} to the radio`; + // ⚠ `note` carries the fields that were DROPPED — out of range for the + // schema, or a select value this app cannot name — so it has to be shown + // whether or not the write verified. Reporting "verified ✓" while silently + // discarding the list of what never made it is worse than not reporting. + const suffix = res.note ? ` — ${res.note}` : ""; + // Three states, not two. `verified: null` means the radio offers no + // in-session read-back (the AnyTone reboots on commit), which is not the + // same as a read-back that disagreed. + if (res.verified === true) { + toast.success(`${applied} · read back and verified ✓${suffix}`); + } else if (res.verified === null) { + toast.info( + `${applied}. This radio cannot be read back in the same session, so the write is unverified${suffix}`, + ); + } else { + toast.warning(res.note || `${applied}, but the read-back did not confirm it.`); + } + }; + + return ( +
+
+
+ + Write these settings to a connected {modelLabel} + +
+ + +
+
+ +
+ {(dirty || neverSaved) && ( +

+ {neverSaved + ? "This profile has not been saved yet. The radio is written from the saved profile, so Save first." + : "This profile has unsaved changes. The radio is written from the saved profile, so Save first."} +

+ )} + {confirming && ( +
+ + This changes settings on the radio. Channels are left untouched, and a + backup of the radio is written beside the app's data first. + + + +
+ )} +
+ ); +} + /// The card file each media format's settings are decoded out of. A radio /// programmed from a card gets a settings loader the moment its format has a /// reader here — no new branch in the editor. @@ -739,6 +894,11 @@ export function ProfileEditor({ // from a radio rather than from the operator, and blocking the save over it // would strand a profile they never typed into (see below). const [baseline, setBaseline] = useState({}); + // What the DATABASE holds, which is a different question from `baseline`. + // `baseline` means "not typed by the operator" and deliberately absorbs a + // read from the radio, so it cannot answer "is this profile saved?" — and + // `write_radio_settings` sends the SAVED row, not the form. + const [saved, setSaved] = useState({}); const [lastId, setLastId] = useState(null); if (profile.id !== lastId) { setName(profile.display_name); @@ -753,6 +913,7 @@ export function ProfileEditor({ ); setValues(seeded); setBaseline(seeded); + setSaved(seeded); setLastId(profile.id); setTab("settings"); setSubTab(null); @@ -761,6 +922,23 @@ export function ProfileEditor({ const setValue = (key: string, v: string | number | boolean) => setValues((s) => ({ ...s, [key]: v })); + // `write_radio_settings` sends the profile as STORED, so anything the form + // holds that the database does not must block the write. + // + // ⚠ This compares against `saved`, NOT `baseline`. `baseline` absorbs a read + // from the radio on purpose, so using it here made the button live again the + // instant "Download from radio" finished — and that write would have sent the + // PREVIOUSLY SAVED values straight back over the ones just read, while the + // form went on displaying the radio's. The one path that most needs the guard + // was the one path it did not cover. + const dirty = useMemo( + () => + name !== profile.display_name || + notes !== (profile.notes ?? "") || + fields.some((f) => values[f.key] !== saved[f.key]), + [name, notes, values, saved, fields, profile], + ); + // Values that came off the radio (or its card) are the new starting point, // not an edit — a radio is allowed to hold a value this app's schema does not // describe, and it must stay saveable. @@ -816,7 +994,10 @@ export function ProfileEditor({ { success: "Profile saved" }, ); setSaving(false); - if (updated) onSaved(updated); + if (updated) { + setSaved(values); + onSaved(updated); + } }; const remove = async () => { @@ -897,6 +1078,25 @@ export function ProfileEditor({ onLoaded={loadFromRadio} /> )} + {/* The inverse, gated on the driver's write capability for the same + reason. Card radios never get it: their settings are patched + into the file the export writes, not sent over a cable. + ⚠ And only for radios on the GENERIC programming UI. A radio + with a bespoke dialog already offers this write there, in a + place that can speak to what makes it unusual — the AnyTone's + settings commit reboots the radio and re-enumerates USB, so it + reports `verified: null` plus an `expected_path` to diff in a + fresh session, and a generic bar would show neither. */} + {caps?.write_settings && + fields.length > 0 && + (model.programming_ui ?? "generic") === "generic" && ( + + )} {/* A card radio's settings come off its microSD rather than a cable, so it gets a file picker where the others get a port picker. Keyed on the export format — the same key that names the diff --git a/src/lib/types.ts b/src/lib/types.ts index 6f6c16c..5489fe9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -184,6 +184,10 @@ export interface DriverCapabilities { write_settings: boolean; write_channels: boolean; program_codeplug: boolean; + /// Whether a codeplug program ALSO writes the profile's settings. Only the + /// UV-5R does; every other radio here treats settings as a separate, + /// explicitly-acknowledged write and leaves the radio's own alone. + programs_settings: boolean; write_callsign_db: boolean; export: boolean; diagnostics: boolean;