diff --git a/README.md b/README.md index 9a7eadf..15f8555 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ cable, or its microSD card — or exported as CSV for tools that expect it. | **Baofeng UV-5R** | Analog FM | VHF / UHF | Direct USB — read, write, settings | 128 channels, flat memory | | **TIDRADIO TD-H3** | Analog FM/NFM/AM | VHF / UHF / 1.25 m | Direct USB — read, write, settings | 200 channels, tri-band | | **AnyTone AT-D890UV** | DMR + Analog | VHF / UHF | Direct USB — channels, zones, scan lists, settings, call-sign DB | 4000 channels; full DMR: zones, talkgroups, 308k-entry caller-ID database | +| **AnyTone AT-D578UV** | DMR + Analog mobile | 2 m / 1.25 m / 70 cm | Direct USB — channels, zones, scan lists, talkgroups, settings, call-sign DB | 4000 channels in 250 zones; 264 menu settings including the FM and DMR APRS setup; 500k-entry caller-ID database. AM channels in 108–136 MHz go to the air-band memory, which only the D578UV **PLUS** receives — that part is written to the published map but has not been checked on a PLUS. Same protocol as the D890UV over a different memory map | | **Yaesu FT5D** | C4FM (System Fusion) + Analog | VHF / UHF TX, wideband RX | microSD — patches the radio's own backup file | 900 channels in 24 banks; channels, banks, and menu settings | | **Yaesu FTM-300DR** | C4FM (System Fusion) + Analog | 2 m / 70 cm TX, 108–999 MHz RX | microSD — patches the radio's own backup file | 999 memories; memories and 147 menu settings, including the APRS and SmartBeaconing setup | | **Icom ID-52** | D-STAR + Analog | VHF / UHF TX, 108–174 / 225–479 MHz RX | microSD — patches the radio's own `.icf` file | 1000 memories in 100 groups; memories and menu settings restore in one operation | @@ -62,7 +63,6 @@ settings together, then verify on the actual radio before shipping. | Radio | Modes | Issue | |-------|-------|-------| -| **AnyTone AT-D578UV** | DMR + Analog mobile | [#47](https://github.com/ww8l/codeplug-magic/issues/47) | | **AnyTone AT-D868UV** | DMR + Analog handheld | [#51](https://github.com/ww8l/codeplug-magic/issues/51) | | **Icom ID-51** | D-STAR + Analog handheld | [#50](https://github.com/ww8l/codeplug-magic/issues/50) | | **Icom IC-9100** | HF / VHF / UHF base | [#45](https://github.com/ww8l/codeplug-magic/issues/45) | diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ce168e4..1adb8ab 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6340,6 +6340,7 @@ dependencies = [ "tauri-plugin-opener", "thiserror 2.0.21", "tokio", + "unicode-normalization", "windows-sys 0.61.2", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c925188..bfb5a9a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -55,6 +55,10 @@ md-5 = "0.11" # over `reqwest`'s `bytes_stream()`. reqwest already depends on it, so naming it # here adds no new code to the build. futures-util = { version = "0.3", default-features = false } +# The AnyTone D578's call-sign database is plain single-byte text: library names +# are folded to ASCII (René -> Rene) by decomposing and dropping the accents. +# Already in the tree as a transitive dependency, so this adds no new code. +unicode-normalization = "0.1" # The RepeaterBook API token (#136) is the user's own credential and lives in # the OS credential store, never in SQLite, settings or a backup: Keychain on # macOS, Credential Manager on Windows, Secret Service on Linux. The Linux diff --git a/src-tauri/src/anytone_d578_settings_schema.json b/src-tauri/src/anytone_d578_settings_schema.json new file mode 100644 index 0000000..44ff4fa --- /dev/null +++ b/src-tauri/src/anytone_d578_settings_schema.json @@ -0,0 +1,4096 @@ +[ + { + "key": "__sec_1", + "label": "Radio ID", + "type": "section", + "group": "General" + }, + { + "key": "dmr-id", + "label": "DMR ID", + "type": "integer", + "min": 1, + "max": 16777215 + }, + { + "key": "radio-name", + "label": "Radio Name", + "type": "text", + "max_length": 16 + }, + { + "key": "__sec_2", + "label": "Power-On & Boot", + "type": "section", + "group": "General" + }, + { + "key": "boot-display", + "label": "Boot Display", + "type": "select", + "options": [ + "Default", + "Custom Text", + "Custom Image" + ] + }, + { + "key": "boot-text-line-1", + "label": "Boot text line 1", + "type": "text", + "max_length": 14 + }, + { + "key": "boot-text-line-2", + "label": "Boot text line 2", + "type": "text", + "max_length": 14 + }, + { + "key": "enable-boot-password", + "label": "Enable Boot Password", + "type": "boolean" + }, + { + "key": "boot-password", + "label": "Boot password", + "type": "text", + "max_length": 8 + }, + { + "key": "enable-startup-tone", + "label": "Enable Startup Tone", + "type": "boolean" + }, + { + "key": "boot-zone-vfo-a", + "label": "Boot zone VFO A", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "boot-zone-vfo-b", + "label": "Boot Zone VFO B", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "enable-default-boot-channel", + "label": "Enable default boot channel", + "type": "boolean" + }, + { + "key": "default-boot-channel-vfo-a", + "label": "Default boot channel VFO A", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "default-boot-channel-vfo-b", + "label": "Default boot channel VFO B", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "show-last-caller-on-boot", + "label": "Show last caller on boot", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "__sec_3", + "label": "General", + "type": "section", + "group": "General" + }, + { + "key": "language", + "label": "Language", + "type": "select", + "options": [ + "English", + "German" + ] + }, + { + "key": "ui-mode", + "label": "UI Mode", + "type": "select", + "options": [ + "Amateur", + "Professional" + ] + }, + { + "key": "units", + "label": "Units", + "type": "select", + "options": [ + "Metric", + "Archaic" + ] + }, + { + "key": "date-format", + "label": "Date format", + "type": "select", + "options": [ + "Big Endian", + "Little Endian" + ] + }, + { + "key": "time-zone", + "label": "Time Zone", + "type": "select", + "options": [ + "UTC-12:00h", + "UTC-11:00h", + "UTC-10:00h", + "UTC-09:00h", + "UTC-08:00h", + "UTC-07:00h", + "UTC-06:00h", + "UTC-05:00h", + "UTC-04:00h", + "UTC-03:30h", + "UTC-02:00h", + "UTC-01:00h", + "UTC", + "UTC+01:00h", + "UTC+02:00h", + "UTC+03:00h", + "UTC+03:30h", + "UTC+04:00h", + "UTC+04:30h", + "UTC+05:00h", + "UTC+05:30h", + "UTC+05:45h", + "UTC+06:00h", + "UTC+07:00h", + "UTC+08:00h", + "UTC+08:30h", + "UTC+09:00h", + "UTC+10:00h", + "UTC+11:00h", + "UTC+12:00h", + "UTC+13:00h" + ] + }, + { + "key": "auto-shutoff-delay", + "label": "Auto-shutoff delay", + "type": "select", + "options": [ + "Off", + "10 min", + "30 min", + "60 min", + "120 min" + ] + }, + { + "key": "disable-auto-shutoff-timer-reset-on-call", + "label": "Disable auto-shutoff timer reset on call", + "type": "boolean" + }, + { + "key": "enable-automatic-key-lock", + "label": "Enable automatic key lock", + "type": "boolean" + }, + { + "key": "force-key-lock", + "label": "Force Key Lock", + "type": "boolean" + }, + { + "key": "lock-side-keys", + "label": "Lock Side Keys", + "type": "boolean" + }, + { + "key": "lock-keyboard", + "label": "Lock Keypad", + "type": "boolean" + }, + { + "key": "lock-knob", + "label": "Lock Knob", + "type": "boolean" + }, + { + "key": "fan-control", + "label": "Fan control", + "type": "select", + "options": [ + "PTT", + "Temperature", + "Both" + ] + }, + { + "key": "menu-exit-time", + "label": "Menu exit time", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "enable-weather-alarm", + "label": "Enable Weather Alarm", + "type": "boolean" + }, + { + "key": "weather-channel-index", + "label": "Weather channel index", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "__sec_4", + "label": "Display", + "type": "section", + "group": "Display" + }, + { + "key": "display-mode", + "label": "Display Mode", + "type": "select", + "options": [ + "Channel", + "Frequency" + ] + }, + { + "key": "display-brightness", + "label": "Display Brightness (0-4 = menu steps 1-5)", + "type": "integer", + "min": 0, + "max": 4 + }, + { + "key": "backlight-duration-tx", + "label": "Backlight duration TX", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "channel-number-mode", + "label": "Channel number mode", + "type": "select", + "options": [ + "Total", + "Within Zone" + ] + }, + { + "key": "call-display-mode", + "label": "Call display mode", + "type": "select", + "options": [ + "Off", + "Call", + "Name" + ] + }, + { + "key": "last-caller-display-mode", + "label": "Last caller display mode", + "type": "select", + "options": [ + "Off", + "DMR ID", + "Callsign", + "Both" + ] + }, + { + "key": "keep-last-caller", + "label": "Keep last caller", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "enable-show-current-contact", + "label": "Enable show current contact", + "type": "boolean" + }, + { + "key": "show-clock", + "label": "Show Clock", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "show-volume-bar", + "label": "Show volume bar", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "show-menu-separator", + "label": "Show menu separator", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "show-color-code", + "label": "Show color code", + "type": "boolean" + }, + { + "key": "show-time-slot", + "label": "Show Time Slot", + "type": "boolean" + }, + { + "key": "show-channel-type", + "label": "Show channel type", + "type": "boolean" + }, + { + "key": "standby-text-color", + "label": "Standby text color", + "type": "select", + "options": [ + "White", + "Black", + "Orange", + "Red", + "Yellow", + "Green", + "Turquoise", + "Blue" + ] + }, + { + "key": "standby-background-image", + "label": "Standby background image", + "type": "select", + "options": [ + "Default", + "Custom 1", + "Custom 2" + ] + }, + { + "key": "call-sign-color", + "label": "Call sign color", + "type": "select", + "options": [ + "Orange", + "Red", + "Yellow", + "Green", + "Turquoise", + "Blue", + "White" + ] + }, + { + "key": "channel-a-name-color", + "label": "Channel A name color", + "type": "select", + "options": [ + "Orange", + "Red", + "Yellow", + "Green", + "Turquoise", + "Blue", + "White" + ] + }, + { + "key": "channel-b-name-color", + "label": "Channel B name color", + "type": "select", + "options": [ + "Orange", + "Red", + "Yellow", + "Green", + "Turquoise", + "Blue", + "White" + ] + }, + { + "key": "zone-a-name-color", + "label": "Zone A name color", + "type": "select", + "options": [ + "Orange", + "Red", + "Yellow", + "Green", + "Turquoise", + "Blue", + "White" + ] + }, + { + "key": "zone-b-name-color", + "label": "Zone B name color", + "type": "select", + "options": [ + "Orange", + "Red", + "Yellow", + "Green", + "Turquoise", + "Blue", + "White" + ] + }, + { + "key": "__sec_5", + "label": "Sounds", + "type": "section", + "group": "Sounds" + }, + { + "key": "key-tone", + "label": "Key Tone", + "type": "select", + "options": [ + "Off", + "Level 1", + "Level 2", + "Level 3", + "Level 4", + "Level 5", + "Level 6", + "Level 7", + "Level 8", + "With RX Tone A", + "With RX Tone B" + ] + }, + { + "key": "max-speaker-volume", + "label": "Max speaker volume", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "speakers", + "label": "Speakers", + "type": "select", + "options": [ + "Microphone", + "Radio", + "Both" + ] + }, + { + "key": "talk-permit-tone", + "label": "Talk permit tone", + "type": "select", + "options": [ + "None", + "DMR", + "FM", + "Both" + ] + }, + { + "key": "dmr-channel-free-tone", + "label": "DMR channel free tone", + "type": "select", + "options": [ + "Off", + "Type 1", + "Type 2", + "Type 3" + ] + }, + { + "key": "enable-fm-channel-free-tone", + "label": "Enable FM channel free tone", + "type": "boolean" + }, + { + "key": "enable-dmr-call-reset-tone", + "label": "Enable DMR call reset tone", + "type": "boolean" + }, + { + "key": "enable-call-end-notification", + "label": "Enable call-end notification.", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "enable-call-alert", + "label": "Enable Call Alert", + "type": "boolean" + }, + { + "key": "enable-sms-alert", + "label": "Enable SMS Alert", + "type": "boolean" + }, + { + "key": "enable-enhanced-audio", + "label": "Enable enhanced audio", + "type": "boolean" + }, + { + "key": "enable-plug-in-recording-tone", + "label": "Enable plug-in recording tone", + "type": "boolean" + }, + { + "key": "enable-tot-reminder", + "label": "Enable TOT reminder", + "type": "boolean" + }, + { + "key": "__sec_6", + "label": "Transmit & Receive", + "type": "section", + "group": "Transmit & Receive" + }, + { + "key": "tot", + "label": "Transmit Timeout", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "squelch-level-vfo-a", + "label": "Squelch Level VFO A", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "squelch-level-vfo-b", + "label": "Squelch Level VFO B", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "fm-mic-gain", + "label": "FM mic gain", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "dmr-mic-gain", + "label": "DMR mic gain", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "microphone-type", + "label": "Microphone Type", + "type": "select", + "options": [ + "AnyTone", + "Generic" + ] + }, + { + "key": "microphone-speaker-source", + "label": "Microphone Speaker Source", + "type": "select", + "options": [ + "Main Channel", + "Sub Channel" + ] + }, + { + "key": "tbst", + "label": "TBST", + "type": "select", + "options": [ + "1000 Hz", + "1450 Hz", + "1750 Hz", + "2100 Hz" + ] + }, + { + "key": "ste-type", + "label": "STE type", + "type": "select", + "options": [ + "Off", + "Silent", + "120 deg", + "180 deg", + "240 deg" + ] + }, + { + "key": "ste-frequency", + "label": "STE Frequency", + "type": "select", + "options": [ + "Off", + "55.2 Hz", + "259.2 Hz" + ] + }, + { + "key": "ste-duration", + "label": "STE duration", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "fm-call-hold-time", + "label": "FM Call hold time", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "enable-recording", + "label": "Enable Recording", + "type": "boolean" + }, + { + "key": "__sec_7", + "label": "VFO & Memory", + "type": "section", + "group": "Memory & VFO" + }, + { + "key": "main-vfo", + "label": "Main VFO", + "type": "select", + "options": [ + "A", + "B" + ] + }, + { + "key": "current-zone-a", + "label": "Current Zone A (0 = first zone)", + "type": "integer", + "min": 0, + "max": 249 + }, + { + "key": "current-zone-b", + "label": "Current Zone B (0 = first zone)", + "type": "integer", + "min": 0, + "max": 249 + }, + { + "key": "work-mode-a", + "label": "Work Mode A", + "type": "select", + "options": [ + "Channel", + "VFO" + ] + }, + { + "key": "work-mode-b", + "label": "Work Mode B", + "type": "select", + "options": [ + "Channel", + "VFO" + ] + }, + { + "key": "enable-subchannel", + "label": "Enable Subchannel", + "type": "boolean" + }, + { + "key": "frequency-step-size", + "label": "Frequency step size", + "type": "select", + "options": [ + "2.5 kHz", + "5 kHz", + "6.25 kHz", + "8.33 kHz", + "10 kHz", + "12.5 kHz", + "20 kHz", + "25 kHz", + "30 kHz", + "50 kHz" + ] + }, + { + "key": "priority-zone-index-a", + "label": "Priority zone index A", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "priority-zone-index-b", + "label": "Priority zone index B", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "vfo-scan-type", + "label": "VFO Scan Type", + "type": "select", + "options": [ + "Fixed Time", + "Carrier", + "Stop" + ] + }, + { + "key": "vfo-scan-vhf-min-frequency", + "label": "VFO Scan VHF min Frequency", + "type": "text", + "max_length": 10, + "placeholder": "144.390" + }, + { + "key": "vfo-scan-vhf-max-frequency", + "label": "VFO Scan VHF max Frequency", + "type": "text", + "max_length": 10, + "placeholder": "144.390" + }, + { + "key": "vfo-scan-uhf-min-frequency", + "label": "VFO Scan UHF min Frequency", + "type": "text", + "max_length": 10, + "placeholder": "144.390" + }, + { + "key": "vfo-scan-uhf-max-frequency", + "label": "VFO Scan UHF max Frequency", + "type": "text", + "max_length": 10, + "placeholder": "144.390" + }, + { + "key": "__sec_8", + "label": "Auto Repeater", + "type": "section", + "group": "Memory & VFO" + }, + { + "key": "auto-repeater-a-direction", + "label": "Auto-repeater A direction", + "type": "select", + "options": [ + "Off", + "Positive", + "Negative" + ] + }, + { + "key": "auto-repeater-b-direction", + "label": "Auto-repeater B direction", + "type": "select", + "options": [ + "Off", + "Positive", + "Negative" + ] + }, + { + "key": "vhf-auto-repeater-offset-frequency-index", + "label": "VHF Auto-repeater offset frequency index.", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "uhf-auto-repeater-offset-frequency-1-index", + "label": "UHF Auto-repeater offset frequency 1 index", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "vhf-auto-repeater-min-frequency", + "label": "VHF auto-repeater min frequency", + "type": "text", + "max_length": 10, + "placeholder": "144.390" + }, + { + "key": "vhf-auto-repeater-max-frequency", + "label": "VHF auto-repeater max frequency", + "type": "text", + "max_length": 10, + "placeholder": "144.390" + }, + { + "key": "uhf-auto-repeater-min-frequency", + "label": "UHF auto-repeater min frequency", + "type": "text", + "max_length": 10, + "placeholder": "144.390" + }, + { + "key": "uhf-auto-repeater-max-frequency", + "label": "UHF auto-repeater max frequency", + "type": "text", + "max_length": 10, + "placeholder": "144.390" + }, + { + "key": "__sec_9", + "label": "Cross-Band Repeater", + "type": "section", + "group": "Transmit & Receive" + }, + { + "key": "enable-repeater", + "label": "Enable Repeater", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "repeater-mode-colorcode", + "label": "Repeater Mode Colorcode", + "type": "select", + "options": [ + "None", + "Colorcode VFO A", + "Colorcode VFO B" + ] + }, + { + "key": "repeater-vfo-a-timeslot", + "label": "Repeater VFO A Timeslot", + "type": "select", + "options": [ + "Any", + "TS 1 -> 2", + "TS 2 -> 1" + ] + }, + { + "key": "repeater-vfo-b-timeslot", + "label": "Repeater VFO B Timeslot", + "type": "select", + "options": [ + "Any", + "TS 1 -> 2", + "TS 2 -> 1" + ] + }, + { + "key": "__sec_10", + "label": "FM Broadcast", + "type": "section", + "group": "General" + }, + { + "key": "enable-fm-broadcast", + "label": "Enable FM Broadcast", + "type": "boolean" + }, + { + "key": "enable-fm-broadcast-monitor", + "label": "FM Broadcast Monitor", + "type": "boolean" + }, + { + "key": "fm-broadcast-mode", + "label": "FM broadcast mode", + "type": "select", + "options": [ + "Channel", + "VFO" + ] + }, + { + "key": "fm-broadcast-channel-index", + "label": "FM broadcast channel index", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "__sec_11", + "label": "Programmable Keys", + "type": "section", + "group": "Keys" + }, + { + "key": "p1-short-press", + "label": "P1 Short Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "p1-long-press", + "label": "P1 Long Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "p2-short-press", + "label": "P2 Short Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "p2-long-press", + "label": "P2 Long Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "p3-short-press", + "label": "P3 Short Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "p3-long-press", + "label": "P3 Long Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "p4-short-press", + "label": "P4 Short Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "p4-long-press", + "label": "P4 Long Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "p5-short-press", + "label": "P5 Short Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "p5-long-press", + "label": "P5 Long Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "p6-short-press", + "label": "P6 Short Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "p6-long-press", + "label": "P6 Long Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "pa-short-press", + "label": "PA Short Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "pa-long-press", + "label": "PA Long Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "pb-short-press", + "label": "PB Short Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "pb-long-press", + "label": "PB Long Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "pc-short-press", + "label": "PC Short Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "pc-long-press", + "label": "PC Long Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "pd-short-press", + "label": "PD Short Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "pd-long-press", + "label": "PD Long Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "long-press-duration", + "label": "Long press duration", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "up-down-key-function", + "label": "Up/Down Key Function", + "type": "select", + "options": [ + "Channel/Frequency", + "Volume" + ] + }, + { + "key": "channel-knob-short-press-function", + "label": "Channel Knob Short-Press Function", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "channel-knob-long-press-function", + "label": "Channel Knob Long-Press Function", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "__sec_12", + "label": "DMR", + "type": "section", + "group": "Digital" + }, + { + "key": "group-call-hang-time", + "label": "Group call hang time", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "private-call-hang-time", + "label": "Private call hang time", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "manual-dial-group-call-hang-time", + "label": "Manual dial group call hang time", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "manual-dial-private-call-hang-time", + "label": "Manual dial private call hang time", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "preamble-duration", + "label": "Preamble duration", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "header-repetitions", + "label": "Header repetitions", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "enable-filter-own-id", + "label": "Enable filter own Id", + "type": "boolean" + }, + { + "key": "sms-standard", + "label": "SMS standard", + "type": "select", + "options": [ + "Motorola", + "Hytera", + "ETSI DMR" + ] + }, + { + "key": "send-selected-analog-contact", + "label": "Send selected analog contact", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "encryption-type", + "label": "Encryption Type", + "type": "select", + "options": [ + "Common/DMR", + "AES/ARC4" + ] + }, + { + "key": "talker-alias-priority", + "label": "Talker alias priority", + "type": "select", + "options": [ + "Off", + "Contact", + "Over the air" + ] + }, + { + "key": "talker-alias-encoding", + "label": "Talker alias encoding", + "type": "select", + "options": [ + "ISO 7", + "ISO 8", + "Unicode" + ] + }, + { + "key": "enable-dmr-remote-stun-kill", + "label": "Enable DMR remote stun & kill", + "type": "boolean" + }, + { + "key": "enable-remote-monitor", + "label": "Enable remote monitor", + "type": "boolean" + }, + { + "key": "__sec_13", + "label": "DMR Monitor", + "type": "section", + "group": "Digital" + }, + { + "key": "dmr-monitor-mode", + "label": "DMR monitor mode", + "type": "select", + "options": [ + "Off", + "Single slot", + "Double slot" + ] + }, + { + "key": "dmr-monitor-match-cc", + "label": "DMR monitor match CC", + "type": "select", + "options": [ + "Any", + "Same" + ] + }, + { + "key": "dmr-monitor-match-id", + "label": "DMR monitor match id", + "type": "select", + "options": [ + "Any", + "Same" + ] + }, + { + "key": "enable-dmr-monitor-slot-hold", + "label": "Enable DMR monitor slot hold", + "type": "boolean" + }, + { + "key": "__sec_14", + "label": "Roaming", + "type": "section", + "group": "Digital" + }, + { + "key": "enable-auto-roaming", + "label": "Enable auto roaming", + "type": "boolean" + }, + { + "key": "auto-roaming-period", + "label": "Auto-roaming period", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "auto-roaming-start-condition", + "label": "Auto-roaming start condition", + "type": "select", + "options": [ + "Periodic", + "Out-of-range" + ] + }, + { + "key": "roaming-delay", + "label": "Roaming delay", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "roaming-zone-index", + "label": "Roaming zone index", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "enable-repeater-check", + "label": "Enable repeater check", + "type": "boolean" + }, + { + "key": "repeater-check-interval", + "label": "Repeater check interval", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "repeater-check-retry-count", + "label": "Repeater check retry count", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "repeater-check-oor-notification", + "label": "Repeater check OOR notification", + "type": "select", + "options": [ + "Off", + "Tone", + "Voice" + ] + }, + { + "key": "repeater-check-oor-notification-count", + "label": "Repeater check OOR notification count", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "enable-gps-roaming", + "label": "Enable GPS roaming.", + "type": "boolean" + }, + { + "key": "__sec_15", + "label": "GPS", + "type": "section", + "group": "GPS" + }, + { + "key": "enable-gps", + "label": "Enable GPS", + "type": "boolean" + }, + { + "key": "gps-modes", + "label": "GPS Modes", + "type": "select", + "options": [ + "GPS", + "BDS", + "Both" + ] + }, + { + "key": "ranging-interval", + "label": "Ranging interval", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "enable-rx-dmr-aprs-positions", + "label": "Enable RX DMR APRS Positions", + "type": "boolean" + }, + { + "key": "sent-dmr-aprs-message", + "label": "Sent (DMR) APRS message.", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "__sec_16", + "label": "Bluetooth", + "type": "section", + "group": "Bluetooth" + }, + { + "key": "enable-bluetooth", + "label": "Enable bluetooth", + "type": "boolean" + }, + { + "key": "bluetooth-and-internal-mic", + "label": "Bluetooth and internal mic", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "bluetooth-and-internal-speaker", + "label": "Bluetooth and internal speaker.", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "bluetooth-mic-gain", + "label": "Bluetooth mic gain", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "bluetooth-speaker-gain", + "label": "Bluetooth speaker gain", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "enable-bluetooth-ptt-latch", + "label": "Enable bluetooth PTT latch", + "type": "boolean" + }, + { + "key": "bluetooth-ptt-sleep-delay", + "label": "Bluetooth PTT sleep delay", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "bluetooth-rx-delay", + "label": "Bluetooth RX delay", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "__sec_17", + "label": "Bluetooth Handset", + "type": "section", + "group": "Bluetooth" + }, + { + "key": "bt-handset-key-1-short-press", + "label": "BT Handset Key 1 Short Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "bt-handset-key-2-short-press", + "label": "BT Handset Key 2 Short Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "bt-handset-key-3-short-press", + "label": "BT Handset Key 3 Short Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "bt-handset-key-1-long-press", + "label": "BT Handset Key 1 Long Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "bt-handset-key-2-long-press", + "label": "BT Handset Key 2 Long Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "bt-handset-key-3-long-press", + "label": "BT Handset Key 3 Long Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "bt-handset-key-1-very-long-press", + "label": "BT Handset Key 1 Very Long Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "bt-handset-key-2-very-long-press", + "label": "BT Handset Key 2 Very Long Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "bt-handset-key-3-very-long-press", + "label": "BT Handset Key 3 Very Long Press", + "type": "select", + "options": [ + "A Mute", + "Alarm", + "APRS Send", + "APRS Settings", + "APRS Type Toggle", + "B Mute", + "Bluetooth", + "Call", + "Channel Name", + "Channel Ranging", + "Cross Band Repeater", + "CTCSS/DTS Scan", + "Dial", + "DMR Encryption", + "DMR Monitor", + "Exit", + "FM APRS Info", + "FM Broadcast", + "GPS", + "GPS Information", + "GPS Roaming", + "Hot Key 1", + "Hot Key 2", + "Hot Key 3", + "Hot Key 4", + "Hot Key 5", + "Hot Key 6", + "Last Call Reply", + "Main Channel Toggle", + "Menu", + "Mic Sound Quality", + "Monitor", + "Noise Reduction RX", + "Noise Reduction TX", + "Nuisance Delete", + "Off", + "Power", + "Priority Zone", + "Ranging", + "Record", + "Record Switch", + "Reverse", + "Roaming", + "Roaming Settings", + "Scan", + "Select Zone", + "Set Max Volume", + "SMS", + "Speaker Mode", + "Sub Channel Toggle", + "Talk Around", + "TBST", + "Timeslot Toggle", + "Toggle Channel Type", + "VFO Scan", + "VFO/Channel", + "Voltage", + "Work Alone", + "Zone decrement", + "Zone increment" + ] + }, + { + "key": "bt-handset-mic-gain", + "label": "BT Handset Mic Gain", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "bt-handset-backlight-duration", + "label": "BT Handset Backlight Duration", + "type": "select", + "options": [ + "Infinity", + "5s", + "10s", + "15s", + "20s", + "25s", + "1min", + "2min", + "3min", + "4min", + "5min", + "15min", + "35min", + "45min", + "1h" + ] + }, + { + "key": "bt-handset-rx-noise-reduction-level", + "label": "BT Handset RX Noise Reduction Level", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "bt-handset-tx-noise-reduction-level", + "label": "BT Handset TX Noise Reduction Level", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "bt-handset-vox-level", + "label": "BT Handset VOX Level", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "bt-handset-vox-delay", + "label": "BT Handset VOX delay", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "bt-handset-volume-vfo-a", + "label": "BT Handset Volume VFO A", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "bt-handset-volume-vfo-b", + "label": "BT Handset Volume VFO B", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "enable-bt-handset-device-shutdown", + "label": "Enable BT Handset Device Shutdown", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "__sec_18", + "label": "APRS", + "type": "section", + "group": "APRS" + }, + { + "key": "source-call", + "label": "Source call", + "type": "text", + "max_length": 6 + }, + { + "key": "source-ssid", + "label": "Source SSID", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "destination-call", + "label": "Destination call", + "type": "text", + "max_length": 6 + }, + { + "key": "destination-ssid", + "label": "Destination SSID", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "aprs-path", + "label": "APRS Path", + "type": "text", + "max_length": 21 + }, + { + "key": "aprs-path-extended", + "label": "APRS Path (continued)", + "type": "text", + "max_length": 35 + }, + { + "key": "aprs-symbol-table", + "label": "APRS symbol table", + "type": "text", + "max_length": 1 + }, + { + "key": "aprs-symbol", + "label": "APRS symbol", + "type": "text", + "max_length": 1 + }, + { + "key": "manual-tx-interval", + "label": "Manual TX interval", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "automatic-tx-interval", + "label": "Automatic TX interval", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "aprs-monitor-enable", + "label": "APRS monitor enable", + "type": "select", + "options": [ + "Off", + "On" + ] + }, + { + "key": "aprs-info-display-duration", + "label": "APRS Info Display Duration", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "roaming-support", + "label": "Roaming support", + "type": "select", + "options": [ + "Disabled", + "Enabled" + ] + }, + { + "key": "repeater-activation-delay", + "label": "Repeater activation delay", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "prewave-delay", + "label": "Prewave delay", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "__sec_19", + "label": "APRS Position", + "type": "section", + "group": "APRS" + }, + { + "key": "enable-fixed-location", + "label": "Enable fixed location", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "fixed-latitude-degree", + "label": "Fixed Latitude degree", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "fixed-latitude-minute", + "label": "Fixed Latitude minute", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "fixed-latitude-seconds", + "label": "Fixed Latitude seconds", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "fixed-latitude-hemisphere", + "label": "Fixed Latitude hemisphere", + "type": "select", + "options": [ + "North", + "South" + ] + }, + { + "key": "fixed-longitude-degree", + "label": "Fixed Longitude degree", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "fixed-longitude-minute", + "label": "Fixed Longitude minute", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "fixed-longitude-seconds", + "label": "Fixed Longitude seconds", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "fixed-longitude-hemisphere", + "label": "Fixed Longitude hemisphere", + "type": "select", + "options": [ + "East", + "West" + ] + }, + { + "key": "fixed-altitude", + "label": "Fixed altitude", + "type": "integer", + "min": 0, + "max": 65535 + }, + { + "key": "__sec_20", + "label": "FM APRS", + "type": "section", + "group": "APRS" + }, + { + "key": "fm-aprs-frequency-1", + "label": "FM APRS Frequency 1", + "type": "text", + "max_length": 10, + "placeholder": "144.390" + }, + { + "key": "fm-aprs-frequency-2", + "label": "FM APRS Frequency 2", + "type": "text", + "max_length": 10, + "placeholder": "144.390" + }, + { + "key": "fm-aprs-frequency-3", + "label": "FM APRS Frequency 3", + "type": "text", + "max_length": 10, + "placeholder": "144.390" + }, + { + "key": "fm-aprs-frequency-4", + "label": "FM APRS Frequency 4", + "type": "text", + "max_length": 10, + "placeholder": "144.390" + }, + { + "key": "fm-aprs-frequency-5", + "label": "FM APRS Frequency 5", + "type": "text", + "max_length": 10, + "placeholder": "144.390" + }, + { + "key": "fm-aprs-frequency-6", + "label": "FM APRS Frequency 6", + "type": "text", + "max_length": 10, + "placeholder": "144.390" + }, + { + "key": "fm-aprs-frequency-7", + "label": "FM APRS Frequency 7", + "type": "text", + "max_length": 10, + "placeholder": "144.390" + }, + { + "key": "fm-aprs-frequency-8", + "label": "FM APRS Frequency 8", + "type": "text", + "max_length": 10, + "placeholder": "144.390" + }, + { + "key": "fm-aprs-transmit-power", + "label": "FM APRS transmit power", + "type": "select", + "options": [ + "Low", + "Mid", + "High", + "Max" + ] + }, + { + "key": "fm-aprs-bandwidth", + "label": "FM APRS bandwidth", + "type": "select", + "options": [ + "Narrow", + "Wide" + ] + }, + { + "key": "fm-aprs-tx-delay", + "label": "FM APRS TX delay", + "type": "integer", + "min": 0, + "max": 255 + }, + { + "key": "fm-aprs-sub-tone-type", + "label": "FM APRS sub tone type", + "type": "select", + "options": [ + "None", + "CTCSS", + "DCS" + ] + }, + { + "key": "fm-aprs-tx-ctcss-tone", + "label": "FM APRS TX CTCSS tone", + "type": "select", + "options": [ + "62.5 Hz", + "67.0 Hz", + "69.3 Hz", + "71.9 Hz", + "74.4 Hz", + "77.0 Hz", + "79.7 Hz", + "82.5 Hz", + "85.4 Hz", + "88.5 Hz", + "91.5 Hz", + "94.8 Hz", + "97.4 Hz", + "100.0 Hz", + "103.5 Hz", + "107.2 Hz", + "110.9 Hz", + "114.8 Hz", + "118.8 Hz", + "123.0 Hz", + "127.3 Hz", + "131.8 Hz", + "136.5 Hz", + "141.3 Hz", + "146.2 Hz", + "151.4 Hz", + "156.7 Hz", + "159.8 Hz", + "162.2 Hz", + "165.5 Hz", + "167.9 Hz", + "171.3 Hz", + "173.8 Hz", + "177.3 Hz", + "179.9 Hz", + "183.5 Hz", + "186.2 Hz", + "189.9 Hz", + "192.8 Hz", + "196.6 Hz", + "199.5 Hz", + "203.5 Hz", + "206.5 Hz", + "210.7 Hz", + "218.1 Hz", + "225.7 Hz", + "229.1 Hz", + "233.6 Hz", + "241.8 Hz", + "250.3 Hz", + "254.1 Hz" + ] + }, + { + "key": "fm-aprs-dcs-code", + "label": "FM APRS DCS code", + "type": "integer", + "min": 0, + "max": 65535 + }, + { + "key": "enable-ax-25-crc-check", + "label": "Enable AX.25 CRC check", + "type": "select", + "options": [ + "On", + "Off" + ] + }, + { + "key": "fm-aprs-message", + "label": "FM APRS Message", + "type": "text", + "max_length": 60 + }, + { + "key": "__sec_21", + "label": "DMR APRS", + "type": "section", + "group": "APRS" + }, + { + "key": "dmr-aprs-destination-id-1", + "label": "DMR APRS Report Channel 1 Destination ID", + "type": "integer", + "min": 0, + "max": 16777215 + }, + { + "key": "dmr-aprs-call-type-1", + "label": "DMR APRS Report Channel 1 Call Type", + "type": "select", + "options": [ + "Private Call", + "Group Call", + "All Call" + ] + }, + { + "key": "dmr-aprs-time-slot-1", + "label": "DMR APRS Report Channel 1 Time Slot", + "type": "select", + "options": [ + "Channel slot", + "Time slot 1", + "Time slot 2" + ] + }, + { + "key": "dmr-aprs-destination-id-2", + "label": "DMR APRS Report Channel 2 Destination ID", + "type": "integer", + "min": 0, + "max": 16777215 + }, + { + "key": "dmr-aprs-call-type-2", + "label": "DMR APRS Report Channel 2 Call Type", + "type": "select", + "options": [ + "Private Call", + "Group Call", + "All Call" + ] + }, + { + "key": "dmr-aprs-time-slot-2", + "label": "DMR APRS Report Channel 2 Time Slot", + "type": "select", + "options": [ + "Channel slot", + "Time slot 1", + "Time slot 2" + ] + }, + { + "key": "dmr-aprs-destination-id-3", + "label": "DMR APRS Report Channel 3 Destination ID", + "type": "integer", + "min": 0, + "max": 16777215 + }, + { + "key": "dmr-aprs-call-type-3", + "label": "DMR APRS Report Channel 3 Call Type", + "type": "select", + "options": [ + "Private Call", + "Group Call", + "All Call" + ] + }, + { + "key": "dmr-aprs-time-slot-3", + "label": "DMR APRS Report Channel 3 Time Slot", + "type": "select", + "options": [ + "Channel slot", + "Time slot 1", + "Time slot 2" + ] + }, + { + "key": "dmr-aprs-destination-id-4", + "label": "DMR APRS Report Channel 4 Destination ID", + "type": "integer", + "min": 0, + "max": 16777215 + }, + { + "key": "dmr-aprs-call-type-4", + "label": "DMR APRS Report Channel 4 Call Type", + "type": "select", + "options": [ + "Private Call", + "Group Call", + "All Call" + ] + }, + { + "key": "dmr-aprs-time-slot-4", + "label": "DMR APRS Report Channel 4 Time Slot", + "type": "select", + "options": [ + "Channel slot", + "Time slot 1", + "Time slot 2" + ] + }, + { + "key": "dmr-aprs-destination-id-5", + "label": "DMR APRS Report Channel 5 Destination ID", + "type": "integer", + "min": 0, + "max": 16777215 + }, + { + "key": "dmr-aprs-call-type-5", + "label": "DMR APRS Report Channel 5 Call Type", + "type": "select", + "options": [ + "Private Call", + "Group Call", + "All Call" + ] + }, + { + "key": "dmr-aprs-time-slot-5", + "label": "DMR APRS Report Channel 5 Time Slot", + "type": "select", + "options": [ + "Channel slot", + "Time slot 1", + "Time slot 2" + ] + }, + { + "key": "dmr-aprs-destination-id-6", + "label": "DMR APRS Report Channel 6 Destination ID", + "type": "integer", + "min": 0, + "max": 16777215 + }, + { + "key": "dmr-aprs-call-type-6", + "label": "DMR APRS Report Channel 6 Call Type", + "type": "select", + "options": [ + "Private Call", + "Group Call", + "All Call" + ] + }, + { + "key": "dmr-aprs-time-slot-6", + "label": "DMR APRS Report Channel 6 Time Slot", + "type": "select", + "options": [ + "Channel slot", + "Time slot 1", + "Time slot 2" + ] + }, + { + "key": "dmr-aprs-destination-id-7", + "label": "DMR APRS Report Channel 7 Destination ID", + "type": "integer", + "min": 0, + "max": 16777215 + }, + { + "key": "dmr-aprs-call-type-7", + "label": "DMR APRS Report Channel 7 Call Type", + "type": "select", + "options": [ + "Private Call", + "Group Call", + "All Call" + ] + }, + { + "key": "dmr-aprs-time-slot-7", + "label": "DMR APRS Report Channel 7 Time Slot", + "type": "select", + "options": [ + "Channel slot", + "Time slot 1", + "Time slot 2" + ] + }, + { + "key": "dmr-aprs-destination-id-8", + "label": "DMR APRS Report Channel 8 Destination ID", + "type": "integer", + "min": 0, + "max": 16777215 + }, + { + "key": "dmr-aprs-call-type-8", + "label": "DMR APRS Report Channel 8 Call Type", + "type": "select", + "options": [ + "Private Call", + "Group Call", + "All Call" + ] + }, + { + "key": "dmr-aprs-time-slot-8", + "label": "DMR APRS Report Channel 8 Time Slot", + "type": "select", + "options": [ + "Channel slot", + "Time slot 1", + "Time slot 2" + ] + }, + { + "key": "dmr-aprs-message", + "label": "DMR APRS message", + "type": "text", + "max_length": 32 + }, + { + "key": "__sec_22", + "label": "APRS Receive Filter", + "type": "section", + "group": "APRS" + }, + { + "key": "aprs-filter-position", + "label": "Receive Positions", + "type": "boolean" + }, + { + "key": "aprs-filter-mic-e", + "label": "Receive Mic-E", + "type": "boolean" + }, + { + "key": "aprs-filter-objects", + "label": "Receive Objects", + "type": "boolean" + }, + { + "key": "aprs-filter-items", + "label": "Receive Items", + "type": "boolean" + }, + { + "key": "aprs-filter-messages", + "label": "Receive Messages", + "type": "boolean" + }, + { + "key": "aprs-filter-weather", + "label": "Receive Weather", + "type": "boolean" + }, + { + "key": "aprs-filter-nmea", + "label": "Receive NMEA", + "type": "boolean" + }, + { + "key": "aprs-filter-status", + "label": "Receive Status Reports", + "type": "boolean" + }, + { + "key": "aprs-filter-other", + "label": "Receive Other", + "type": "boolean" + } +] diff --git a/src-tauri/src/anytone_settings_schema.json b/src-tauri/src/anytone_settings_schema.json index 0c4804d..0cb8b38 100644 --- a/src-tauri/src/anytone_settings_schema.json +++ b/src-tauri/src/anytone_settings_schema.json @@ -2238,7 +2238,7 @@ "key": "section-bluetooth", "label": "Bluetooth", "type": "section", - "group": "Connections" + "group": "Bluetooth" }, { "key": "enable-blue-tooth", diff --git a/src-tauri/src/bt9000_settings_schema.json b/src-tauri/src/bt9000_settings_schema.json index 72e9b15..509327d 100644 --- a/src-tauri/src/bt9000_settings_schema.json +++ b/src-tauri/src/bt9000_settings_schema.json @@ -312,7 +312,7 @@ "key": "section-bluetooth", "label": "Bluetooth", "type": "section", - "group": "Connections" + "group": "Bluetooth" }, { "key": "bluetooth", diff --git a/src-tauri/src/commands/anytone_program.rs b/src-tauri/src/commands/anytone_program.rs index 3619577..866cc66 100644 --- a/src-tauri/src/commands/anytone_program.rs +++ b/src-tauri/src/commands/anytone_program.rs @@ -28,8 +28,8 @@ use crate::error::MapErrString; use crate::models::RadioModel; use super::anytone::{ - check_restore_blocks, diff_dumps, end_session, enter_program_and_ident, open_port, parse_dump, - read_block, write_block, AnytoneDumpDiff, AnytonePatchWriteResult, + diff_dumps, end_session, enter_program_and_ident_as, open_port, parse_dump, read_block, + write_block, AnytoneDumpDiff, AnytonePatchWriteResult, IdentExpect, D890_IDENT, }; // The plan/session half moved into the driver in 3.6b; the DTOs it returns are // the generic driver-level ones, so the wire shape (and the frontend) is @@ -52,6 +52,82 @@ pub struct AnytoneVerifyResult { pub mismatches: Vec, } +// ============================================================ +// Family dispatch +// ============================================================ + +/// One AnyTone model the program dialog serves. The dialog's Verify, Restore +/// and "latest program" actions are shared across the family; what differs is +/// the ident a session must find, which windows a restore may write, and the +/// file-name prefixes that model's backups carry. +/// A model's restore guard: refuses a backup holding windows it never writes. +type RestoreCheck = fn(&[(u32, Vec)]) -> Result<(), String>; + +/// How a model writes one restored block. +type BlockWrite = fn(&mut dyn serialport::SerialPort, u32, &[u8]) -> Result<(), String>; + +struct AnytoneFamily { + ident: IdentExpect, + check_restore: RestoreCheck, + /// The D578 never sends a flash half's marker frame, even from a backup. + write: BlockWrite, + /// Backups this model writes (newest-first search in `latest_anytone_program`). + prefixes: &'static [&'static str], +} + +const D890_FAMILY: AnytoneFamily = AnytoneFamily { + ident: D890_IDENT, + check_restore: super::anytone::check_restore_blocks, + write: write_block, + prefixes: &["anytone-program-", "anytone-settings-write-"], +}; + +const D578_FAMILY: AnytoneFamily = AnytoneFamily { + ident: crate::radios::anytone_atd578uv::D578_IDENT, + check_restore: crate::radios::anytone_atd578uv::check_restore_blocks, + write: crate::radios::anytone_atd578uv::write_sendable, + prefixes: &[ + crate::radios::anytone_atd578uv::program::PROGRAM_PREFIX, + crate::radios::anytone_atd578uv::settings::SETTINGS_PREFIX, + ], +}; + +/// Every family member, for the cross-model file check. +const FAMILIES: [(&str, &AnytoneFamily); 2] = + [("anytone_atd890uv", &D890_FAMILY), ("anytone_atd578uv", &D578_FAMILY)]; + +fn family(driver_key: &str) -> Result<&'static AnytoneFamily, String> { + FAMILIES + .iter() + .find(|(k, _)| *k == driver_key) + .map(|(_, f)| *f) + .ok_or_else(|| format!("'{driver_key}' is not an AnyTone radio this dialog programs")) +} + +/// Refuse a backup or expected image written for a DIFFERENT AnyTone model. The +/// maps overlap — the D578's zone lists sit at the D890's first channel bank — +/// so a window check alone would let one radio's backup through onto the other. +/// A file carrying no family prefix is left to the window check, which keeps +/// the D890's older backups restorable exactly as before. +fn check_file_belongs(driver_key: &str, path: &str) -> Result<(), String> { + let name = std::path::Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + for (key, fam) in FAMILIES { + if key != driver_key && fam.prefixes.iter().any(|p| name.starts_with(p)) { + return Err(format!( + "{name} was written for a different radio ({key}) — refusing to use it here" + )); + } + } + let own = family(driver_key)?; + if driver_key != "anytone_atd890uv" && !own.prefixes.iter().any(|p| name.starts_with(p)) { + return Err(format!("{name} is not a backup this app wrote for this radio")); + } + Ok(()) +} + // ============================================================ // Tauri commands // ============================================================ @@ -92,9 +168,12 @@ pub async fn program_anytone_preview( /// actually holds after its commit+reboot. Read-only. #[tauri::command] pub async fn verify_anytone_program( + driver_key: String, port: String, expected_path: String, ) -> Result { + let fam = family(&driver_key)?; + check_file_belongs(&driver_key, &expected_path)?; let bytes = std::fs::read(&expected_path) .map_err(|e| format!("could not read {expected_path}: {e}"))?; let expected = parse_dump(&bytes).map_err(|e| format!("{expected_path}: {e}"))?; @@ -103,7 +182,7 @@ pub async fn verify_anytone_program( // session and releases on the way out, panic included. (#67) let _port_guard = crate::radios::port_lock::claim(&port)?; let mut p = open_port(&port)?; - let _ident = enter_program_and_ident(&mut *p)?; + let _ident = enter_program_and_ident_as(&mut *p, fam.ident)?; let mut actual = Vec::with_capacity(expected.len()); for (addr, data) in &expected { match read_block(&mut *p, *addr, data.len()) { @@ -142,31 +221,36 @@ pub struct AnytoneLatestProgram { #[tauri::command] pub async fn latest_anytone_program( app: AppHandle, + driver_key: String, ) -> Result, String> { let dir = app.path().app_data_dir().estr()?.join("radio-backups"); // Both the channel-set program and the settings push write a // `{prefix}{stamp}.expected.bin` / `{prefix}{stamp}.bin` pair; either is a - // valid Verify/Restore target. Track the newest by full base filename. - const PREFIXES: &[&str] = &["anytone-program-", "anytone-settings-write-"]; + // valid Verify/Restore target. Track the newest by full base filename — + // among THIS model's prefixes only, so a D578 dialog never offers a D890 + // backup or the reverse. + Ok(newest_program(&dir, family(&driver_key)?.prefixes)) +} + +/// The newest `{prefix}{stamp}.expected.bin` / `.bin` pair in `dir` among +/// `prefixes`, or `None`. Pure file listing; no radio. +fn newest_program(dir: &std::path::Path, prefixes: &[&str]) -> Option { let mut newest: Option<(String, String)> = None; // (stamp, base filename) - let entries = match std::fs::read_dir(&dir) { - Ok(e) => e, - Err(_) => return Ok(None), // dir may not exist yet - }; + let entries = std::fs::read_dir(dir).ok()?; // the dir may not exist yet for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); let Some(base) = name.strip_suffix(".expected.bin") else { continue; }; // The stamp (YYYYMMDD-HHMMSS) sorts lexicographically, so max = newest. - let Some(stamp) = PREFIXES.iter().find_map(|p| base.strip_prefix(p)) else { + let Some(stamp) = prefixes.iter().find_map(|p| base.strip_prefix(p)) else { continue; }; if newest.as_ref().is_none_or(|(cur, _)| stamp > cur.as_str()) { newest = Some((stamp.to_string(), base.to_string())); } } - Ok(newest.map(|(stamp, base)| { + newest.map(|(stamp, base)| { let expected = dir.join(format!("{base}.expected.bin")); let backup = dir.join(format!("{base}.bin")); AnytoneLatestProgram { @@ -174,7 +258,7 @@ pub async fn latest_anytone_program( expected_path: expected.to_string_lossy().to_string(), stamp, } - })) + }) } @@ -183,25 +267,28 @@ pub async fn latest_anytone_program( /// captured, then END once. Brick-capable; gated behind the dialog. #[tauri::command] pub async fn restore_anytone_backup( + driver_key: String, port: String, backup_path: String, ) -> Result { + let fam = family(&driver_key)?; + check_file_belongs(&driver_key, &backup_path)?; let bytes = std::fs::read(&backup_path) .map_err(|e| format!("could not read {backup_path}: {e}"))?; let blocks = parse_dump(&bytes).map_err(|e| format!("{backup_path}: {e}"))?; if blocks.is_empty() { return Err(format!("{backup_path} contains no data blocks")); } - check_restore_blocks(&blocks).map_err(|e| format!("{backup_path}: {e}"))?; + (fam.check_restore)(&blocks).map_err(|e| format!("{backup_path}: {e}"))?; tauri::async_runtime::spawn_blocking(move || { // One radio operation per port: this guard lives for the whole blocking // session and releases on the way out, panic included. (#67) let _port_guard = crate::radios::port_lock::claim(&port)?; let mut p = open_port(&port)?; - let _ident = enter_program_and_ident(&mut *p)?; + let _ident = enter_program_and_ident_as(&mut *p, fam.ident)?; let mut written = Vec::new(); for (addr, data) in &blocks { - write_block(&mut *p, *addr, data)?; + (fam.write)(&mut *p, *addr, data)?; written.push(format!("0x{addr:08X}")); } let _ = end_session(&mut *p); @@ -216,3 +303,65 @@ pub async fn restore_anytone_backup( .await .estr()? } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_dialog_model_finds_only_its_own_backups() { + let dir = std::env::temp_dir().join(format!("cpm-anytone-latest-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for name in [ + "anytone-program-20261001-090000.expected.bin", + "anytone-settings-write-20261002-090000.expected.bin", + "atd578uv-program-20260930-090000.expected.bin", + "atd578uv-settings-write-20260929-090000.expected.bin", + ] { + std::fs::write(dir.join(name), b"").unwrap(); + } + let d890 = newest_program(&dir, D890_FAMILY.prefixes).unwrap(); + assert!(d890.expected_path.ends_with("anytone-settings-write-20261002-090000.expected.bin")); + let d578 = newest_program(&dir, D578_FAMILY.prefixes).unwrap(); + assert!(d578.expected_path.ends_with("atd578uv-program-20260930-090000.expected.bin")); + assert!(d578.backup_path.ends_with("atd578uv-program-20260930-090000.bin")); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_backup_from_the_other_model_is_refused() { + assert!(check_file_belongs("anytone_atd890uv", "/x/anytone-program-20261001-090000.bin").is_ok()); + assert!(check_file_belongs("anytone_atd890uv", "/x/atd578uv-program-20261001-090000.bin").is_err()); + assert!(check_file_belongs("anytone_atd578uv", "/x/atd578uv-settings-write-1.bin").is_ok()); + assert!(check_file_belongs("anytone_atd578uv", "/x/anytone-program-20261001-090000.bin").is_err()); + assert!(check_file_belongs("anytone_atd578uv", "/x/something-else.bin").is_err()); + assert!(family("tyt_md380").is_err()); + } + + /// The gate against the real files: every AnyTone backup on this machine's + /// prod and dev app data stays usable by the D890 dialog, and none of them is + /// accepted by the D578's. Skips when the directories are absent (CI). + #[test] + fn real_backups_on_this_machine_keep_their_dialog() { + let Some(home) = std::env::var_os("HOME") else { return }; + let mut seen = 0; + for app in ["com.ww8l.codeplugmagic", "com.ww8l.codeplugmagic.dev"] { + let dir = std::path::Path::new(&home) + .join("Library/Application Support") + .join(app) + .join("radio-backups"); + let Ok(entries) = std::fs::read_dir(&dir) else { continue }; + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().to_string(); + if !D890_FAMILY.prefixes.iter().any(|p| name.starts_with(p)) { + continue; + } + seen += 1; + let path = e.path().to_string_lossy().to_string(); + assert!(check_file_belongs("anytone_atd890uv", &path).is_ok(), "{name}"); + assert!(check_file_belongs("anytone_atd578uv", &path).is_err(), "{name}"); + } + } + eprintln!("checked {seen} real D890 backups"); + } +} diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index c3aa988..bbf2e75 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -122,6 +122,7 @@ async fn codeplug_channels( /// expands into N of these (one channel per talkgroup x timeslot), because a /// real radio channel holds exactly one talkgroup. Everything else (and a DMR /// repeater with no assigned talkgroups) is a single passthrough row. +#[derive(Clone)] pub(crate) struct ExpandedChannel { pub(crate) channel: Channel, /// Talkgroup label appended to the channel name, e.g. "Colorado". Also used diff --git a/src-tauri/src/commands/program.rs b/src-tauri/src/commands/program.rs index aa796a4..493c3bf 100644 --- a/src-tauri/src/commands/program.rs +++ b/src-tauri/src/commands/program.rs @@ -923,7 +923,33 @@ pub async fn write_callsign_db( .and_then(|d| d.as_callsign_db_writer()) .ok_or_else(|| format!("{} has no on-board call-sign database", model.display_name))?; - let mut records = user_records(&state.pool, max_count, &priority_countries).await?; + let records = callsign_records(&state.pool, max_count, &priority_countries).await?; + + // Checked on the COMBINED set, not on the users alone: a codeplug with + // talkgroups but no downloaded user library is still worth writing — it is + // exactly what gives the TX screen its names. + if records.is_empty() { + return Err(crate::radios::driver::NO_DMR_USERS.to_string()); + } + + tauri::async_runtime::spawn_blocking(move || { + // One radio operation per port: this guard lives for the whole blocking + // session and releases on the way out, panic included. (#67) + let _port_guard = crate::radios::port_lock::claim(&port)?; + db_writer.write_callsign_db(&port, &records) + }) + .await + .estr()? +} + +/// Everything a call-sign database write carries: the library's users, capped +/// and prioritized, then the talkgroup library as group-call entries. +pub(crate) async fn callsign_records( + pool: &sqlx::SqlitePool, + max_count: Option, + priority_countries: &[String], +) -> Result, String> { + let mut records = user_records(pool, max_count, priority_countries).await?; // Append the talkgroup library as GROUP-call entries (issue #30). The // caller-ID DB is what the radio's TX screen resolves a talkgroup ID @@ -937,7 +963,7 @@ pub async fn write_callsign_db( // few dozen entries the user actually cares about naming. let talkgroups: Vec<(i64, String, String)> = sqlx::query_as("SELECT tg_number, name, call_type FROM talkgroups ORDER BY tg_number") - .fetch_all(&state.pool) + .fetch_all(pool) .await .estr()?; records.extend(talkgroups.into_iter().map(|(tg_number, name, call_type)| { @@ -959,22 +985,7 @@ pub async fn write_callsign_db( comment: None, } })); - - // Checked on the COMBINED set, not on the users alone: a codeplug with - // talkgroups but no downloaded user library is still worth writing — it is - // exactly what gives the TX screen its names. - if records.is_empty() { - return Err(crate::radios::driver::NO_DMR_USERS.to_string()); - } - - tauri::async_runtime::spawn_blocking(move || { - // One radio operation per port: this guard lives for the whole blocking - // session and releases on the way out, panic included. (#67) - let _port_guard = crate::radios::port_lock::claim(&port)?; - db_writer.write_callsign_db(&port, &records) - }) - .await - .estr()? + Ok(records) } /// The library's DMR users, capped and prioritized, as call-sign records. diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index ac080de..2f2debf 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -59,7 +59,7 @@ mod tests { // Models are reintroduced one at a time (migration 0005 trimmed the // original set): currently the Baofeng UV-5R, TIDRADIO TD-H3, AnyTone - // AT-D890UV, Yaesu FT5D, Icom ID-52, Kenwood TH-D75, Kenwood TH-D72 + // AT-D890UV and AT-D578UV, Yaesu FT5D, Icom ID-52, Kenwood TH-D75, Kenwood TH-D72 // the Binteradio BT-9000, Kenwood TM-D710, Icom ID-5100, TYT MD-380 and Yaesu // FTM-300DR. (0015 // removed the Vero VR-N76 placeholder.) None of the last five has a @@ -71,8 +71,8 @@ mod tests { .await .unwrap(); assert_eq!( - count.0, 12, - "expected the UV-5R, TD-H3, AT-D890UV, FT5D, ID-52, TH-D75, TH-D72, BT-9000, \ + count.0, 13, + "expected the UV-5R, TD-H3, AT-D890UV, AT-D578UV, FT5D, ID-52, TH-D75, TH-D72, BT-9000, \ TM-D710, ID-5100, MD-380 and FTM-300DR seeded models" ); @@ -85,8 +85,8 @@ mod tests { assert_eq!( names, vec![ - "AT-D890UV", "BT-9000", "FT5D", "FTM-300DR", "ID-5100", "ID-52", "MD-380", "TD-H3", - "TH-D72", "TH-D75", "TM-D710", "UV-5R" + "AT-D578UV", "AT-D890UV", "BT-9000", "FT5D", "FTM-300DR", "ID-5100", "ID-52", + "MD-380", "TD-H3", "TH-D72", "TH-D75", "TM-D710", "UV-5R" ] ); @@ -96,7 +96,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count2.0, 12, "seeding should be idempotent"); + assert_eq!(count2.0, 13, "seeding should be idempotent"); // A new database starts with NO talkgroups. The BrandMeister list used // to be compiled in and seeded here; it is downloaded on request now, diff --git a/src-tauri/src/ftm300_settings_schema.json b/src-tauri/src/ftm300_settings_schema.json index baf9940..8d0b0f8 100644 --- a/src-tauri/src/ftm300_settings_schema.json +++ b/src-tauri/src/ftm300_settings_schema.json @@ -390,7 +390,7 @@ "key": "section-bluetooth", "label": "Bluetooth", "type": "section", - "group": "Connections" + "group": "Bluetooth" }, { "key": "bluetooth-save", diff --git a/src-tauri/src/id5100_settings_schema.json b/src-tauri/src/id5100_settings_schema.json index 330784e..f84e1a4 100644 --- a/src-tauri/src/id5100_settings_schema.json +++ b/src-tauri/src/id5100_settings_schema.json @@ -344,7 +344,7 @@ "key": "section-bluetooth", "label": "Bluetooth", "type": "section", - "group": "Connections" + "group": "Bluetooth" }, { "key": "bluetooth-bluetooth", @@ -360,7 +360,7 @@ "key": "section-bluetooth-headset-set", "label": "Bluetooth - Headset Set", "type": "section", - "group": "Connections" + "group": "Bluetooth" }, { "key": "bluetooth-headset-set-af-output", @@ -436,7 +436,7 @@ "key": "section-bluetooth-icom-headset", "label": "Bluetooth - Icom Headset", "type": "section", - "group": "Connections" + "group": "Bluetooth" }, { "key": "bluetooth-icom-headset-power-save", @@ -573,7 +573,7 @@ "key": "section-bluetooth-data-device", "label": "Bluetooth - Data Device", "type": "section", - "group": "Connections" + "group": "Bluetooth" }, { "key": "bluetooth-data-device-serialport-function", diff --git a/src-tauri/src/id52_settings_schema.json b/src-tauri/src/id52_settings_schema.json index 0abe914..4b891a7 100644 --- a/src-tauri/src/id52_settings_schema.json +++ b/src-tauri/src/id52_settings_schema.json @@ -1305,7 +1305,7 @@ "key": "section-bluetooth-and-headset", "label": "Bluetooth and Headset", "type": "section", - "group": "Connections" + "group": "Bluetooth" }, { "key": "bluetooth-on", diff --git a/src-tauri/src/radios/anytone_atd578uv/callsign_db.rs b/src-tauri/src/radios/anytone_atd578uv/callsign_db.rs new file mode 100644 index 0000000..9e02065 --- /dev/null +++ b/src-tauri/src/radios/anytone_atd578uv/callsign_db.rs @@ -0,0 +1,348 @@ +//! AT-D578UV **call-sign database** (caller ID): up to 500,000 DMR IDs with +//! name, city, callsign, state and country, shown when a station is heard. +//! +//! # Layout (qdmr's `D878UV2CallsignDB`, which the D578 uses; checked byte for +//! byte against the anchor radio's own 281,304-entry database) +//! +//! - **Index** at [`INDEX_BASE`]: banks of [`INDEX_BANK_BYTES`] (16,000 eight-byte +//! entries) every [`BANK_STEP`]. Each entry is a key — the DMR ID in BCD as a +//! u32, shifted left one bit, bit 0 the group-call flag (u32 LE) — and the +//! record's byte offset in the record stream as if the banks had no gaps +//! (u32 LE). Sorted by key, so the radio can binary-search a heard ID. +//! - **Limits** at [`LIMITS_BASE`]: entry count (u32 LE), then the end of the +//! record stream as a REAL address (u32 LE) — gaps between banks included. +//! qdmr writes a gap-free end against the D868's base; the anchor radio holds +//! `0x07808232` for 281,304 entries, which is only the real-address form. +//! - **Records** at [`RECORD_BASE`]: banks of [`RECORD_BANK_BYTES`] every +//! [`BANK_STEP`], one continuous stream that runs on across banks (a record +//! may straddle two). Each record: call type (1: 0 private, 1 group), DMR ID +//! (BCD BE, 4), flags (1: friend bit 4, ring tone bits 1-0; written 0), then +//! name, city, callsign, state, country and comment, each cut to its byte +//! limit and NUL-terminated. +//! +//! # Text +//! +//! The fields are raw single-byte text. The anchor radio's own database (written +//! by another tool) holds whatever bytes its source had — Latin-1 `0xE9` for é +//! beside UTF-8 `c3 a8` for è, and 4,037 fields cut through a multi-byte +//! character — so it settles the byte limits (names never exceed 15 bytes there, +//! not qdmr's 16) but not an encoding. Text from the app's library is folded to +//! ASCII first (`util::fold_ascii`: René -> Rene), the one encoding every AnyTone +//! font draws, and then cut to the limit. +//! +//! # Writing +//! +//! Every index and record bank sits at the start of the first 128 KB half of +//! its 256 KB block, with nothing else in that half (see the flash-half notes +//! in `mod.rs`), and no bank reaches a half's marker frame. So the database is +//! written DIRECT — no read phase, no backup — one whole bank per half, which +//! is exactly what the radio rebuilds the half from. + +use crate::radios::anytone_atd890uv::{bcd_be_encode, end_session, open_port}; +use crate::util::fold_ascii; +use crate::radios::driver::{CallsignDbReport, CallsignDbWriter, CallsignRecord}; + +use super::{enter, write_sendable, AnytoneAtd578uv}; + +pub(crate) const INDEX_BASE: u32 = 0x0400_0000; +pub(crate) const LIMITS_BASE: u32 = 0x0484_0000; +pub(crate) const RECORD_BASE: u32 = 0x0550_0000; +pub(crate) const BANK_STEP: u32 = 0x0004_0000; +pub(crate) const INDEX_ENTRY: usize = 8; +pub(crate) const INDEX_BANK_BYTES: usize = 16_000 * INDEX_ENTRY; // 0x1F400 +pub(crate) const RECORD_BANK_BYTES: usize = 100_000; // 0x186A0 +/// The radio's capacity, and what the index region holds before it reaches +/// the contact ID table at 0x04800000 (32 banks of 16,000). +pub(crate) const MAX_ENTRIES: usize = 500_000; + +/// Per-field byte limits: name, city, callsign, state, country, comment. The +/// anchor radio's 281,304 records peak at exactly these lengths. +pub(crate) const FIELD_LIMITS: [usize; 6] = [15, 15, 8, 16, 16, 0]; + +/// One caller-ID entry. Fields are the bytes as stored. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Entry { + pub dmr_id: u32, + pub group_call: bool, + pub fields: [Vec; 6], +} + +impl Entry { + fn key(&self) -> u32 { + let bcd = u32::from_be_bytes( + bcd_be_encode(self.dmr_id as u64, 4).try_into().expect("four BCD bytes"), + ); + (bcd << 1) | self.group_call as u32 + } + + /// The record's bytes: header, then six NUL-terminated fields cut to their + /// byte limits (a NUL inside a field would end it early, so it is dropped). + fn record(&self) -> Vec { + let mut r = Vec::with_capacity(64); + r.push(self.group_call as u8); + r.extend_from_slice(&bcd_be_encode(self.dmr_id as u64, 4)); + r.push(0); + for (bytes, &limit) in self.fields.iter().zip(&FIELD_LIMITS) { + r.extend(bytes.iter().copied().filter(|&b| b != 0).take(limit)); + r.push(0); + } + r + } +} + +/// The encoded database: every block to write, in address order. +#[derive(Debug)] +pub(crate) struct Encoded { + pub entries: usize, + /// Entries left out because the radio holds [`MAX_ENTRIES`]. + pub dropped: usize, + pub blocks: Vec<(u32, Vec)>, +} + +/// Encode a database: entries sorted by key (on a duplicate key the LAST entry +/// wins — the command appends the hand-curated talkgroups after the bulk user +/// library so that they do), +/// the record stream split into banks, the index, and the limits record. Each +/// bank's block is padded to a whole 16-byte frame (index with 0xFF, records +/// with 0x00, as the radio's own database is). +pub(crate) fn encode(entries: &[Entry]) -> Result { + // Reversed, then a stable sort: the last of each duplicate run comes first, + // and `dedup_by_key` keeps the first of a run. + let mut sorted: Vec<&Entry> = entries.iter().rev().collect(); + sorted.sort_by_key(|e| e.key()); + sorted.dedup_by_key(|e| e.key()); + if sorted.is_empty() { + return Err("no entries to write".into()); + } + // Over capacity: keep every group entry (the talkgroups the TX screen names) + // and the users that come FIRST in the input — the library hands them over + // in priority order — then restore key order. + let mut dropped = 0; + if sorted.len() > MAX_ENTRIES { + let rank: std::collections::HashMap = + entries.iter().enumerate().map(|(i, e)| (e.key(), i)).collect(); + let groups = sorted.iter().filter(|e| e.group_call).count(); + let mut users: Vec<&Entry> = sorted.iter().copied().filter(|e| !e.group_call).collect(); + users.sort_by_key(|e| rank[&e.key()]); + users.truncate(MAX_ENTRIES.saturating_sub(groups)); + let kept: std::collections::HashSet = users.iter().map(|e| e.key()).collect(); + let before = sorted.len(); + sorted.retain(|e| e.group_call || kept.contains(&e.key())); + dropped = before - sorted.len(); + } + + let mut stream = Vec::new(); + let mut index = Vec::with_capacity(sorted.len() * INDEX_ENTRY); + for e in &sorted { + index.extend_from_slice(&e.key().to_le_bytes()); + index.extend_from_slice(&(stream.len() as u32).to_le_bytes()); + stream.extend(e.record()); + } + + let pad = |mut v: Vec, fill: u8| { + v.resize(v.len().div_ceil(16) * 16, fill); + v + }; + let mut blocks = vec![]; + for (i, chunk) in index.chunks(INDEX_BANK_BYTES).enumerate() { + blocks.push((INDEX_BASE + i as u32 * BANK_STEP, pad(chunk.to_vec(), 0xFF))); + } + let mut limits = vec![0u8; 16]; + limits[0..4].copy_from_slice(&(sorted.len() as u32).to_le_bytes()); + let banks = stream.len().div_ceil(RECORD_BANK_BYTES); + let last = stream.len() - (banks - 1) * RECORD_BANK_BYTES; + let end = RECORD_BASE + (banks as u32 - 1) * BANK_STEP + last as u32; + limits[4..8].copy_from_slice(&end.to_le_bytes()); + blocks.push((LIMITS_BASE, limits)); + for (i, chunk) in stream.chunks(RECORD_BANK_BYTES).enumerate() { + blocks.push((RECORD_BASE + i as u32 * BANK_STEP, pad(chunk.to_vec(), 0x00))); + } + blocks.sort_by_key(|(a, _)| *a); + Ok(Encoded { entries: sorted.len(), dropped, blocks }) +} + +/// The app's records as entries: IDs the radio cannot hold dropped, text folded. +pub(crate) fn entries_from_records(records: &[CallsignRecord]) -> Vec { + records + .iter() + .filter(|r| r.dmr_id > 0 && r.dmr_id <= 16_777_215) + .map(|r| Entry { + dmr_id: r.dmr_id, + group_call: r.group_call, + fields: [ + fold_ascii(&r.name), + fold_ascii(r.city.as_deref().unwrap_or_default()), + fold_ascii(&r.callsign), + fold_ascii(r.state.as_deref().unwrap_or_default()), + fold_ascii(r.country.as_deref().unwrap_or_default()), + fold_ascii(r.comment.as_deref().unwrap_or_default()), + ], + }) + .collect() +} + +impl CallsignDbWriter for AnytoneAtd578uv { + /// Encode and write the whole database DIRECT (see the module doc): one + /// session, every block whole, END once. + fn write_callsign_db(&self, port: &str, records: &[CallsignRecord]) -> Result { + let encoded = encode(&entries_from_records(records))?; + let mut p = open_port(port)?; + let _ident = enter(&mut *p)?; + for (addr, data) in &encoded.blocks { + // A failed frame leaves the session open on purpose: END would + // commit a half-written database. + write_sendable(&mut *p, *addr, data) + .map_err(|e| format!("{e} — the call-sign database is half written; power-cycle the radio and write it again"))?; + } + let _ = end_session(&mut *p); + Ok(CallsignDbReport { + entries_written: encoded.entries, + windows_written: encoded.blocks.iter().map(|(a, _)| format!("0x{a:08X}")).collect(), + note: if encoded.dropped > 0 { + format!( + "Written and committed — the radio reboots. The radio holds {MAX_ENTRIES} \ + entries, so the {} lowest-priority users were left out.", + encoded.dropped + ) + } else { + "Written and committed — the radio reboots. Check a heard station's name on its \ + screen." + .to_string() + }, + verified: false, + }) + } +} + +/// Decode a database back out of its blocks, for the byte-identical gate. +#[cfg(test)] +pub(crate) fn decode(blocks: &std::collections::BTreeMap>) -> Vec { + let lim = &blocks[&LIMITS_BASE]; + let count = u32::from_le_bytes(lim[0..4].try_into().unwrap()) as usize; + let mut stream = Vec::new(); + for bank in 0.. { + let Some(b) = blocks.get(&(RECORD_BASE + bank * BANK_STEP)) else { break }; + stream.extend_from_slice(&b[..b.len().min(RECORD_BANK_BYTES)]); + } + let mut out = Vec::with_capacity(count); + let mut pos = 0; + for _ in 0..count { + let r = &stream[pos..]; + let id = crate::radios::anytone_atd890uv::bcd_be(&r[1..5]) as u32; + let mut fields: [Vec; 6] = Default::default(); + let mut o = 6; + for f in fields.iter_mut() { + let n = r[o..].iter().position(|&b| b == 0).unwrap(); + *f = r[o..o + n].to_vec(); + o += n + 1; + } + out.push(Entry { dmr_id: id, group_call: r[0] == 1, fields }); + pos += o; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(id: u32, group: bool, name: &str) -> Entry { + Entry { + dmr_id: id, + group_call: group, + fields: [fold_ascii(name), b"City".to_vec(), b"CALL".to_vec(), b"ST".to_vec(), b"USA".to_vec(), vec![]], + } + } + + #[test] + fn a_duplicate_key_keeps_the_last_entry_and_group_is_a_separate_key() { + let enc = encode(&[ + entry(3100, false, "User"), + entry(3100, true, "TG USA"), + entry(3100, false, "Hand-curated"), + ]) + .unwrap(); + assert_eq!(enc.entries, 2); + let blocks = enc.blocks.into_iter().collect(); + let back = decode(&blocks); + assert_eq!(back[0].fields[0], b"Hand-curated"); + assert!(!back[0].group_call); + assert!(back[1].group_call, "the group entry sorts after the private one"); + } + + #[test] + fn records_straddle_banks_and_the_end_is_a_real_address() { + // 2,500 entries of 6 + (5+1)+(4+1)+(4+1)+(2+1)+(3+1)+1 = 30 bytes = 75,000 + // bytes — one bank; 4,000 such entries run into a second bank. + let entries: Vec = (1..=4000).map(|i| entry(1_000_000 + i, false, "Name0")).collect(); + let enc = encode(&entries).unwrap(); + let stream_len = 4000 * 30; + let end = RECORD_BASE + BANK_STEP + (stream_len - RECORD_BANK_BYTES) as u32; + let limits = &enc.blocks.iter().find(|(a, _)| *a == LIMITS_BASE).unwrap().1; + assert_eq!(u32::from_le_bytes(limits[4..8].try_into().unwrap()), end); + let blocks: std::collections::BTreeMap> = enc.blocks.into_iter().collect(); + assert_eq!(blocks[&RECORD_BASE].len(), RECORD_BANK_BYTES); + assert_eq!(decode(&blocks), entries); + } + + #[test] + fn over_capacity_keeps_talkgroups_and_the_highest_priority_users() { + // Priority order is input order: the last users in the input go first. + let mut entries: Vec = (0..MAX_ENTRIES as u32 + 10).map(|i| entry(1_000_000 + i, false, "U")).collect(); + entries.push(entry(91, true, "Worldwide")); + let enc = encode(&entries).unwrap(); + assert_eq!(enc.entries, MAX_ENTRIES); + assert_eq!(enc.dropped, 11); + let back = decode(&enc.blocks.into_iter().collect()); + assert!(back.iter().any(|e| e.group_call && e.dmr_id == 91), "the talkgroup survives"); + assert!(back.iter().any(|e| e.dmr_id == 1_000_000), "the first user survives"); + assert!(!back.iter().any(|e| e.dmr_id == 1_000_000 + MAX_ENTRIES as u32 + 9), "the last is dropped"); + } + + #[test] + fn fields_are_cut_to_their_byte_limits() { + let mut e = entry(1, false, "A very long name that overflows"); + e.fields[2] = b"VERYLONGCALL".to_vec(); + let enc = encode(&[e]).unwrap(); + let back = decode(&enc.blocks.into_iter().collect()); + assert_eq!(back[0].fields[0], b"A very long nam", "names stop at 15 bytes"); + assert_eq!(back[0].fields[2], b"VERYLONG"); + } + + #[test] + fn library_text_is_folded_to_ascii() { + assert_eq!(fold_ascii("René Liège"), b"Rene Liege"); + assert_eq!(fold_ascii("Jürgen Groß"), b"Jurgen Gross"); + assert_eq!(fold_ascii("Søren Łódź"), b"Soren Lodz"); + assert_eq!(fold_ascii("東京"), b"??"); + assert_eq!(fold_ascii("tab\there"), b"tabhere"); + } + + /// ★ The gate: decode the anchor radio's own database (a full read-only dump, + /// kept out of the repo — it is radioid.net user data) and re-encode it. The + /// index, every record bank and the limits record must come out byte for + /// byte as the radio holds them. + /// CPM_CSDB=scratchpad/anytone_atd578uv/csdb_full.bin cargo test --lib csdb_gate -- --ignored --nocapture + #[test] + #[ignore = "needs CPM_CSDB pointing at a full call-sign DB dump"] + fn csdb_gate_the_radios_database_reencodes_byte_identically() { + let path = std::env::var("CPM_CSDB").expect("CPM_CSDB"); + let blocks: std::collections::BTreeMap> = + crate::radios::anytone_atd890uv::parse_dump(&std::fs::read(path).unwrap()) + .unwrap() + .into_iter() + .collect(); + let entries = decode(&blocks); + eprintln!("decoded {} entries", entries.len()); + let enc = encode(&entries).unwrap(); + assert_eq!(enc.entries, entries.len(), "no entry was a duplicate key"); + for (addr, data) in &enc.blocks { + let radio = &blocks[addr]; + let n = data.len().min(radio.len()); + let first = (0..n).find(|&i| data[i] != radio[i]); + assert_eq!(first, None, "block 0x{addr:08X} differs at +0x{:X}", first.unwrap_or(0)); + assert_eq!(data.len(), radio.len(), "block 0x{addr:08X} length"); + } + eprintln!("{} blocks byte-identical", enc.blocks.len()); + } +} diff --git a/src-tauri/src/radios/anytone_atd578uv/hw_ladder.rs b/src-tauri/src/radios/anytone_atd578uv/hw_ladder.rs new file mode 100644 index 0000000..d239872 --- /dev/null +++ b/src-tauri/src/radios/anytone_atd578uv/hw_ladder.rs @@ -0,0 +1,408 @@ +//! THROWAWAY hardware ladder for the AT-D578UV (issue #47, new-radio step 7). +//! Each test is ONE radio operation in one process — the radio reboots and drops +//! off USB on END, so it is reattached between runs. Each write saves its backup +//! and its expected image BEFORE anything is sent, and says what the follow-up +//! verify must find. +//! +//! CPM_PORT=/dev/cu.usbmodemXXX CPM_DIR= \ +//! cargo test --lib hw_d578_ -- --ignored --nocapture +//! +//! Ops: `identity` (rewrite bank 0 unchanged), `rename` (change one character of +//! channel 1's name), `verify` (read back the last `.expected.bin` in CPM_DIR, +//! or CPM_EXPECTED, and byte-compare). + +use std::path::PathBuf; + +use crate::radios::anytone_atd890uv::{ + diff_dumps, end_session, open_port, parse_dump, read_block, + serialize_backup, BankPlan, WRITE_WINDOW, +}; + +use super::*; + +fn env(key: &str) -> String { + std::env::var(key).unwrap_or_else(|_| panic!("{key} not set")) +} + +fn dir() -> PathBuf { + PathBuf::from(env("CPM_DIR")) +} + +/// Read bank 0, apply `edit`, and write it the way the driver does: the rest of +/// its flash half carried, the marker frame never sent ([`write_windows`]). +fn write_bank0(label: &str, edit: impl FnOnce(&mut Vec)) { + write_windows(label, &[CHANNEL_BASE], |w| edit(w.get_mut(&CHANNEL_BASE).unwrap())); +} + +/// Rung 1: rewrite bank 0 byte-for-byte. Proves the write path and the commit +/// with nothing at risk; the verify must find zero differences. +#[test] +#[ignore = "writes a real radio"] +fn hw_d578_identity() { + write_bank0("identity", |_| {}); +} + +/// Rung 2: channel 1's name, last character 'S' -> 'Z' ("***SCAN CHANNELZ"). +/// The verify must find exactly that one byte changed, and the radio's channel +/// list must show the new name. +#[test] +#[ignore = "writes a real radio"] +fn hw_d578_rename() { + write_bank0("rename", |w| { + let o = CH_NAME + NAME_CHARS - 1; + assert_eq!(w[o], b'S', "channel 1 is not the anchor radio's \"***SCAN CHANNELS\""); + w[o] = b'Z'; + }); +} + +/// Read back every block of an expected image and byte-compare. Read-only. +#[test] +#[ignore = "reads a real radio"] +fn hw_d578_verify() { + let expected = std::env::var("CPM_EXPECTED").map(PathBuf::from).unwrap_or_else(|_| { + let mut all: Vec = std::fs::read_dir(dir()) + .unwrap() + .flatten() + .map(|e| e.path()) + .filter(|p| p.to_string_lossy().ends_with(".expected.bin")) + .collect(); + all.sort(); + all.pop().expect("no .expected.bin in CPM_DIR") + }); + eprintln!("verifying {}", expected.display()); + let blocks = parse_dump(&std::fs::read(&expected).unwrap()).unwrap(); + let mut p = open_port(&env("CPM_PORT")).unwrap(); + let _ = enter(&mut *p).unwrap(); + let mut actual = Vec::new(); + for (addr, data) in &blocks { + match read_block(&mut *p, *addr, data.len()) { + Ok(d) => actual.push((*addr, d)), + Err(e) => { + let _ = end_session(&mut *p); + panic!("read 0x{addr:08X} failed: {e}"); + } + } + } + let _ = end_session(&mut *p); + let diffs = diff_dumps(&blocks, &actual); + eprintln!("{} bytes checked, {} differences", blocks.iter().map(|(_, d)| d.len()).sum::(), diffs.len()); + for d in diffs.iter().take(20) { + eprintln!(" {d:?}"); + } + assert!(diffs.is_empty(), "radio does not match the expected image"); +} + +/// Rung 3: a full codeplug through the driver's real program path +/// (`CodeplugProgrammer::program`): every channel list and scan list any codeplug +/// in a COPY of the app database uses, assembled the way the app assembles one. +/// The backup and expected image land in CPM_DIR as `atd578uv-program-*`. +/// CPM_DB= CPM_PORT=… CPM_DIR=… cargo test --lib hw_d578_program -- --ignored --nocapture +#[tokio::test] +#[ignore = "writes a real radio"] +async fn hw_d578_program() { + use crate::radios::driver::CodeplugProgrammer; + let pool = crate::db::init_pool(std::path::Path::new(&env("CPM_DB"))).await.unwrap(); + let (model_id,): (i64,) = sqlx::query_as("SELECT id FROM radio_models WHERE model = 'AT-D578UV'") + .fetch_one(&pool) + .await + .unwrap(); + let profile_id = sqlx::query("INSERT INTO radio_profiles (display_name, radio_model_id) VALUES ('ladder', ?1)") + .bind(model_id) + .execute(&pool) + .await + .unwrap() + .last_insert_rowid(); + let cp_id = sqlx::query("INSERT INTO codeplugs (name, radio_profile_id) VALUES ('ladder', ?1)") + .bind(profile_id) + .execute(&pool) + .await + .unwrap() + .last_insert_rowid(); + sqlx::query( + "INSERT INTO codeplug_channel_lists (codeplug_id, channel_list_id, position) + SELECT ?1, channel_list_id, ROW_NUMBER() OVER (ORDER BY MIN(codeplug_id), MIN(position)) + FROM codeplug_channel_lists WHERE codeplug_id != ?1 GROUP BY channel_list_id", + ) + .bind(cp_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO codeplug_scan_lists (codeplug_id, scan_list_id) + SELECT DISTINCT ?1, scan_list_id FROM codeplug_scan_lists WHERE codeplug_id != ?1", + ) + .bind(cp_id) + .execute(&pool) + .await + .unwrap(); + let resolved = crate::commands::export::resolve_codeplug_payload(&pool, cp_id).await.unwrap(); + let report = DRIVER.program(&env("CPM_PORT"), &resolved.payload(), &dir()).unwrap(); + eprintln!( + "programmed {} channels ({} cleared), {} zones ({} cleared), {} scan lists ({} cleared), \ + {} contacts ({} cleared); {} windows; backup {}", + report.channels_written, + report.slots_cleared, + report.zones_written, + report.zones_cleared, + report.scan_lists_written, + report.scan_lists_cleared, + report.contacts_written, + report.contacts_cleared, + report.windows_written.len(), + report.backup_path + ); + for w in report.warnings.iter().take(10) { + eprintln!(" warning {w}"); + } +} + +/// Restore the newest `atd578uv-program-*.bin` backup in CPM_DIR (or +/// CPM_BACKUP) through the same guard and write loop as the dialog's Restore, +/// then END. Verify afterwards with CPM_EXPECTED pointing at the backup itself. +#[test] +#[ignore = "writes a real radio"] +fn hw_d578_restore() { + let backup = std::env::var("CPM_BACKUP").map(PathBuf::from).unwrap_or_else(|_| { + let mut all: Vec = std::fs::read_dir(dir()) + .unwrap() + .flatten() + .map(|e| e.path()) + .filter(|p| { + let n = p.file_name().unwrap().to_string_lossy().to_string(); + n.starts_with(program::PROGRAM_PREFIX) && !n.ends_with(".expected.bin") + }) + .collect(); + all.sort(); + all.pop().expect("no program backup in CPM_DIR") + }); + eprintln!("restoring {}", backup.display()); + let blocks = parse_dump(&std::fs::read(&backup).unwrap()).unwrap(); + check_restore_blocks(&blocks).unwrap(); + let mut p = open_port(&env("CPM_PORT")).unwrap(); + let _ = enter(&mut *p).unwrap(); + for (addr, data) in &blocks { + write_sendable(&mut *p, *addr, data) + .unwrap_or_else(|e| panic!("write 0x{addr:08X} failed (NOT ended): {e}")); + } + end_session(&mut *p).unwrap(); + eprintln!("restored {} windows and ended", blocks.len()); +} + +/// Read `windows`, let `edit` patch copies, back up + save expected, write the +/// changed windows, END. +fn write_windows(label: &str, windows: &[u32], edit: impl FnOnce(&mut std::collections::BTreeMap>)) { + let mut p = open_port(&env("CPM_PORT")).unwrap(); + let _ = enter(&mut *p).unwrap(); + let mut original = std::collections::BTreeMap::new(); + for &w in windows { + match read_block(&mut *p, w, WRITE_WINDOW) { + Ok(d) => { + original.insert(w, d); + } + Err(e) => { + let _ = end_session(&mut *p); + panic!("read 0x{w:08X} failed: {e}"); + } + } + } + let mut patched = original.clone(); + edit(&mut patched); + let plans: Vec = patched + .into_iter() + .filter(|(b, d)| original[b] != *d) + .map(|(b, d)| BankPlan { base: b, original: original[&b].clone(), patched: d }) + .collect(); + // The radio rebuilds every half it is written into: carry the rest of it. + let mut reads = original.clone(); + for w in windows_of_touched_halves(&plans) { + reads.entry(w).or_insert_with(|| read_block(&mut *p, w, WRITE_WINDOW).unwrap()); + } + let plans = with_carried_windows(plans, &reads).unwrap(); + eprintln!("writing {:?}", plans.iter().map(|p| format!("0x{:08X}{}", p.base, if p.original == p.patched { " (carried)" } else { "" })).collect::>()); + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let backup = dir().join(format!("ladder-{label}-{stamp}.bin")); + let expected = dir().join(format!("ladder-{label}-{stamp}.expected.bin")); + std::fs::write(&backup, serialize_backup(&plan_blocks(&plans, false))).unwrap(); + std::fs::write(&expected, serialize_backup(&plan_blocks(&plans, true))).unwrap(); + eprintln!("{} windows change; backup {}", plans.len(), backup.display()); + commit_windows(&mut *p, &plans).unwrap_or_else(|e| panic!("write failed (NOT ended): {e}")); + end_session(&mut *p).unwrap(); + eprintln!("written and ended; expected {}", expected.display()); +} + +/// Rung 4: six receive-only probe channels at the edges of the claimed coverage, +/// in the anchor radio's empty slots of bank 0, gathered into its empty zone 11 +/// as "BAND PROBE". The radio must list all six at these frequencies. +#[test] +#[ignore = "writes a real radio"] +fn hw_d578_band_probe() { + const SLOTS: [usize; 6] = [21, 37, 38, 70, 85, 125]; + const FREQS: [f64; 6] = [136.0, 173.995, 220.0, 224.995, 400.0, 519.995]; + const ZONE: usize = 10; + let bitmap = CHANNEL_BITMAP_BASE & !(WRITE_WINDOW as u32 - 1); + write_windows("band-probe", &[CHANNEL_BASE, bitmap, ZONE_LIST_BASE, ZONE_NAME_BASE], |w| { + for (&slot, &mhz) in SLOTS.iter().zip(&FREQS) { + let bm = &mut w.get_mut(&bitmap).unwrap()[(CHANNEL_BITMAP_BASE - bitmap) as usize..]; + assert_eq!(bm[slot / 8] >> (slot % 8) & 1, 0, "slot {} is not empty", slot + 1); + bm[slot / 8] |= 1 << (slot % 8); + let mut rec = channel_record_template().to_vec(); + let edit = crate::radios::anytone_atd890uv::AnytoneChannelEdit { + name: format!("PROBE {mhz:.3}"), + rx_mhz: mhz, + tx_mhz: mhz, + power: 0, + wide: true, + mode: 0, + tone: Default::default(), + color_code: 1, + time_slot: 1, + contact_index: 0, + scan_list_index: Some(0xFF), + }; + encode_channel_into(&mut rec, &edit).unwrap(); + let o = slot * CH_REC_LEN; + w.get_mut(&CHANNEL_BASE).unwrap()[o..o + CH_REC_LEN].copy_from_slice(&rec); + } + let zb = &mut w.get_mut(&bitmap).unwrap()[(ZONE_BITMAP_BASE - bitmap) as usize..]; + assert_eq!(zb[ZONE / 8] >> (ZONE % 8) & 1, 0, "zone 11 is not empty"); + zb[ZONE / 8] |= 1 << (ZONE % 8); + let members: Vec = SLOTS.iter().map(|&s| s as u16).collect(); + let o = ZONE * ZONE_LIST_STEP as usize; + w.get_mut(&ZONE_LIST_BASE).unwrap()[o..o + ZONE_LIST_STEP as usize] + .copy_from_slice(&encode_zone_list(&members).unwrap()); + let o = ZONE * ZONE_NAME_STEP as usize; + w.get_mut(&ZONE_NAME_BASE).unwrap()[o..o + ZONE_NAME_STEP as usize] + .copy_from_slice(&encode_zone_name("BAND PROBE")); + }); +} + +/// Rung 5: two settings that stay visible, through the real settings encoder: +/// display brightness 4 -> 2, Channel A name colour Yellow -> Green. +#[test] +#[ignore = "writes a real radio"] +fn hw_d578_settings() { + let values = serde_json::json!({ "display-brightness": 2, "channel-a-name-color": "Green" }); + write_windows("settings", &settings::WRITE_WINDOWS, |w| { + let plans = settings::plan_settings_write(w, &values).unwrap(); + assert_eq!(plans.len(), 1, "only the settings window should change"); + let pl = &plans[0]; + let diff: Vec = (0..WRITE_WINDOW).filter(|&i| pl.original[i] != pl.patched[i]).collect(); + assert_eq!(diff, vec![0x01D, 0x0C5], "exactly the two fields' bytes"); + w.insert(pl.base, pl.patched.clone()); + }); +} + +/// Rung 5, any values: CPM_VALUES is a JSON object of profile settings, written +/// through the real settings encoder. +#[test] +#[ignore = "writes a real radio"] +fn hw_d578_settings_values() { + let values: serde_json::Value = serde_json::from_str(&env("CPM_VALUES")).unwrap(); + write_windows("settings", &settings::WRITE_WINDOWS, |w| { + for pl in settings::plan_settings_write(w, &values).unwrap() { + let diff: Vec = (0..WRITE_WINDOW) + .filter(|&i| pl.original[i] != pl.patched[i]) + .map(|i| format!("0x{:X}", pl.base + i as u32)) + .collect(); + eprintln!("changing {diff:?}"); + w.insert(pl.base, pl.patched.clone()); + } + }); +} + +/// Repair: put the anchor radio's GPS-roaming table (0x02504000, 0x400 bytes of +/// zeros in the first dump) back after a settings write erased it. The settings +/// window shares the half and is carried unchanged. +#[test] +#[ignore = "writes a real radio"] +fn hw_d578_repair_gps_roaming() { + write_windows("repair-gps-roaming", &[0x0250_4000], |w| { + let win = w.get_mut(&0x0250_4000).unwrap(); + assert!(win.iter().all(|&b| b == 0xFF), "the table is not in its erased state"); + win[..0x400].fill(0); + }); +} + +/// Settings through the SHIPPING `SettingsWriter::write_settings` (half carrying +/// included). CPM_VALUES is a JSON object of profile values. +#[test] +#[ignore = "writes a real radio"] +fn hw_d578_settings_driver() { + use crate::radios::driver::SettingsWriter; + let values: serde_json::Value = serde_json::from_str(&env("CPM_VALUES")).unwrap(); + let r = DRIVER.write_settings(&env("CPM_PORT"), &values, "", &dir()).unwrap(); + eprintln!("fields {} windows {:?} backup {}", r.fields_written, r.windows_written, r.backup_path); +} + +/// The AnyTone dialog's own Verify command, as the dialog calls it (driver key +/// first). CPM_EXPECTED is the image to verify against. +#[tokio::test] +#[ignore = "reads a real radio"] +async fn hw_d578_dialog_verify() { + use crate::commands::anytone_program::verify_anytone_program; + let path = env("CPM_EXPECTED"); + let wrong = verify_anytone_program("anytone_atd890uv".into(), env("CPM_PORT"), path.clone()).await; + eprintln!("D890 key on a D578 file: {:?}", wrong.as_ref().err()); + assert!(wrong.is_err()); + let r = verify_anytone_program("anytone_atd578uv".into(), env("CPM_PORT"), path).await.unwrap(); + eprintln!("ok {} bytes {} mismatches {}", r.ok, r.bytes_checked, r.mismatches.len()); + assert!(r.ok); +} + +/// The AnyTone dialog's own Restore command. CPM_BACKUP is the file. +#[tokio::test] +#[ignore = "writes a real radio"] +async fn hw_d578_dialog_restore() { + use crate::commands::anytone_program::restore_anytone_backup; + let r = restore_anytone_backup("anytone_atd578uv".into(), env("CPM_PORT"), env("CPM_BACKUP")).await.unwrap(); + eprintln!("restored {:?}", r.windows_written); +} + +/// Call-sign DB through the SHIPPING writer, from a copy of the app database +/// (users + talkgroups exactly as the command assembles them). The expected +/// image is saved first as `csdb-.expected.bin` in CPM_DIR. +#[tokio::test] +#[ignore = "writes a real radio"] +async fn hw_d578_csdb_program() { + use crate::radios::driver::CallsignDbWriter; + let pool = crate::db::init_pool(std::path::Path::new(&env("CPM_DB"))).await.unwrap(); + let records = crate::commands::program::callsign_records(&pool, None, &[]).await.unwrap(); + let enc = callsign_db::encode(&callsign_db::entries_from_records(&records)).unwrap(); + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let expected = dir().join(format!("csdb-{stamp}.expected.bin")); + std::fs::write(&expected, serialize_backup(&enc.blocks)).unwrap(); + eprintln!("{} records -> {} entries, {} blocks; expected {}", records.len(), enc.entries, enc.blocks.len(), expected.display()); + let r = DRIVER.write_callsign_db(&env("CPM_PORT"), &records).unwrap(); + eprintln!("wrote {} entries, {} blocks", r.entries_written, r.windows_written.len()); +} + +/// Put a full call-sign DB dump (CPM_BACKUP) back, direct, in one session. +#[test] +#[ignore = "writes a real radio"] +fn hw_d578_csdb_restore() { + let blocks = parse_dump(&std::fs::read(env("CPM_BACKUP")).unwrap()).unwrap(); + let mut p = open_port(&env("CPM_PORT")).unwrap(); + let _ = enter(&mut *p).unwrap(); + for (addr, data) in &blocks { + write_sendable(&mut *p, *addr, data).unwrap_or_else(|e| panic!("0x{addr:08X} failed (NOT ended): {e}")); + } + end_session(&mut *p).unwrap(); + eprintln!("restored {} blocks", blocks.len()); +} + +/// Air band: three channels at the edges and middle of 108-136 MHz in the +/// first three air slots, present and scanned; everything else as it was. +#[test] +#[ignore = "writes a real radio"] +fn hw_d578_air_probe() { + write_windows("air-probe", &[AIR_BASE], |w| { + let win = w.get_mut(&AIR_BASE).unwrap(); + for (i, (mhz, name)) in [(108.0, "AIR 108.000"), (121.5, "AIR 121.500"), (135.975, "AIR 135.975")].iter().enumerate() { + win[i * AIR_REC_LEN..(i + 1) * AIR_REC_LEN].copy_from_slice(&encode_air_channel(*mhz, name)); + } + let bm = (AIR_BITMAP - AIR_BASE) as usize; + let scan = (AIR_SCAN_BITMAP - AIR_BASE) as usize; + win[bm] |= 0x07; + win[scan] |= 0x07; + }); +} diff --git a/src-tauri/src/radios/anytone_atd578uv/mod.rs b/src-tauri/src/radios/anytone_atd578uv/mod.rs new file mode 100644 index 0000000..e8ae89a --- /dev/null +++ b/src-tauri/src/radios/anytone_atd578uv/mod.rs @@ -0,0 +1,833 @@ +//! AnyTone AT-D578UV driver (`driver_key = "anytone_atd578uv"`, issue #47). +//! +//! The D578UV speaks the same clone protocol as the D890UV — `PROGRAM`, the +//! `0x02` ident, 16-byte `R`/`W` frames with the `addr+len+data` checksum, `END` +//! to commit and reboot — so the transport, the window read-modify-write engine +//! and the codeplug planner are shared with `anytone_atd890uv`. The MEMORY MAP is +//! not shared: the D578 uses the D868/D878 family layout with its own overrides, +//! and every base address and record size below differs from the D890's. +//! +//! Sources, both for firmware 1.21 (the radio this was built against reads +//! `ID578UV\0` band 0x0E rev `V111`, Device Info firmware 1.21): +//! - qdmr (GPLv3) `lib/d578uv_codeplug.*`, `d878uv_codeplug.*`, `d868uv_codeplug.*`; +//! - the dmr-tools/codeplugs `d578uv` v1.21 definition (machine-generated from +//! the CPS by anytone-emu). +//! +//! Every offset this module writes was anchored on a read-only dump of a real +//! D578UV first (`scratchpad/anytone_atd578uv/FINDINGS.md`): 392 channels, 21 +//! zones and 21 scan lists decoded at these addresses, and the channel fields +//! agree with the operator's own channel library on 272 independently joined +//! rows. The fixture in `testdata/` is that dump's record regions only. +//! +//! Two traps, each measured on the radio rather than taken from a source: +//! - the CTCSS table has NO 150.0 Hz entry (index 25 = 151.4). The published +//! D578 maps agree; the D890UV's hardware-proven table does have one, so the +//! two drivers' tables are NOT interchangeable ([`CTCSS`]); +//! - dmr-tools gives channel byte 0x3D bit 0 a default of 1; the radio writes 0 +//! on 391 of 392 channels ([`channel_record_template`]). + +use serialport::SerialPort; + +use std::collections::{BTreeMap, BTreeSet}; + + +use crate::radios::anytone_atd890uv::{ + ascii, bcd_be_encode, encode_mode_byte, encode_shift, end_session, + enter_program_and_ident_as, freq_encode, hex, open_port, read_block, write_block, + AnytoneChannelEdit, + AnytoneSubTone, BankPlan, IdentExpect, BAUD, WRITE_WINDOW, +}; +#[cfg(test)] +use crate::radios::anytone_atd890uv::{bcd_be, freq_mhz, AnytoneToneEdit}; +use crate::radios::driver::{ + CallsignDbWriter, CodeplugProgrammer, DriverDiagnostics, RadioDriver, RadioIdentity, + SettingsReader, SettingsWriter, +}; + +pub(crate) mod callsign_db; +pub(crate) mod program; +pub(crate) mod settings; + +// ============================================================ +// Driver registration +// ============================================================ + +pub(crate) struct AnytoneAtd578uv; + +pub(crate) static DRIVER: AnytoneAtd578uv = AnytoneAtd578uv; + +/// The ident every D578 session requires. A D890UV (or a D878UV, whose map is +/// close but whose channel tail is laid out differently) is refused before a +/// single address is read — and so is a D578 of another revision. The map is +/// firmware 1.21's; field positions moved between 1.17 and 1.21, and the PLUS +/// runs separate firmware. No readable region carries the firmware version +/// (0x02FA0000 holds a serial and build date), so the ident's revision is the +/// gate: qdmr's 1.21 map is for `V111`, which the anchor radio reports. +pub(crate) const D578_IDENT: IdentExpect = IdentExpect { + token: "578", + name: "D578UV", + revision: Some("V111"), +}; + +impl RadioDriver for AnytoneAtd578uv { + fn key(&self) -> &'static str { + "anytone_atd578uv" + } + + fn display_name(&self) -> &'static str { + "AnyTone AT-D578UV" + } + + fn baud(&self) -> u32 { + BAUD + } + + fn identify(&self, port: &str) -> Result { + let mut p = open_port(port)?; + let ident = enter(&mut *p)?; + // END reboots the radio and drops it off USB; it is always the last + // thing a session does. + let _ = end_session(&mut *p); + Ok(RadioIdentity { + matched: ascii(&ident), + ident_hex: hex(&ident), + ident_ascii: Some(ascii(&ident)), + }) + } + + // The programming unit is the whole codeplug (channels + zones + scan lists + // + contacts in one session), as on the D890UV. + fn as_codeplug_programmer(&self) -> Option<&dyn CodeplugProgrammer> { + Some(self) + } + + // Settings are a separate, explicitly chosen write (the dialog's Profile + // payload); a codeplug program leaves the radio's own alone. + fn as_settings_reader(&self) -> Option<&dyn SettingsReader> { + Some(self) + } + + fn as_settings_writer(&self) -> Option<&dyn SettingsWriter> { + Some(self) + } + + fn as_callsign_db_writer(&self) -> Option<&dyn CallsignDbWriter> { + Some(self) + } + + fn as_diagnostics(&self) -> Option<&dyn DriverDiagnostics> { + Some(self) + } +} + +impl DriverDiagnostics for AnytoneAtd578uv { + fn dump_raw(&self, port: &str, start: u32, len: u32) -> Result, String> { + let mut p = open_port(port)?; + let _ident = enter(&mut *p)?; + let data = read_block(&mut *p, start, len as usize); + let _ = end_session(&mut *p); + data + } +} + +/// Enter PC mode and identify, refusing anything that is not a D578UV. +pub(crate) fn enter(p: &mut dyn SerialPort) -> Result, String> { + enter_program_and_ident_as(p, D578_IDENT) +} + +// ============================================================ +// Memory map (firmware 1.21; anchored on a real dump — see the module doc) +// ============================================================ + +/// Channels: 32 banks × 128 records of 0x40 bytes. Bank N is at +/// `CHANNEL_BASE + N * BANK_STEP`; its records fill the first 0x2000 bytes and a +/// parallel 0x40-byte extension record per channel fills the next 0x2000, so one +/// 0x4000 write window covers a bank's records AND their extensions. +pub(crate) const CHANNEL_BASE: u32 = 0x0080_0000; +pub(crate) const BANK_STEP: u32 = 0x0004_0000; +pub(crate) const NUM_BANKS: usize = 32; +pub(crate) const CH_PER_BANK: usize = 128; +pub(crate) const CH_REC_LEN: usize = 0x40; +pub(crate) const MAX_CHANNELS: usize = 4000; +/// Channel-present bitmap: 1 bit per channel, LSB-first, SET = present. 392 bits +/// set on the anchor radio, exactly the 392 records that decode. +pub(crate) const CHANNEL_BITMAP_BASE: u32 = 0x024C_1500; +pub(crate) const CHANNEL_BITMAP_BYTES: usize = 500; + +/// Zones: 250 member lists of 250 u16-LE 0-based channel indices (0xFFFF fill) +/// at `ZONE_LIST_BASE` step 0x200, names 16 ASCII chars in a 0x20 record at +/// `ZONE_NAME_BASE`, and a present bitmap (SET = present). +pub(crate) const ZONE_LIST_BASE: u32 = 0x0100_0000; +pub(crate) const ZONE_LIST_STEP: u32 = 0x200; +pub(crate) const ZONE_LIST_SLOTS: usize = 250; +pub(crate) const ZONE_NAME_BASE: u32 = 0x0254_0000; +pub(crate) const ZONE_NAME_STEP: u32 = 0x20; +pub(crate) const MAX_ZONES: usize = 250; +pub(crate) const ZONE_BITMAP_BASE: u32 = 0x024C_1300; +pub(crate) const ZONE_BITMAP_BYTES: usize = 32; +/// Channels per zone. The record holds 250 and qdmr, dmr-tools and the radio's +/// own 75-channel zone agree on more than the 2019 manual's 160; 250 is the +/// record's capacity and the published limit. +pub(crate) const ZONE_MAX_CHANNELS: usize = 250; + +/// Scan lists: 16 records of 0x200 per 0x40000 bank (list 17 is at 0x010C0000 — +/// confirmed on the anchor radio, which carries 21). Record layout (dmr-tools, +/// all 21 radio lists decode with it): +0x01 priority select, +0x02/+0x04 +/// priority channel u16 LE (0xFFFF none), +0x06/+0x08 look-back A/B, +0x0A +/// dropout, +0x0C dwell (u16 LE, 0.1 s), +0x0E revert enum, +0x0F name (16 +/// ASCII), +0x20 50 × u16 LE 0-based channel indices (0xFFFF fill). +pub(crate) const SCAN_LIST_BASE: u32 = 0x0108_0000; +pub(crate) const SCAN_LISTS_PER_BANK: usize = 16; +pub(crate) const SCAN_LIST_STEP: u32 = 0x200; +pub(crate) const MAX_SCAN_LISTS: usize = 250; +pub(crate) const SCAN_BITMAP_BASE: u32 = 0x024C_1340; +pub(crate) const SCAN_BITMAP_BYTES: usize = 32; +pub(crate) const SCAN_MAX_CHANNELS: usize = 50; +pub(crate) const SCAN_NAME: usize = 0x0F; +pub(crate) const SCAN_MEMBERS: usize = 0x20; +/// End of the bytes this driver owns in a scan-list record. 0x84..0x90 carry +/// values that match no channel field on the anchor radio (dmr-tools calls them +/// unused); a read-modify-write leaves them, and everything after, alone. +pub(crate) const SCAN_OWNED_END: usize = SCAN_MEMBERS + SCAN_MAX_CHANNELS * 2; + +/// DMR contacts: 10 banks of 1000 records of 100 bytes, bank step 0x40000. +/// +0x00 call type (0 private, 1 group, 2 all), +0x01 name (16 ASCII), +0x23 +/// DMR ID (BCD BE, 4 bytes), +0x27 call alert. +pub(crate) const CONTACT_BASE: u32 = 0x0268_0000; +pub(crate) const CONTACT_BANK_STEP: u32 = 0x0004_0000; +pub(crate) const CONTACTS_PER_BANK: usize = 1000; +pub(crate) const CONTACT_REC_LEN: usize = 0x64; +pub(crate) const MAX_CONTACTS: usize = 10_000; +pub(crate) const CONTACT_NAME: usize = 0x01; +pub(crate) const CONTACT_DMR_ID: usize = 0x23; +/// Contact index list: u32 LE per listed contact, 0xFFFFFFFF fill. +pub(crate) const CONTACT_INDEX_BASE: u32 = 0x0260_0000; +/// Contact present bitmap — INVERTED (a clear bit = present), as on the D890. +pub(crate) const CONTACT_BITMAP_BASE: u32 = 0x0264_0000; +pub(crate) const CONTACT_BITMAP_BYTES: usize = 1250; + +/// Contact ID table: one 8-byte entry per contact — a u32 LE key of the DMR ID +/// in BCD shifted left one bit with the group-call flag in bit 0, then the u32 +/// LE contact index — sorted by key, then one 0xFF entry. Because the ID is BCD +/// the key order is numeric ID order. The radio looks talkgroups up here; +/// rebuilding it from the anchor radio's 38 contacts reproduces its own table +/// byte for byte. +pub(crate) const CONTACT_ID_TABLE: u32 = 0x0480_0000; +pub(crate) const CONTACT_ID_ENTRY: usize = 8; + +/// Air band: a separate memory of 100 AM receive-only channels, 108-136 MHz +/// (qdmr's limits). Each 0x20 record: frequency BCD BE in 10 Hz units (4), +/// name 16 ASCII NUL-padded, 12 zero bytes; an empty slot is all 0xFF. A present +/// bitmap and a scan-enable bitmap follow at 0x02BC1020 / 0x02BC1040, and the +/// air-band VFO at 0x02BC1000 is left alone. The anchor radio holds one: slot +/// 1, 108.000 MHz, no name, present and scanned. +pub(crate) const AIR_BASE: u32 = 0x02BC_0000; +pub(crate) const AIR_REC_LEN: usize = 0x20; +pub(crate) const MAX_AIR: usize = 100; +pub(crate) const AIR_BITMAP: u32 = 0x02BC_1020; +pub(crate) const AIR_SCAN_BITMAP: u32 = 0x02BC_1040; +pub(crate) const AIR_BITMAP_BYTES: usize = 13; +pub(crate) const AIR_MIN_MHZ: f64 = 108.0; +pub(crate) const AIR_MAX_MHZ: f64 = 136.0; + +/// One air-band record. +pub(crate) fn encode_air_channel(mhz: f64, name: &str) -> Vec { + let mut rec = vec![0u8; AIR_REC_LEN]; + rec[0..4].copy_from_slice(&freq_encode(mhz)); + rec[4..4 + NAME_CHARS].copy_from_slice(&encode_ascii_field(name, NAME_CHARS)); + rec +} + +/// Name width shared by channels, zones, scan lists and contacts. +pub(crate) const NAME_CHARS: usize = 16; + +// ============================================================ +// Channel record (0x40 bytes) +// ============================================================ +// +// 0x00 RX freq, BCD BE, 10 Hz units 0x14 contact index, u32 LE +// 0x04 TX offset, BCD BE (= RX, simplex) 0x18 radio-ID index +// 0x08 shift(7-6) bw(5-4) pwr(3-2) mode 0x19 squelch mode, bit 4 = CTCSS/DCS +// 0x09 tone enables, low nibble 0x1B scan list (0xFF none) +// 0x0A TX CTCSS index 0x0B RX index 0x1C RX group list (0xFF none) +// 0x0C TX DCS u16 LE 0x0E RX DCS 0x20 colour code +// 0x10 custom CTCSS 0x21 bit 0 = time slot (0 = TS1) +// 0x23 name, 16 ASCII, NUL padded 0x34..0x3F D578-specific tail +// +// Bytes 0x00-0x22 are laid out as on the D890UV; the name and everything after +// it are not. + +pub(crate) const CH_RX: usize = 0x00; +pub(crate) const CH_TX_OFFSET: usize = 0x04; +pub(crate) const CH_MODE: usize = 0x08; +pub(crate) const CH_TONE_EN: usize = 0x09; +pub(crate) const CH_TX_CTCSS: usize = 0x0A; +pub(crate) const CH_RX_CTCSS: usize = 0x0B; +pub(crate) const CH_TX_DCS: usize = 0x0C; +pub(crate) const CH_RX_DCS: usize = 0x0E; +pub(crate) const CH_CONTACT: usize = 0x14; +pub(crate) const CH_SQUELCH_MODE: usize = 0x19; +pub(crate) const CH_SQ_CTCSS_DCS: u8 = 0x10; +pub(crate) const CH_SCAN_LIST: usize = 0x1B; +pub(crate) const CH_COLOR_CODE: usize = 0x20; +pub(crate) const CH_TIME_SLOT: usize = 0x21; +pub(crate) const CH_NAME: usize = 0x23; + +/// CTCSS tone table in the radio's index order. 51 tones; 51 = custom. +/// +/// MEASURED, and different from the D890UV's table: this radio has no 150.0 Hz +/// entry, so index 25 is 151.4. Eleven channels on the anchor radio use indices +/// 25 and up, and every one matches the operator's library only under this +/// table (`scratchpad/anytone_atd578uv/FINDINGS.md`). Reusing the D890's table +/// would shift every tone from 151.4 Hz up by one step. +pub(crate) const CTCSS: &[f64] = &[ + 62.5, 67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5, 94.8, 97.4, 100.0, 103.5, + 107.2, 110.9, 114.8, 118.8, 123.0, 127.3, 131.8, 136.5, 141.3, 146.2, 151.4, 156.7, 159.8, + 162.2, 165.5, 167.9, 171.3, 173.8, 177.3, 179.9, 183.5, 186.2, 189.9, 192.8, 196.6, 199.5, + 203.5, 206.5, 210.7, 218.1, 225.7, 229.1, 233.6, 241.8, 250.3, 254.1, +]; + +/// The CTCSS table index for a tone in Hz, if the radio has it. +pub(crate) fn ctcss_index(hz: f64) -> Option { + CTCSS.iter().position(|&t| (t - hz).abs() < 0.05).map(|i| i as u8) +} + +/// Absolute address of a 0-based channel slot's record. +pub(crate) fn channel_addr(slot0: usize) -> u32 { + CHANNEL_BASE + + (slot0 / CH_PER_BANK) as u32 * BANK_STEP + + ((slot0 % CH_PER_BANK) * CH_REC_LEN) as u32 +} + +/// The record a never-programmed slot starts from: every byte this driver does +/// not encode takes the value the radio itself wrote on the most channels of the +/// anchor dump (the DCS fields' 0x0011, custom CTCSS 0x09CF, "no group list" +/// 0xFF, zeros elsewhere — including 0x3D, whose published default is wrong). +pub(crate) fn channel_record_template() -> [u8; CH_REC_LEN] { + let mut rec = [0u8; CH_REC_LEN]; + rec[CH_TX_CTCSS] = 0x0D; // 100.0 Hz, inert until a tone-enable bit is set + rec[CH_RX_CTCSS] = 0x0D; + rec[CH_TX_DCS] = 0x11; + rec[CH_RX_DCS] = 0x11; + rec[0x10] = 0xCF; + rec[0x11] = 0x09; + rec[CH_SCAN_LIST] = 0xFF; + rec[0x1C] = 0xFF; + rec +} + +/// Fixed-width ASCII name: up to `chars` bytes, NUL padded, folded the same way +/// as the call-sign database (`util::fold_ascii`: Zürich -> Zurich). +pub(crate) fn encode_ascii_field(name: &str, chars: usize) -> Vec { + let mut out = crate::util::fold_ascii(name); + out.resize(chars, 0); + out +} + +/// Inverse of [`encode_ascii_field`]: stop at the first NUL, trim trailing spaces. +pub(crate) fn decode_ascii_field(data: &[u8]) -> String { + data.iter() + .take_while(|&&b| b != 0 && b != 0xFF) + .map(|&b| if (0x20..0x7F).contains(&b) { b as char } else { '?' }) + .collect::() + .trim_end() + .to_string() +} + +/// True for a record nothing has been written to. +#[cfg(test)] +pub(crate) fn is_blank(rec: &[u8]) -> bool { + rec.iter().all(|&b| b == 0xFF) || rec.iter().all(|&b| b == 0x00) +} + +/// Decode a channel record into the family's channel edit — the exact inverse of +/// [`encode_channel_into`] for every field the encoder owns. `None` for a blank +/// record. +#[cfg(test)] +pub(crate) fn decode_channel(rec: &[u8]) -> Option { + if rec.len() < CH_REC_LEN || is_blank(rec) { + return None; + } + let rx_mhz = freq_mhz(&rec[CH_RX..CH_RX + 4]); + let offset = freq_mhz(&rec[CH_TX_OFFSET..CH_TX_OFFSET + 4]); + let m = rec[CH_MODE]; + let tx_mhz = match (m >> 6) & 0x03 { + 1 => rx_mhz + offset, + 2 => rx_mhz - offset, + _ => rx_mhz, + }; + let en = rec[CH_TONE_EN]; + let side = |ctcss_on: bool, dcs_on: bool, c: usize, d: usize| { + if ctcss_on { + CTCSS.get(rec[c] as usize).map(|&hz| AnytoneSubTone::Ctcss(hz)) + } else if dcs_on { + Some(AnytoneSubTone::Dcs(u16::from_le_bytes([rec[d], rec[d + 1]]))) + } else { + None + } + }; + Some(AnytoneChannelEdit { + name: decode_ascii_field(&rec[CH_NAME..CH_NAME + NAME_CHARS]), + rx_mhz, + tx_mhz: (tx_mhz * 100_000.0).round() / 100_000.0, + power: (m >> 2) & 0x03, + wide: (m >> 4) & 0x03 == 1, + mode: m & 0x03, + tone: AnytoneToneEdit { + tx: side(en & 0x04 != 0, en & 0x08 != 0, CH_TX_CTCSS, CH_TX_DCS), + rx: side(en & 0x01 != 0, en & 0x02 != 0, CH_RX_CTCSS, CH_RX_DCS), + }, + color_code: rec[CH_COLOR_CODE], + time_slot: (rec[CH_TIME_SLOT] & 0x01) + 1, + contact_index: u32::from_le_bytes([ + rec[CH_CONTACT], + rec[CH_CONTACT + 1], + rec[CH_CONTACT + 2], + rec[CH_CONTACT + 3], + ]) as u16, + scan_list_index: Some(rec[CH_SCAN_LIST]), + }) +} + +/// Patch a channel edit onto a record (read-modify-write). Only the fields the +/// edit models are written; every other byte — the D578 tail, APRS, encryption, +/// radio-ID and group-list bytes — is left as it was. +/// +/// Unlike the D890 encoder this writes BOTH field groups whatever the mode: the +/// D578 keeps a DMR channel's tone bytes and an FM channel's colour code in the +/// same record, and writing both is what lets a decoded record re-encode to +/// itself byte for byte. Squelch mode is derived from the RX tone, as on the +/// D890: an RX tone with carrier squelch never actually squelches. +pub(crate) fn encode_channel_into(rec: &mut [u8], edit: &AnytoneChannelEdit) -> Result<(), String> { + if rec.len() != CH_REC_LEN { + return Err(format!("channel record is {} bytes, expected {CH_REC_LEN}", rec.len())); + } + rec[CH_RX..CH_RX + 4].copy_from_slice(&freq_encode(edit.rx_mhz)); + let (shift, off) = encode_shift(edit.rx_mhz, edit.tx_mhz); + // A simplex channel carries its own RX frequency in the offset field: all + // 57 simplex channels on the anchor radio do, and none hold zero. The + // direction bits say simplex, so the field is not used to transmit; writing + // the radio's own convention keeps a re-encode byte-identical. + let off = if shift == 0 { freq_encode(edit.rx_mhz) } else { off }; + rec[CH_TX_OFFSET..CH_TX_OFFSET + 4].copy_from_slice(&off); + rec[CH_MODE] = encode_mode_byte(shift, edit.wide, edit.power, edit.mode); + rec[CH_NAME..CH_NAME + NAME_CHARS].copy_from_slice(&encode_ascii_field(&edit.name, NAME_CHARS)); + if let Some(idx) = edit.scan_list_index { + rec[CH_SCAN_LIST] = idx; + } + + let mut en = rec[CH_TONE_EN] & 0xF0; + match &edit.tone.tx { + Some(AnytoneSubTone::Ctcss(hz)) => { + en |= 0x04; + rec[CH_TX_CTCSS] = + ctcss_index(*hz).ok_or_else(|| format!("no CTCSS table entry for {hz} Hz (TX)"))?; + } + Some(AnytoneSubTone::Dcs(code)) => { + en |= 0x08; + rec[CH_TX_DCS..CH_TX_DCS + 2].copy_from_slice(&code.to_le_bytes()); + } + None => {} + } + match &edit.tone.rx { + Some(AnytoneSubTone::Ctcss(hz)) => { + en |= 0x01; + rec[CH_RX_CTCSS] = + ctcss_index(*hz).ok_or_else(|| format!("no CTCSS table entry for {hz} Hz (RX)"))?; + } + Some(AnytoneSubTone::Dcs(code)) => { + en |= 0x02; + rec[CH_RX_DCS..CH_RX_DCS + 2].copy_from_slice(&code.to_le_bytes()); + } + None => {} + } + rec[CH_TONE_EN] = en; + + rec[CH_COLOR_CODE] = edit.color_code.min(15); + rec[CH_TIME_SLOT] = (rec[CH_TIME_SLOT] & !0x01) | (edit.time_slot.saturating_sub(1) & 0x01); + rec[CH_CONTACT..CH_CONTACT + 4].copy_from_slice(&(edit.contact_index as u32).to_le_bytes()); + Ok(()) +} + +/// Set the squelch-mode bit from the channel's RX tone. Kept out of +/// [`encode_channel_into`] so the byte-identical gate can re-encode the radio's +/// own records — most of which carry an RX tone with carrier squelch, as their +/// last programmer left them — without this policy rewriting them. +pub(crate) fn apply_tone_squelch(rec: &mut [u8], edit: &AnytoneChannelEdit) { + let sq = rec[CH_SQUELCH_MODE] & !CH_SQ_CTCSS_DCS; + rec[CH_SQUELCH_MODE] = if edit.tone.rx.is_some() { sq | CH_SQ_CTCSS_DCS } else { sq }; +} + +// ============================================================ +// Zone / scan-list / contact records +// ============================================================ + +/// One zone's member list: 0-based channel indices, 0xFFFF fill to 250 slots. +pub(crate) fn encode_zone_list(members: &[u16]) -> Result, String> { + if members.len() > ZONE_LIST_SLOTS { + return Err(format!("zone has {} channels (record holds {ZONE_LIST_SLOTS})", members.len())); + } + let mut out = vec![0xFFu8; ZONE_LIST_STEP as usize]; + for (i, m) in members.iter().enumerate() { + out[i * 2..i * 2 + 2].copy_from_slice(&m.to_le_bytes()); + } + Ok(out) +} + +/// One zone's name record: 16 ASCII + 16 zero bytes. +pub(crate) fn encode_zone_name(name: &str) -> Vec { + let mut out = encode_ascii_field(name, NAME_CHARS); + out.resize(ZONE_NAME_STEP as usize, 0); + out +} + +/// Absolute address of a 0-based scan list's record. +pub(crate) fn scan_list_addr(i: usize) -> u32 { + SCAN_LIST_BASE + + (i / SCAN_LISTS_PER_BANK) as u32 * BANK_STEP + + (i % SCAN_LISTS_PER_BANK) as u32 * SCAN_LIST_STEP +} + +/// A scan list's owned fields, encoded as the record's first [`SCAN_OWNED_END`] +/// bytes. Priority channels are 0-based slots (`None` = 0xFFFF). +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ScanListFields { + pub name: String, + pub members: Vec, + pub priority_select: u8, + pub priority_1: Option, + pub priority_2: Option, + pub look_back_a: u16, + pub look_back_b: u16, + pub dropout_delay: u16, + pub dwell_time: u16, + pub revert_channel: u8, +} + +/// Write a scan list's owned bytes onto `rec` (0x200), leaving 0x84.. alone. +pub(crate) fn encode_scan_list_into(rec: &mut [u8], s: &ScanListFields) -> Result<(), String> { + if s.members.len() > SCAN_MAX_CHANNELS { + return Err(format!( + "scan list '{}' has {} channels (radio holds {SCAN_MAX_CHANNELS})", + s.name, + s.members.len() + )); + } + rec[0x00] = 0x00; + rec[0x01] = s.priority_select; + rec[0x02..0x04].copy_from_slice(&s.priority_1.unwrap_or(0xFFFF).to_le_bytes()); + rec[0x04..0x06].copy_from_slice(&s.priority_2.unwrap_or(0xFFFF).to_le_bytes()); + rec[0x06..0x08].copy_from_slice(&s.look_back_a.to_le_bytes()); + rec[0x08..0x0A].copy_from_slice(&s.look_back_b.to_le_bytes()); + rec[0x0A..0x0C].copy_from_slice(&s.dropout_delay.to_le_bytes()); + rec[0x0C..0x0E].copy_from_slice(&s.dwell_time.to_le_bytes()); + rec[0x0E] = s.revert_channel; + rec[SCAN_NAME..SCAN_NAME + NAME_CHARS].copy_from_slice(&encode_ascii_field(&s.name, NAME_CHARS)); + rec[SCAN_NAME + NAME_CHARS] = 0x00; + rec[SCAN_MEMBERS..SCAN_OWNED_END].fill(0xFF); + for (i, m) in s.members.iter().enumerate() { + let o = SCAN_MEMBERS + i * 2; + rec[o..o + 2].copy_from_slice(&m.to_le_bytes()); + } + Ok(()) +} + +/// Decode a scan list's owned fields. +#[cfg(test)] +pub(crate) fn decode_scan_list(rec: &[u8]) -> ScanListFields { + let u16_at = |o: usize| u16::from_le_bytes([rec[o], rec[o + 1]]); + let opt = |v: u16| (v != 0xFFFF).then_some(v); + ScanListFields { + name: decode_ascii_field(&rec[SCAN_NAME..SCAN_NAME + NAME_CHARS]), + members: (0..SCAN_MAX_CHANNELS) + .map(|i| u16_at(SCAN_MEMBERS + i * 2)) + .filter(|&m| m != 0xFFFF) + .collect(), + priority_select: rec[0x01], + priority_1: opt(u16_at(0x02)), + priority_2: opt(u16_at(0x04)), + look_back_a: u16_at(0x06), + look_back_b: u16_at(0x08), + dropout_delay: u16_at(0x0A), + dwell_time: u16_at(0x0C), + revert_channel: rec[0x0E], + } +} + +/// Absolute address of a 0-based contact record. +pub(crate) fn contact_addr(i: usize) -> u32 { + CONTACT_BASE + + (i / CONTACTS_PER_BANK) as u32 * CONTACT_BANK_STEP + + ((i % CONTACTS_PER_BANK) * CONTACT_REC_LEN) as u32 +} + +/// One contact record. `call_type` 0 = private, 1 = group. +pub(crate) fn encode_contact(call_type: u8, dmr_id: u32, name: &str) -> Result, String> { + if dmr_id > 16_777_215 { + return Err(format!("DMR ID {dmr_id} is out of range")); + } + let mut rec = vec![0u8; CONTACT_REC_LEN]; + rec[0] = call_type; + rec[CONTACT_NAME..CONTACT_NAME + NAME_CHARS].copy_from_slice(&encode_ascii_field(name, NAME_CHARS)); + rec[CONTACT_DMR_ID..CONTACT_DMR_ID + 4].copy_from_slice(&bcd_be_encode(dmr_id as u64, 4)); + Ok(rec) +} + +/// `(call type, DMR ID, name)` of a contact record. +#[cfg(test)] +pub(crate) fn decode_contact(rec: &[u8]) -> (u8, u32, String) { + ( + rec[0], + bcd_be(&rec[CONTACT_DMR_ID..CONTACT_DMR_ID + 4]) as u32, + decode_ascii_field(&rec[CONTACT_NAME..CONTACT_NAME + NAME_CHARS]), + ) +} + +/// The contact ID table for `(call_type, dmr_id, index)` contacts, sorted, with +/// its 0xFF terminator. +pub(crate) fn encode_contact_id_table(contacts: &[(u8, u32, u32)]) -> Vec { + let mut keyed: Vec<(u32, u32)> = contacts + .iter() + .map(|&(call_type, id, index)| { + let bcd = u32::from_be_bytes( + bcd_be_encode(id as u64, 4).try_into().expect("four BCD bytes"), + ); + ((bcd << 1) | (call_type == 1) as u32, index) + }) + .collect(); + keyed.sort_unstable(); + let mut out = Vec::with_capacity((keyed.len() + 1) * CONTACT_ID_ENTRY); + for (key, index) in keyed { + out.extend_from_slice(&key.to_le_bytes()); + out.extend_from_slice(&index.to_le_bytes()); + } + out.extend_from_slice(&[0xFF; CONTACT_ID_ENTRY]); + out +} + +/// The family's bitmap builders: set-means-present, and the contact bitmap's +/// inverted form (a clear bit = present). +pub(crate) use crate::radios::anytone_atd890uv::program::{contact_present_bitmap, present_bitmap}; + +/// Indices of the set bits in a present bitmap. +pub(crate) fn bitmap_indices(bm: &[u8], n: usize) -> Vec { + (0..n.min(bm.len() * 8)).filter(|&i| bm[i / 8] >> (i % 8) & 1 == 1).collect() +} + +/// The contact bitmap for `n` contacts (inverted: the first `n` bits clear). +pub(crate) fn contact_bitmap(n: usize) -> Vec { + contact_present_bitmap(n, CONTACT_BITMAP_BYTES) +} + +// ============================================================ +// Flash halves — the radio's real write unit +// ============================================================ +// +// Every 256 KB block of the codeplug is two 128 KB halves, each ending in the +// marker `22 33 44 55 FF FF FF FF 55 55 AA AA` at +0x1FFF4. The codeplug lives +// in the first; the second holds the first's PREVIOUS contents — on a write the +// radio copies the old half there, then rebuilds the first half from what the +// session sent and nothing else. MEASURED on the anchor radio (2026-10-14): a +// settings write of the 16 KB window at 0x02500000 erased the GPS-roaming table +// at 0x02504000, which no write addressed; a repair write then left the second +// half byte-identical to the first half as it stood before the repair. The +// second half is the radio's; nothing here reads or writes it. +// +// So a session that writes a window must also write every other window of the +// same half that holds data, unchanged. [`with_carried_windows`] adds those. +// +// The marker frame is the radio's, and it rewrites it itself (it cleared the +// settings half's `22334455` on its own while saving the operator's zone +// browsing). A session that carried the marker windows unchanged left two of +// its thirteen halves' new data in the INACTIVE half — the radio kept showing +// the old channels — where the same program without them had committed every +// half live, twice. So the final 16-byte frame of a half is never sent, and a +// window whose only data is that marker is not carried ([`sendable`]). + +/// A flash half: the unit the radio rebuilds on a write. +pub(crate) const FLASH_HALF: u32 = 0x0002_0000; +/// The final frame of every half, where the radio keeps its marker. +pub(crate) const MARKER_FRAME: usize = 0x10; + +/// The bytes of a block starting at `base` this driver may send: all of them, +/// except a half's final frame when the block reaches the end of its half. +pub(crate) fn sendable(base: u32, data: &[u8]) -> &[u8] { + let half_end = half_of(base) + FLASH_HALF; + if base + data.len() as u32 == half_end && data.len() >= MARKER_FRAME { + &data[..data.len() - MARKER_FRAME] + } else { + data + } +} + +/// Write one block, never its half's marker frame. +pub(crate) fn write_sendable(p: &mut dyn SerialPort, addr: u32, data: &[u8]) -> Result<(), String> { + write_block(p, addr, sendable(addr, data)) +} + +/// Write every planned window (marker frames left out); the bases written. +pub(crate) fn commit_windows(p: &mut dyn SerialPort, plans: &[BankPlan]) -> Result, String> { + for plan in plans { + write_sendable(p, plan.base, &plan.patched)?; + } + Ok(plans.iter().map(|p| p.base).collect()) +} + +/// The plans as backup / expected-image blocks, marker frames left out, so a +/// Verify never compares the radio's own housekeeping. +pub(crate) fn plan_blocks(plans: &[BankPlan], patched: bool) -> Vec<(u32, Vec)> { + plans + .iter() + .map(|p| { + let data = if patched { &p.patched } else { &p.original }; + (p.base, sendable(p.base, data).to_vec()) + }) + .collect() +} + +pub(crate) fn half_of(addr: u32) -> u32 { + addr & !(FLASH_HALF - 1) +} + +/// Every window of the halves `plans` touch, for the session to read. +pub(crate) fn windows_of_touched_halves(plans: &[BankPlan]) -> BTreeSet { + plans + .iter() + .map(|p| half_of(p.base)) + .flat_map(|h| (0..FLASH_HALF / WRITE_WINDOW as u32).map(move |i| h + i * WRITE_WINDOW as u32)) + .collect() +} + +/// `plans` plus, for every half they touch, each other window of that half that +/// holds data (anything but erased 0xFF), to be rewritten unchanged so the +/// radio's rebuild of the half keeps it. `reads` must hold every window of +/// every touched half ([`windows_of_touched_halves`]). +pub(crate) fn with_carried_windows( + mut plans: Vec, + reads: &BTreeMap>, +) -> Result, String> { + let planned: BTreeSet = plans.iter().map(|p| p.base).collect(); + for w in windows_of_touched_halves(&plans) { + if planned.contains(&w) { + continue; + } + let data = reads + .get(&w) + .ok_or_else(|| format!("internal: window 0x{w:08X} of a written half was not read"))?; + if sendable(w, data).iter().any(|&b| b != 0xFF) { + plans.push(BankPlan { base: w, original: data.clone(), patched: data.clone() }); + } + } + plans.sort_by_key(|p| p.base); + Ok(plans) +} + +// ============================================================ +// Write-window guard +// ============================================================ + +/// The 0x4000 windows a D578 program, settings write or restore owns, by name. +/// Anything else is refused unless it is carried unchanged beside an owned +/// window ([`with_carried_windows`]) — a backup from another radio, or a bug, +/// cannot reach the call-sign database or the VFOs' own regions this way. +pub(crate) fn writable_window_name(addr: u32) -> Option<&'static str> { + let win = WRITE_WINDOW as u32; + if !addr.is_multiple_of(win) { + return None; + } + let run = |base: u32, n: u32| addr >= base && addr < base + n * win; + if addr >= CHANNEL_BASE + && (addr - CHANNEL_BASE).is_multiple_of(BANK_STEP) + && (addr - CHANNEL_BASE) / BANK_STEP < NUM_BANKS as u32 + { + return Some("channel bank"); + } + if run(ZONE_LIST_BASE, (MAX_ZONES * ZONE_LIST_STEP as usize).div_ceil(WRITE_WINDOW) as u32) { + return Some("zone member lists"); + } + if addr >= SCAN_LIST_BASE && (addr - SCAN_LIST_BASE).is_multiple_of(BANK_STEP) { + let bank = (addr - SCAN_LIST_BASE) / BANK_STEP; + if (bank as usize) < MAX_SCAN_LISTS.div_ceil(SCAN_LISTS_PER_BANK) { + return Some("scan lists"); + } + } + if addr == ZONE_NAME_BASE { + return Some("zone names"); + } + if addr == CHANNEL_BITMAP_BASE & !(win - 1) { + return Some("present bitmaps"); + } + if addr == settings::SETTINGS_WINDOW { + return Some("settings"); + } + if addr == AIR_BASE { + return Some("air band"); + } + if addr == settings::RADIO_ID_BASE { + return Some("radio IDs"); + } + for b in 0..MAX_CONTACTS.div_ceil(CONTACTS_PER_BANK) as u32 { + let base = CONTACT_BASE + b * CONTACT_BANK_STEP; + let windows = (CONTACTS_PER_BANK * CONTACT_REC_LEN).div_ceil(WRITE_WINDOW) as u32; + if run(base, windows) { + return Some("contacts"); + } + } + if run(CONTACT_INDEX_BASE, (MAX_CONTACTS * 4).div_ceil(WRITE_WINDOW) as u32) { + return Some("contact index"); + } + if addr == CONTACT_BITMAP_BASE { + return Some("contact bitmap"); + } + if run(CONTACT_ID_TABLE, ((MAX_CONTACTS + 1) * CONTACT_ID_ENTRY).div_ceil(WRITE_WINDOW) as u32) { + return Some("contact ID table"); + } + None +} + +/// Refuse a restore image unless every block is whole aligned windows in flash +/// halves this driver writes: a window it owns, or a window carried unchanged +/// because it shares a half with one ([`with_carried_windows`]). +pub(crate) fn check_restore_blocks(blocks: &[(u32, Vec)]) -> Result<(), String> { + let mut owned_halves = BTreeSet::new(); + for (addr, data) in blocks { + for w in 0..data.len().div_ceil(WRITE_WINDOW) { + let a = addr + (w * WRITE_WINDOW) as u32; + if writable_window_name(a).is_some() { + owned_halves.insert(half_of(a)); + } + } + } + for (addr, data) in blocks { + // A window that ends its half is stored without the marker frame. + let len = if data.len() % WRITE_WINDOW == WRITE_WINDOW - MARKER_FRAME + && addr + data.len() as u32 + MARKER_FRAME as u32 == half_of(*addr) + FLASH_HALF + { + data.len() + MARKER_FRAME + } else { + data.len() + }; + if !addr.is_multiple_of(WRITE_WINDOW as u32) || len % WRITE_WINDOW != 0 { + return Err(format!( + "block 0x{addr:08X}+0x{:X} is not 0x4000-aligned — not a program backup", + data.len() + )); + } + for w in 0..len / WRITE_WINDOW { + let a = addr + (w * WRITE_WINDOW) as u32; + if writable_window_name(a).is_none() && !owned_halves.contains(&half_of(a)) { + return Err(format!( + "block 0x{a:08X} is not a region this app writes on a D578UV — refusing to \ + restore it" + )); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod hw_ladder; diff --git a/src-tauri/src/radios/anytone_atd578uv/program.rs b/src-tauri/src/radios/anytone_atd578uv/program.rs new file mode 100644 index 0000000..0cd248e --- /dev/null +++ b/src-tauri/src/radios/anytone_atd578uv/program.rs @@ -0,0 +1,998 @@ +//! Full-codeplug programming for the AT-D578UV: channels, zones, scan lists and +//! DMR contacts as a FULL REPLACE, in one PC-mode session with one END. +//! +//! The plan comes from the family planner (`anytone_atd890uv::program`), given +//! this radio's limits and CTCSS table; everything after the plan is this +//! radio's map. The session is split so all of its arithmetic is pure: +//! +//! 1. read the present-bitmap window — it says what the radio holds now; +//! 2. [`windows_to_read`] — every 0x4000 window the program touches, sized by +//! the larger of the plan and what is already on the radio, so stale records +//! past the new set are cleared rather than left behind; +//! 3. read those windows, then [`patches_for`] computes every byte to write; +//! 4. back up the originals and save the expected image BEFORE any write, +//! write the changed windows, END once. +//! +//! The write unit is the whole 0x4000 window, read-modify-write. That is the +//! D890UV's hardware-proven safe unit (an isolated record write erased its flash +//! neighbours there); the D578 has not been measured either way, so it gets the +//! conservative unit. A channel bank's 128 records and their 128 extension +//! records fill exactly one window. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use serialport::SerialPort; + +use crate::radios::anytone_atd890uv::program::{plan_program_for, AnytoneProgramPlan, PlanLimits}; +use crate::radios::anytone_atd890uv::{ + end_session, open_port, plans_from_pre_read, read_block, + serialize_backup, BankPlan, RegionPatch, WRITE_WINDOW, +}; +use crate::commands::export::ExpandedChannel; +use crate::radios::driver::{ + CodeplugPayload, CodeplugPreview, CodeplugProgrammer, ProgramReport, SkippedChannel, +}; + +use super::*; + +pub(crate) const D578_LIMITS: PlanLimits = PlanLimits { + model: "AT-D578UV", + ctcss: CTCSS, + // qdmr and dmr-tools both store an inverted DCS code as code + 512. Not + // measured on the anchor radio, whose channels hold no inverted DCS. + dcs_invert: Some(512), + max_channels: MAX_CHANNELS, + max_zones: MAX_ZONES, + zone_max_channels: ZONE_MAX_CHANNELS, + max_scan_lists: MAX_SCAN_LISTS, + scan_max_channels: SCAN_MAX_CHANNELS, + max_contacts: MAX_CONTACTS, +}; + +/// One air-band memory: an AM channel between 108 and 136 MHz. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AirChannel { + pub mhz: f64, + pub name: String, +} + +/// The D578's plan: the family plan for the channel memories, plus the AM +/// channels that go to the separate air-band memory. +pub(crate) struct D578Plan { + pub base: AnytoneProgramPlan, + pub air: Vec, +} + +fn is_am(ec: &ExpandedChannel) -> bool { + ec.channel.mode.as_deref().is_some_and(|m| m.eq_ignore_ascii_case("AM")) +} + +fn in_air_band(mhz: f64) -> bool { + (AIR_MIN_MHZ..=AIR_MAX_MHZ).contains(&mhz) +} + +/// Plan a D578 program. AM channels in 108-136 MHz become air-band memories; +/// AM anywhere else is skipped — the channel memories hold FM and DMR only, and +/// the family planner would otherwise program an AM row as FM. Anything else in +/// 108-136 is skipped too: that span is in `rx_bands` only for the air band, +/// and the channel memories do not receive there. +pub(crate) fn plan_d578(payload: &CodeplugPayload) -> Result { + let (am, rest): (Vec, Vec) = + payload.channels.iter().cloned().partition(is_am); + let (in_air, rest): (Vec, Vec) = + rest.into_iter().partition(|ec| in_air_band(ec.channel.rx_freq) && ec.channel.rx_freq < AIR_MAX_MHZ); + let filtered = CodeplugPayload { + model: payload.model, + groups: payload.groups, + channels: &rest, + scan_lists: payload.scan_lists, + scan_list_overrides: payload.scan_list_overrides, + contact_users: payload.contact_users, + }; + let mut base = plan_program_for(&filtered, &D578_LIMITS)?; + for ec in &in_air { + let c = &ec.channel; + base.skipped.push(SkippedChannel { + name: c.name_long.clone().or_else(|| c.name_short.clone()).unwrap_or_default(), + reason: format!( + "{} at {:.4} MHz — the {} receives only AM between {AIR_MIN_MHZ:.0} and {AIR_MAX_MHZ:.0} MHz", + c.mode.as_deref().unwrap_or("FM"), + c.rx_freq, + base.radio + ), + }); + } + + let mut air: Vec = Vec::new(); + let mut seen = BTreeSet::new(); + for ec in &am { + let c = &ec.channel; + let name = c + .name_long + .clone() + .or_else(|| c.name_short.clone()) + .or_else(|| c.callsign.clone()) + .unwrap_or_else(|| format!("AIR {:.3}", c.rx_freq)); + if !in_air_band(c.rx_freq) { + base.skipped.push(SkippedChannel { + name, + reason: format!( + "AM at {:.4} MHz — the {} receives AM only on its air band ({AIR_MIN_MHZ:.0}-{AIR_MAX_MHZ:.0} MHz)", + c.rx_freq, base.radio + ), + }); + continue; + } + if !seen.insert(c.id) { + continue; // one row per channel; AM has no talkgroup expansion + } + if air.len() == MAX_AIR { + base.warnings.push(format!( + "{name}: the air-band memory holds {MAX_AIR} channels — left out" + )); + continue; + } + air.push(AirChannel { mhz: c.rx_freq, name: crate::util::truncate(&name, NAME_CHARS) }); + } + if !air.is_empty() { + base.warnings.push(format!( + "{} AM channel{} between {AIR_MIN_MHZ:.0} and {AIR_MAX_MHZ:.0} MHz go{} into the radio's \ + separate air-band memory, not the channel list", + air.len(), + if air.len() == 1 { "" } else { "s" }, + if air.len() == 1 { "es" } else { "" }, + )); + } + Ok(D578Plan { base, air }) +} + +/// File-name prefix of a D578 program backup and its expected image. Distinct +/// from the D890's `anytone-program-`, so neither radio's Verify or Restore can +/// be pointed at the other's file — the maps overlap (the D578's zone lists sit +/// at the D890's first channel bank), so an address check alone would not +/// catch it. +pub(crate) const PROGRAM_PREFIX: &str = "atd578uv-program-"; + +/// The 0x4000 window holding every present bitmap this program rewrites +/// (channels 0x024C1500, zones 0x024C1300, scan lists 0x024C1340). +pub(crate) const BITMAP_WINDOW: u32 = 0x024C_0000; + +fn window_of(addr: u32) -> u32 { + addr & !(WRITE_WINDOW as u32 - 1) +} + +/// What the radio holds before the program, read off its present bitmaps. +#[derive(Debug, Default, PartialEq)] +pub(crate) struct OnRadio { + pub channels: Vec, + pub zones: Vec, + pub scan_lists: Vec, +} + +impl OnRadio { + pub(crate) fn from_bitmap_window(w: &[u8]) -> Self { + let at = |addr: u32, bytes: usize, n: usize| { + let o = (addr - BITMAP_WINDOW) as usize; + bitmap_indices(&w[o..o + bytes], n) + }; + OnRadio { + channels: at(CHANNEL_BITMAP_BASE, CHANNEL_BITMAP_BYTES, MAX_CHANNELS), + zones: at(ZONE_BITMAP_BASE, ZONE_BITMAP_BYTES, MAX_ZONES), + scan_lists: at(SCAN_BITMAP_BASE, SCAN_BITMAP_BYTES, MAX_SCAN_LISTS), + } + } +} + +/// How many contact records sit on the radio: the index list's entries before +/// its first 0xFFFFFFFF. +fn contact_count(index_list: &[u8]) -> usize { + index_list + .chunks(4) + .take_while(|c| c != &[0xFF; 4]) + .count() +} + +/// Every window the program reads (and may write), given the plan, what the +/// bitmaps say is on the radio, and the old contact count. +pub(crate) fn windows_to_read( + plan: &AnytoneProgramPlan, + air: &[AirChannel], + on: &OnRadio, + old_contacts: usize, +) -> BTreeSet { + let mut w = BTreeSet::new(); + w.insert(BITMAP_WINDOW); + // The air-band memory is written only when the codeplug has air channels: + // a codeplug without any leaves the radio's own alone. + if !air.is_empty() { + w.insert(AIR_BASE); + } + // Channel banks: every bank holding a planned slot or a present one. + for slot0 in (0..plan.channels.len()).chain(on.channels.iter().copied()) { + w.insert(window_of(channel_addr(slot0))); + } + // Zones: names are one window; lists run contiguously. + let zones = plan.zones.len().max(on.zones.iter().max().map_or(0, |z| z + 1)); + if zones > 0 { + w.insert(ZONE_NAME_BASE); + for z in 0..zones { + w.insert(window_of(ZONE_LIST_BASE + z as u32 * ZONE_LIST_STEP)); + } + } + let lists = plan.scan_lists.len().max(on.scan_lists.iter().max().map_or(0, |s| s + 1)); + for i in 0..lists { + w.insert(window_of(scan_list_addr(i))); + } + // Contacts: records (a record may straddle two windows), the index list, + // and the inverted present bitmap. + let contacts = plan.contacts.len().max(old_contacts); + for i in 0..contacts { + let a = contact_addr(i); + w.insert(window_of(a)); + w.insert(window_of(a + CONTACT_REC_LEN as u32 - 1)); + } + for i in 0..contacts.max(1) { + w.insert(window_of(CONTACT_INDEX_BASE + i as u32 * 4)); + } + for i in 0..=contacts { + w.insert(window_of(CONTACT_ID_TABLE + (i * CONTACT_ID_ENTRY) as u32)); + w.insert(window_of(CONTACT_ID_TABLE + ((i + 1) * CONTACT_ID_ENTRY) as u32 - 1)); + } + w.insert(CONTACT_BITMAP_BASE); + w +} + +/// What a program clears, for the report. +#[derive(Debug, Default, PartialEq)] +pub(crate) struct Cleared { + pub slots: usize, + pub zones: usize, + pub scan_lists: usize, + pub contacts: usize, +} + +/// Every byte range the program writes, computed from the plan and the windows +/// read in phase 1. Pure. +pub(crate) fn patches_for( + plan: &AnytoneProgramPlan, + air: &[AirChannel], + on: &OnRadio, + old_contacts: usize, + windows: &BTreeMap>, +) -> Result<(Vec, Cleared), String> { + let read_at = |addr: u32, len: usize| -> Result, String> { + let base = window_of(addr); + let w = windows + .get(&base) + .ok_or_else(|| format!("internal: window 0x{base:08X} was not read"))?; + let o = (addr - base) as usize; + Ok(w[o..o + len].to_vec()) + }; + let mut patches = Vec::new(); + let mut cleared = Cleared::default(); + + // ---- Channels. A slot that held a channel is edited in place, so every + // byte the plan does not model (APRS, encryption, roaming, the D578 tail) + // survives; a slot that was empty starts from the measured template. + let present: BTreeSet = on.channels.iter().copied().collect(); + for (slot0, pc) in plan.channels.iter().enumerate() { + let addr = channel_addr(slot0); + let mut rec = if present.contains(&slot0) { + read_at(addr, CH_REC_LEN)? + } else { + channel_record_template().to_vec() + }; + encode_channel_into(&mut rec, &pc.edit)?; + apply_tone_squelch(&mut rec, &pc.edit); + patches.push(RegionPatch { addr, data: rec }); + } + for &slot0 in on.channels.iter().filter(|&&s| s >= plan.channels.len()) { + patches.push(RegionPatch { addr: channel_addr(slot0), data: vec![0xFF; CH_REC_LEN] }); + cleared.slots += 1; + } + + // ---- Zones. + let old_zone_end = on.zones.iter().max().map_or(0, |z| z + 1); + for (z, zone) in plan.zones.iter().enumerate() { + patches.push(RegionPatch { + addr: ZONE_NAME_BASE + z as u32 * ZONE_NAME_STEP, + data: encode_zone_name(&zone.name), + }); + patches.push(RegionPatch { + addr: ZONE_LIST_BASE + z as u32 * ZONE_LIST_STEP, + data: encode_zone_list(&zone.member_slots)?, + }); + } + for z in plan.zones.len()..old_zone_end { + patches.push(RegionPatch { + addr: ZONE_NAME_BASE + z as u32 * ZONE_NAME_STEP, + data: vec![0u8; ZONE_NAME_STEP as usize], + }); + patches.push(RegionPatch { + addr: ZONE_LIST_BASE + z as u32 * ZONE_LIST_STEP, + data: vec![0xFF; ZONE_LIST_STEP as usize], + }); + } + cleared.zones = on.zones.iter().filter(|&&z| z >= plan.zones.len()).count(); + + // ---- Scan lists: owned bytes onto the existing record, tail preserved. + let old_scan_end = on.scan_lists.iter().max().map_or(0, |s| s + 1); + for (i, sl) in plan.scan_lists.iter().enumerate() { + let addr = scan_list_addr(i); + let mut rec = read_at(addr, SCAN_LIST_STEP as usize)?; + let s = &sl.settings; + encode_scan_list_into( + &mut rec, + &ScanListFields { + name: sl.name.clone(), + members: sl.member_slots.clone(), + priority_select: s.priority_select, + priority_1: s.priority_slot_1, + priority_2: s.priority_slot_2, + look_back_a: s.look_back_a, + look_back_b: s.look_back_b, + dropout_delay: s.dropout_delay, + dwell_time: s.dwell_time, + revert_channel: s.revert_channel, + }, + )?; + patches.push(RegionPatch { addr, data: rec }); + } + for i in plan.scan_lists.len()..old_scan_end { + patches.push(RegionPatch { addr: scan_list_addr(i), data: vec![0xFF; SCAN_LIST_STEP as usize] }); + } + cleared.scan_lists = on.scan_lists.iter().filter(|&&s| s >= plan.scan_lists.len()).count(); + + // ---- Contacts: records at their planned index, stale ones cleared, the + // index list rewritten 0..N with 0xFF fill, and the inverted bitmap. + for c in &plan.contacts { + patches.push(RegionPatch { + addr: contact_addr(c.index as usize), + data: encode_contact(c.call_type, c.tg_number, &c.name)?, + }); + } + for i in plan.contacts.len()..old_contacts { + patches.push(RegionPatch { addr: contact_addr(i), data: vec![0xFF; CONTACT_REC_LEN] }); + } + cleared.contacts = old_contacts.saturating_sub(plan.contacts.len()); + let index_len = plan.contacts.len().max(old_contacts).max(1) * 4; + let mut index = vec![0xFFu8; index_len]; + for i in 0..plan.contacts.len() { + index[i * 4..i * 4 + 4].copy_from_slice(&(i as u32).to_le_bytes()); + } + patches.push(RegionPatch { addr: CONTACT_INDEX_BASE, data: index }); + patches.push(RegionPatch { addr: CONTACT_BITMAP_BASE, data: contact_bitmap(plan.contacts.len()) }); + // The ID table the radio names talkgroups from, rebuilt for the new bank and + // 0xFF past its terminator over every stale entry the old table had. + let mut table = encode_contact_id_table( + &plan.contacts.iter().map(|c| (c.call_type, c.tg_number, c.index as u32)).collect::>(), + ); + table.resize(table.len().max((old_contacts + 1) * CONTACT_ID_ENTRY), 0xFF); + patches.push(RegionPatch { addr: CONTACT_ID_TABLE, data: table }); + + // ---- Air band: every slot rewritten (programmed or cleared), with its + // present and scan bitmaps; every programmed air channel is scanned. + if !air.is_empty() { + let mut recs = Vec::with_capacity(MAX_AIR * AIR_REC_LEN); + for a in air { + recs.extend(encode_air_channel(a.mhz, &a.name)); + } + recs.resize(MAX_AIR * AIR_REC_LEN, 0xFF); + patches.push(RegionPatch { addr: AIR_BASE, data: recs }); + patches.push(RegionPatch { addr: AIR_BITMAP, data: present_bitmap(air.len(), AIR_BITMAP_BYTES) }); + patches.push(RegionPatch { addr: AIR_SCAN_BITMAP, data: present_bitmap(air.len(), AIR_BITMAP_BYTES) }); + } + + // ---- Present bitmaps: written whole, so a stale bit from an earlier, + // longer codeplug cannot leave a phantom channel, zone or list. + patches.push(RegionPatch { + addr: CHANNEL_BITMAP_BASE, + data: present_bitmap(plan.channels.len(), CHANNEL_BITMAP_BYTES), + }); + patches.push(RegionPatch { + addr: ZONE_BITMAP_BASE, + data: present_bitmap(plan.zones.len(), ZONE_BITMAP_BYTES), + }); + patches.push(RegionPatch { + addr: SCAN_BITMAP_BASE, + data: present_bitmap(plan.scan_lists.len(), SCAN_BITMAP_BYTES), + }); + + for p in &patches { + for a in [window_of(p.addr), window_of(p.addr + p.data.len() as u32 - 1)] { + if writable_window_name(a).is_none() { + return Err(format!("internal: refusing to write window 0x{a:08X}")); + } + } + } + Ok((patches, cleared)) +} + +/// Read every window in `addrs`, in address order. +fn read_windows( + p: &mut dyn SerialPort, + addrs: &BTreeSet, +) -> Result>, String> { + let mut out = BTreeMap::new(); + for &a in addrs { + out.insert(a, read_block(p, a, WRITE_WINDOW)?); + } + Ok(out) +} + +/// The hardware session: phase-1 reads, backup + expected image, write, END. +fn run_program( + port: &str, + d578: &D578Plan, + backup_path: &Path, + expected_path: &Path, +) -> Result { + let (plan, air) = (&d578.base, &d578.air[..]); + let mut p = open_port(port)?; + let _ident = enter(&mut *p)?; + + // ---- Phase 1: reads only. Any error ends the session with the radio + // untouched. + let phase1 = (|| -> Result<(Vec, Cleared), String> { + let bitmap = read_block(&mut *p, BITMAP_WINDOW, WRITE_WINDOW)?; + let on = OnRadio::from_bitmap_window(&bitmap); + let index_head = read_block(&mut *p, CONTACT_INDEX_BASE, WRITE_WINDOW)?; + let mut old_contacts = contact_count(&index_head); + if old_contacts == WRITE_WINDOW / 4 { + // The list runs past its first window: read on until it ends. + let rest = read_block(&mut *p, CONTACT_INDEX_BASE + WRITE_WINDOW as u32, MAX_CONTACTS * 4 - WRITE_WINDOW)?; + old_contacts += contact_count(&rest); + } + let mut want = windows_to_read(plan, air, &on, old_contacts); + want.remove(&BITMAP_WINDOW); + let mut windows = read_windows(&mut *p, &want)?; + windows.insert(BITMAP_WINDOW, bitmap); + let (patches, cleared) = patches_for(plan, air, &on, old_contacts, &windows)?; + let plans = plans_from_pre_read(&windows, &patches)?; + // Read the rest of every half a write lands in, and carry its data. + let rest: BTreeSet = windows_of_touched_halves(&plans) + .into_iter() + .filter(|w| !windows.contains_key(w)) + .collect(); + windows.extend(read_windows(&mut *p, &rest)?); + Ok((with_carried_windows(plans, &windows)?, cleared)) + })(); + let (bank_plans, cleared) = match phase1 { + Ok(v) => v, + Err(e) => { + let _ = end_session(&mut *p); + return Err(e); + } + }; + + // ---- Backup (originals) and expected image (patched) before any write. + for (path, bytes) in [ + (backup_path, serialize_backup(&plan_blocks(&bank_plans, false))), + (expected_path, serialize_backup(&plan_blocks(&bank_plans, true))), + ] { + if let Err(e) = std::fs::write(path, bytes) { + let _ = end_session(&mut *p); + return Err(format!("could not write {}: {e}", path.display())); + } + } + + // ---- Phase 2: write every changed window, then END once. A write error + // deliberately does NOT end the session — END would commit a half-written + // image — and the message names the backup to restore from. + let written = super::commit_windows(&mut *p, &bank_plans).map_err(|e| { + crate::radios::driver::with_restore_hint( + e, + backup_path, + "Power-cycle the radio, rescan the port, then use \"Restore backup\" in the Program \ + dialog.", + ) + })?; + let _ = end_session(&mut *p); + + Ok(ProgramReport { + channels_written: plan.channels.len(), + slots_cleared: cleared.slots, + zones_written: plan.zones.len(), + zones_cleared: cleared.zones, + scan_lists_written: plan.scan_lists.len(), + scan_lists_cleared: cleared.scan_lists, + contacts_written: plan.contacts.len(), + contacts_cleared: cleared.contacts, + windows_written: written.iter().map(|a| format!("0x{a:08X}")).collect(), + backup_path: backup_path.to_string_lossy().to_string(), + expected_path: expected_path.to_string_lossy().to_string(), + warnings: plan.warnings.clone(), + note: "Written and committed — the radio reboots and drops off USB. Reconnect it, \ + rescan, reselect the port, then run Verify against the expected image." + .to_string(), + verified: None, + channels: Vec::new(), + skipped: Vec::new(), + }) +} + +impl CodeplugProgrammer for AnytoneAtd578uv { + fn preview(&self, payload: &CodeplugPayload) -> Result { + Ok(plan_d578(payload)?.base.preview()) + } + + fn program( + &self, + port: &str, + payload: &CodeplugPayload, + backup_dir: &Path, + ) -> Result { + let plan = plan_d578(payload)?; + if plan.base.channels.is_empty() { + return Err( + "this codeplug has no programmable channels — add channel lists (or check the \ + preview's skipped reasons)" + .to_string(), + ); + } + std::fs::create_dir_all(backup_dir).map_err(|e| e.to_string())?; + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let backup_path = backup_dir.join(format!("{PROGRAM_PREFIX}{stamp}.bin")); + let expected_path = backup_dir.join(format!("{PROGRAM_PREFIX}{stamp}.expected.bin")); + run_program(port, &plan, &backup_path, &expected_path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::radios::anytone_atd890uv::parse_dump; + use crate::radios::anytone_atd890uv::program::{PlannedChannel, PlannedScanList, PlannedZone}; + use crate::radios::anytone_atd890uv::ScanListRecordSettings; + + const FIXTURE: &[u8] = include_bytes!("testdata/d578_codeplug_records.bin"); + + /// A simulated radio: the anchor dump's records laid into 0x4000 windows, + /// 0xFF wherever the dump did not reach (which is what unwritten flash reads). + fn radio() -> BTreeMap> { + let mut windows: BTreeMap> = BTreeMap::new(); + for (addr, data) in parse_dump(FIXTURE).unwrap() { + for (i, b) in data.iter().enumerate() { + let a = addr + i as u32; + let w = windows.entry(window_of(a)).or_insert_with(|| vec![0xFF; WRITE_WINDOW]); + w[(a - window_of(a)) as usize] = *b; + } + } + windows + } + + fn windows_for(radio: &BTreeMap>, want: &BTreeSet) -> BTreeMap> { + want.iter() + .map(|&a| (a, radio.get(&a).cloned().unwrap_or_else(|| vec![0xFF; WRITE_WINDOW]))) + .collect() + } + + /// Apply patches to the simulated radio, as the write would. + fn apply(radio: &mut BTreeMap>, plans: &[BankPlan]) { + for p in plans { + radio.insert(p.base, p.patched.clone()); + } + } + + fn read(radio: &BTreeMap>, addr: u32, len: usize) -> Vec { + let w = &radio[&window_of(addr)]; + let o = (addr - window_of(addr)) as usize; + w[o..o + len].to_vec() + } + + /// The plan the radio itself would produce: its own channels, zones and scan + /// lists, decoded off the fixture. + fn plan_from_radio(radio: &BTreeMap>) -> AnytoneProgramPlan { + let on = OnRadio::from_bitmap_window(&radio[&BITMAP_WINDOW]); + let channels = on + .channels + .iter() + .enumerate() + .map(|(i, &slot0)| PlannedChannel { + slot: i + 1, + edit: decode_channel(&read(radio, channel_addr(slot0), CH_REC_LEN)).unwrap(), + }) + .collect(); + let zones = on + .zones + .iter() + .map(|&z| { + let list = read(radio, ZONE_LIST_BASE + z as u32 * ZONE_LIST_STEP, 500); + PlannedZone { + name: decode_ascii_field(&read(radio, ZONE_NAME_BASE + z as u32 * 0x20, 16)), + member_slots: list + .chunks(2) + .map(|p| u16::from_le_bytes([p[0], p[1]])) + .take_while(|&m| m != 0xFFFF) + .collect(), + } + }) + .collect(); + let scan_lists = on + .scan_lists + .iter() + .map(|&i| { + let f = decode_scan_list(&read(radio, scan_list_addr(i), 0x200)); + PlannedScanList { + name: f.name, + member_slots: f.members, + settings: ScanListRecordSettings { + priority_select: f.priority_select, + priority_slot_1: f.priority_1, + priority_slot_2: f.priority_2, + look_back_a: f.look_back_a, + look_back_b: f.look_back_b, + dropout_delay: f.dropout_delay, + dwell_time: f.dwell_time, + revert_channel: f.revert_channel, + }, + } + }) + .collect(); + AnytoneProgramPlan { + radio: "AnyTone AT-D578UV".into(), + channels, + zones, + scan_lists, + contacts: Vec::new(), + skipped: Vec::new(), + warnings: Vec::new(), + } + } + + /// Program a plan into the simulated radio; returns the bank plans written. + fn program(radio: &mut BTreeMap>, plan: &AnytoneProgramPlan) -> (Vec, Cleared) { + let on = OnRadio::from_bitmap_window(&radio[&BITMAP_WINDOW]); + let want = windows_to_read(plan, &[], &on, 0); + let windows = windows_for(radio, &want); + let (patches, cleared) = patches_for(plan, &[], &on, 0, &windows).unwrap(); + let plans = plans_from_pre_read(&windows, &patches).unwrap(); + apply(radio, &plans); + (plans, cleared) + } + + /// Packing the radio's own 392 channels into slots 1..392 (they sit at + /// scattered slots up to 413 on the radio) and reading the result back gives + /// every channel, zone and scan list exactly as the plan said, with the + /// stale slots past 392 cleared and the bitmaps rewritten to match. + #[test] + fn the_radios_own_codeplug_programs_and_reads_back() { + let mut radio = radio(); + let plan = plan_from_radio(&radio); + assert_eq!(plan.channels.len(), 392); + let before = OnRadio::from_bitmap_window(&radio[&BITMAP_WINDOW]); + let stale = before.channels.iter().filter(|&&s| s >= 392).count(); + assert!(stale > 0, "the fixture should have channels past slot 392"); + let (_, cleared) = program(&mut radio, &plan); + + let on = OnRadio::from_bitmap_window(&radio[&BITMAP_WINDOW]); + assert_eq!(on.channels, (0..392).collect::>()); + assert_eq!(on.zones, (0..21).collect::>()); + assert_eq!(on.scan_lists, (0..21).collect::>()); + for (slot0, pc) in plan.channels.iter().enumerate() { + let got = decode_channel(&read(&radio, channel_addr(slot0), CH_REC_LEN)).unwrap(); + assert_eq!(got, pc.edit, "slot {}", slot0 + 1); + } + for (z, zone) in plan.zones.iter().enumerate() { + let name = read(&radio, ZONE_NAME_BASE + z as u32 * 0x20, 16); + assert_eq!(decode_ascii_field(&name), zone.name); + } + for (i, sl) in plan.scan_lists.iter().enumerate() { + let f = decode_scan_list(&read(&radio, scan_list_addr(i), 0x200)); + assert_eq!((f.name, f.members), (sl.name.clone(), sl.member_slots.clone())); + } + // The radio's channels sat in slots 1..413 with holes; packing them + // clears every slot past 392 that held one. + assert_eq!(cleared.slots, stale); + assert_eq!(read(&radio, channel_addr(400), CH_REC_LEN), vec![0xFF; CH_REC_LEN]); + } + + /// Programming the identical codeplug twice: the second program changes + /// nothing — no window differs, so nothing is written. + #[test] + fn reprogramming_the_same_codeplug_writes_nothing() { + let mut radio = radio(); + let plan = plan_from_radio(&radio); + program(&mut radio, &plan); + let plan2 = plan_from_radio(&radio); + let (plans, _) = program(&mut radio, &plan2); + assert!(plans.is_empty(), "second program rewrote {} windows", plans.len()); + } + + /// A smaller codeplug clears what it no longer holds, everywhere. + #[test] + fn a_smaller_codeplug_clears_stale_records_and_bits() { + let mut radio = radio(); + let mut plan = plan_from_radio(&radio); + plan.channels.truncate(10); + plan.zones.truncate(2); + for z in &mut plan.zones { + z.member_slots.retain(|&m| m < 10); + } + plan.scan_lists.truncate(1); + plan.scan_lists[0].member_slots.retain(|&m| m < 10); + let (_, cleared) = program(&mut radio, &plan); + let on = OnRadio::from_bitmap_window(&radio[&BITMAP_WINDOW]); + assert_eq!(on.channels.len(), 10); + assert_eq!(on.zones, vec![0, 1]); + assert_eq!(on.scan_lists, vec![0]); + assert_eq!(cleared.zones, 19); + assert_eq!(cleared.scan_lists, 20); + assert_eq!(read(&radio, ZONE_NAME_BASE + 5 * 0x20, 0x20), vec![0; 0x20]); + assert_eq!(read(&radio, scan_list_addr(20), 0x200), vec![0xFF; 0x200]); + } + + /// Contacts land at their index with the index list and inverted bitmap to + /// match, and an RX tone turns on tone squelch. + #[test] + fn contacts_and_tone_squelch() { + use crate::radios::anytone_atd890uv::program::PlannedContact; + use crate::radios::anytone_atd890uv::{AnytoneSubTone, AnytoneToneEdit}; + let mut radio = radio(); + let mut plan = plan_from_radio(&radio); + plan.channels.truncate(2); + plan.zones.clear(); + plan.scan_lists.clear(); + plan.channels[0].edit.tone = AnytoneToneEdit { + tx: Some(AnytoneSubTone::Ctcss(100.0)), + rx: Some(AnytoneSubTone::Ctcss(100.0)), + }; + plan.contacts = vec![ + PlannedContact { index: 0, tg_number: 91, name: "Worldwide".into(), call_type: 1 }, + PlannedContact { index: 1, tg_number: 3100, name: "USA".into(), call_type: 1 }, + ]; + program(&mut radio, &plan); + assert_eq!(read(&radio, channel_addr(0), CH_REC_LEN)[CH_SQUELCH_MODE] & CH_SQ_CTCSS_DCS, CH_SQ_CTCSS_DCS); + assert_eq!(decode_contact(&read(&radio, contact_addr(1), CONTACT_REC_LEN)), (1, 3100, "USA".into())); + assert_eq!(read(&radio, CONTACT_INDEX_BASE, 12), [0, 0, 0, 0, 1, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF]); + assert_eq!(read(&radio, CONTACT_BITMAP_BASE, 2), [0xFC, 0xFF]); + // ID table sorted by ID: 91 (BCD 0x91 << 1 | group) before 3100. + assert_eq!(read(&radio, CONTACT_ID_TABLE, 24), [ + 0x23, 0x01, 0, 0, 0, 0, 0, 0, + 0x01, 0x62, 0, 0, 1, 0, 0, 0, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + ]); + } + + /// A slot that was empty is written from the measured template, not from + /// whatever erased flash held. + #[test] + fn a_new_slot_starts_from_the_template() { + let mut radio = radio(); + let mut plan = plan_from_radio(&radio); + // Slot 22 (0-based 21) is empty on the anchor radio. + assert!(!OnRadio::from_bitmap_window(&radio[&BITMAP_WINDOW]).channels.contains(&21)); + let edit = plan.channels[0].edit.clone(); + plan.channels.truncate(22); + plan.channels[21].edit = edit.clone(); + program(&mut radio, &plan); + let mut want = channel_record_template().to_vec(); + encode_channel_into(&mut want, &edit).unwrap(); + apply_tone_squelch(&mut want, &edit); + assert_eq!(read(&radio, channel_addr(21), CH_REC_LEN), want); + } + + /// Step-4 gate: the app's own pipeline — `resolve_codeplug_payload` and the + /// planner — run on a COPY of a real database, programmed into the simulated + /// anchor radio and read back record by record. Every channel list and scan + /// list any codeplug in the copy uses goes into one D578 codeplug. + /// cp "/codeplug_manager.sqlite3" /tmp/d578-copy.sqlite3 && \ + /// CPM_LIVE_DB_COPY=/tmp/d578-copy.sqlite3 \ + /// cargo test live_db_copy_programs -- --ignored --nocapture + #[tokio::test] + #[ignore = "needs CPM_LIVE_DB_COPY pointing at a copy of a real app DB"] + async fn live_db_copy_programs_and_reads_back() { + let Ok(path) = std::env::var("CPM_LIVE_DB_COPY") else { + eprintln!("CPM_LIVE_DB_COPY not set — skipping"); + return; + }; + // init_pool runs the migrations and the seed, which is what adds the + // AT-D578UV row to an older database. + let pool = crate::db::init_pool(Path::new(&path)).await.expect("open db copy"); + let (model_id,): (i64,) = + sqlx::query_as("SELECT id FROM radio_models WHERE model = 'AT-D578UV'") + .fetch_one(&pool) + .await + .unwrap(); + let profile_id = sqlx::query( + "INSERT INTO radio_profiles (display_name, radio_model_id) VALUES ('d578 gate', ?1)", + ) + .bind(model_id) + .execute(&pool) + .await + .unwrap() + .last_insert_rowid(); + let cp_id = sqlx::query("INSERT INTO codeplugs (name, radio_profile_id) VALUES ('d578 gate', ?1)") + .bind(profile_id) + .execute(&pool) + .await + .unwrap() + .last_insert_rowid(); + sqlx::query( + "INSERT INTO codeplug_channel_lists (codeplug_id, channel_list_id, position) + SELECT ?1, channel_list_id, ROW_NUMBER() OVER (ORDER BY MIN(codeplug_id), MIN(position)) + FROM codeplug_channel_lists WHERE codeplug_id != ?1 GROUP BY channel_list_id", + ) + .bind(cp_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO codeplug_scan_lists (codeplug_id, scan_list_id) + SELECT DISTINCT ?1, scan_list_id FROM codeplug_scan_lists WHERE codeplug_id != ?1", + ) + .bind(cp_id) + .execute(&pool) + .await + .unwrap(); + + let resolved = crate::commands::export::resolve_codeplug_payload(&pool, cp_id) + .await + .expect("resolves"); + let plan = plan_d578(&resolved.payload()).expect("plans").base; + eprintln!( + "planned: {} channels, {} zones, {} scan lists, {} contacts; {} skipped, {} warnings", + plan.channels.len(), + plan.zones.len(), + plan.scan_lists.len(), + plan.contacts.len(), + plan.skipped.len(), + plan.warnings.len() + ); + for sk in plan.skipped.iter().take(15) { + eprintln!(" skipped {}: {}", sk.name, sk.reason); + } + for w in plan.warnings.iter().take(15) { + eprintln!(" warning {w}"); + } + + let mut radio = radio(); + program(&mut radio, &plan); + let on = OnRadio::from_bitmap_window(&radio[&BITMAP_WINDOW]); + assert_eq!(on.channels.len(), plan.channels.len(), "channels present"); + assert_eq!(on.zones.len(), plan.zones.len(), "zones present"); + assert_eq!(on.scan_lists.len(), plan.scan_lists.len(), "scan lists present"); + for (slot0, pc) in plan.channels.iter().enumerate() { + let got = decode_channel(&read(&radio, channel_addr(slot0), CH_REC_LEN)).unwrap(); + let mut want = pc.edit.clone(); + want.name = decode_ascii_field(&encode_ascii_field(&want.name, NAME_CHARS)); + assert_eq!(got, want, "slot {}", slot0 + 1); + } + for (z, zone) in plan.zones.iter().enumerate() { + let list = read(&radio, ZONE_LIST_BASE + z as u32 * ZONE_LIST_STEP, 500); + let members: Vec = list + .chunks(2) + .map(|p| u16::from_le_bytes([p[0], p[1]])) + .take_while(|&m| m != 0xFFFF) + .collect(); + assert_eq!(members, zone.member_slots, "zone {}", zone.name); + } + for (i, sl) in plan.scan_lists.iter().enumerate() { + let f = decode_scan_list(&read(&radio, scan_list_addr(i), 0x200)); + assert_eq!(f.members, sl.member_slots, "scan list {}", sl.name); + } + for c in &plan.contacts { + let (t, id, _) = decode_contact(&read(&radio, contact_addr(c.index as usize), CONTACT_REC_LEN)); + assert_eq!((t, id), (c.call_type, c.tg_number), "contact {}", c.name); + } + eprintln!("read back: every planned record matches"); + } + + fn chan(id: i64, name: &str, rx: f64, mode: &str) -> ExpandedChannel { + ExpandedChannel { + channel: crate::models::Channel { + id, + name_long: Some(name.into()), + rx_freq: rx, + tx_freq: Some(rx), + mode: Some(mode.into()), + dcs_polarity: "NN".into(), + ..Default::default() + }, + tg_label: None, + timeslot: None, + tg_number: None, + tg_call_type: None, + tg_inline: false, + } + } + + /// AM in the air band goes to the air-band memory; AM elsewhere and FM in + /// the air band are skipped with a reason; the channel memories never see + /// an AM row. + #[test] + fn am_channels_are_routed_to_the_air_band_and_nowhere_else() { + let model = crate::seed::test_model("AT-D578UV"); + let channels = vec![ + chan(1, "Tower", 118.300, "AM"), + chan(2, "Ground", 121.900, "AM"), + chan(3, "CB 19", 27.185, "AM"), + chan(4, "Calling", 146.520, "FM"), + chan(5, "FM in air", 120.000, "FM"), + ]; + let payload = CodeplugPayload { + model: &model, + groups: &[], + channels: &channels, + scan_lists: &[], + scan_list_overrides: &[], + contact_users: &[], + }; + let p = plan_d578(&payload).unwrap(); + assert_eq!( + p.air, + vec![ + AirChannel { mhz: 118.300, name: "Tower".into() }, + AirChannel { mhz: 121.900, name: "Ground".into() }, + ] + ); + assert_eq!(p.base.channels.len(), 1, "only the 2 m FM channel reaches a channel slot"); + assert_eq!(p.base.channels[0].edit.name, "Calling"); + let skipped: Vec<&str> = p.base.skipped.iter().map(|s| s.name.as_str()).collect(); + assert!(skipped.contains(&"CB 19") && skipped.contains(&"FM in air"), "{skipped:?}"); + assert!(p.base.warnings.iter().any(|w| w.contains("2 AM channels"))); + } + + /// A reversed-polarity DCS code is stored with +512 on the D578. + #[test] + fn inverted_dcs_is_stored_with_512_added() { + let model = crate::seed::test_model("AT-D578UV"); + let mut c = chan(1, "Inverted", 146.520, "FM"); + c.channel.tone_mode = Some("DTCS".into()); + c.channel.dcs_code = Some("023".into()); + c.channel.dcs_polarity = "RR".into(); + let mut n = chan(2, "Normal", 146.550, "FM"); + n.channel.tone_mode = Some("DTCS".into()); + n.channel.dcs_code = Some("023".into()); + let channels = vec![c, n]; + let payload = CodeplugPayload { + model: &model, + groups: &[], + channels: &channels, + scan_lists: &[], + scan_list_overrides: &[], + contact_users: &[], + }; + let p = plan_d578(&payload).unwrap(); + use crate::radios::anytone_atd890uv::AnytoneSubTone::Dcs; + assert_eq!(p.base.channels[0].edit.tone.tx, Some(Dcs(0o23 + 512))); + assert_eq!(p.base.channels[1].edit.tone.tx, Some(Dcs(0o23))); + } + + /// Air channels land in their slots with both bitmaps; every other slot is + /// cleared; a codeplug without air channels leaves the memory alone. + #[test] + fn air_band_records_and_bitmaps() { + let mut radio = radio(); + let plan = plan_from_radio(&radio); + let on = OnRadio::from_bitmap_window(&radio[&BITMAP_WINDOW]); + assert!(!windows_to_read(&plan, &[], &on, 0).contains(&AIR_BASE)); + let air = vec![ + AirChannel { mhz: 118.300, name: "Tower".into() }, + AirChannel { mhz: 135.975, name: "Top".into() }, + ]; + let want = windows_to_read(&plan, &air, &on, 0); + assert!(want.contains(&AIR_BASE)); + let windows = windows_for(&radio, &want); + let (patches, _) = patches_for(&plan, &air, &on, 0, &windows).unwrap(); + apply(&mut radio, &plans_from_pre_read(&windows, &patches).unwrap()); + let rec = read(&radio, AIR_BASE + 0x20, 0x20); + assert_eq!(&rec[0..4], &[0x13, 0x59, 0x75, 0x00]); + assert_eq!(decode_ascii_field(&rec[4..20]), "Top"); + assert_eq!(&rec[20..], &[0u8; 12]); + assert_eq!(read(&radio, AIR_BASE + 2 * 0x20, 0x20), vec![0xFF; 0x20]); + assert_eq!(read(&radio, AIR_BITMAP, 2), [0x03, 0x00]); + assert_eq!(read(&radio, AIR_SCAN_BITMAP, 2), [0x03, 0x00]); + } + + #[test] + fn every_window_the_program_touches_is_guarded() { + let radio = radio(); + let plan = plan_from_radio(&radio); + let on = OnRadio::from_bitmap_window(&radio[&BITMAP_WINDOW]); + for w in windows_to_read(&plan, &[], &on, 2500) { + assert!(writable_window_name(w).is_some(), "0x{w:08X}"); + } + } +} diff --git a/src-tauri/src/radios/anytone_atd578uv/settings.rs b/src-tauri/src/radios/anytone_atd578uv/settings.rs new file mode 100644 index 0000000..322c09f --- /dev/null +++ b/src-tauri/src/radios/anytone_atd578uv/settings.rs @@ -0,0 +1,400 @@ +//! AT-D578UV radio settings: read off the radio into the profile form, and +//! written back read-modify-write, over the same cable session as a program. +//! +//! The field table is generated (`settings_table.rs`) and the per-field codec is +//! the family's (`anytone_atd890uv::settings`), so this module is the D578's +//! regions and its own radio-ID record and nothing else. +//! +//! Every field region lives in the one 0x4000 window at [`SETTINGS_WINDOW`]; +//! the radio's own DMR ID and name are radio-ID record 0 at [`RADIO_ID_BASE`], +//! shown by bit 0 of the radio-ID present bitmap. + +use std::collections::BTreeMap; + +use serde_json::{Map, Value}; + +use crate::radios::anytone_atd890uv::settings::{ + as_u32, bcd_be_decode, bcd_be_encode, decode_field, encode_field_into, SF, SK, +}; +use crate::radios::anytone_atd890uv::{ + end_session, open_port, read_block, serialize_backup, BankPlan, + WRITE_WINDOW, +}; +use crate::radios::driver::{SettingsCapture, SettingsReader, SettingsWriteReport, SettingsWriter}; + +use super::{decode_ascii_field, encode_ascii_field, enter, AnytoneAtd578uv}; + +include!("settings_table.rs"); + +/// The window holding every generated field (general, boot, APRS, messages, +/// extension — 0x02500000..0x02501600). +pub(crate) const SETTINGS_WINDOW: u32 = 0x0250_0000; +/// Radio-ID records: BCD-BE DMR ID @+0, pad @+4, 16-char ASCII name @+5. +pub(crate) const RADIO_ID_BASE: u32 = 0x0258_0000; +/// The window holding the radio-ID present bitmap (0x024C1320). +pub(crate) const BITMAP_WINDOW: u32 = 0x024C_0000; +pub(crate) const RADIO_ID_BITMAP: u32 = 0x024C_1320; +pub(crate) const RADIO_ID_NAME: usize = 0x05; + +pub(crate) const DMR_ID_KEY: &str = "dmr-id"; +pub(crate) const RADIO_NAME_KEY: &str = "radio-name"; + +/// File-name prefix of a settings write's backup and expected image. The +/// program dialog's Verify and Restore find them by it. +pub(crate) const SETTINGS_PREFIX: &str = "atd578uv-settings-write-"; + +/// The windows a settings write reads and may rewrite. +pub(crate) const WRITE_WINDOWS: [u32; 3] = [SETTINGS_WINDOW, RADIO_ID_BASE, BITMAP_WINDOW]; + +fn slice_at(windows: &BTreeMap>, addr: u32) -> &[u8] { + let base = addr & !(WRITE_WINDOW as u32 - 1); + windows + .get(&base) + .map(|w| &w[(addr - base) as usize..]) + .unwrap_or(&[]) +} + +/// Decode the settings windows into the profile form's values. +pub(crate) fn decode_settings(windows: &BTreeMap>) -> Value { + let mut map = Map::new(); + for f in D578_SETTINGS_FIELDS { + map.insert(f.key.to_string(), decode_field(slice_at(windows, f.base), f.byte as usize, &f.kind)); + } + let bitmap = slice_at(windows, RADIO_ID_BITMAP); + let rec = slice_at(windows, RADIO_ID_BASE); + if bitmap.first().is_some_and(|b| b & 1 == 1) && rec.len() >= RADIO_ID_NAME + 16 { + if let Some(id) = bcd_be_decode(&rec[0..4]) { + map.insert(DMR_ID_KEY.to_string(), Value::from(id)); + map.insert( + RADIO_NAME_KEY.to_string(), + Value::from(decode_ascii_field(&rec[RADIO_ID_NAME..RADIO_ID_NAME + 16])), + ); + } + } + Value::Object(map) +} + +/// Apply profile values onto copies of the windows (read-modify-write: every +/// byte and bit no field names is kept) and return a plan for each window that +/// changed. A value that does not parse leaves its bytes alone. +pub(crate) fn plan_settings_write( + windows: &BTreeMap>, + values: &Value, +) -> Result, String> { + let obj = values.as_object().ok_or("settings values is not a JSON object")?; + let mut patched = windows.clone(); + for f in D578_SETTINGS_FIELDS { + let Some(v) = obj.get(f.key) else { continue }; + let base = f.base & !(WRITE_WINDOW as u32 - 1); + let Some(w) = patched.get_mut(&base) else { continue }; + encode_field_into(&mut w[(f.base - base) as usize..], f.byte as usize, &f.kind, v); + } + + // The radio's own DMR ID: record 0 and its present bit, only when the + // profile carries a non-zero ID — a profile without one leaves the radio's + // ID alone. A blank name likewise keeps the radio's own (#128). + if let Some(id) = obj.get(DMR_ID_KEY).and_then(as_u32).filter(|&id| id != 0) { + if id > 16_777_215 { + return Err(format!("DMR ID {id} is out of range")); + } + if let Some(w) = patched.get_mut(&RADIO_ID_BASE) { + w[0..4].copy_from_slice(&bcd_be_encode(id)); + w[4] = 0; + // Rewrite the name only when it changed: the radio leaves stale + // bytes after a shortened name's NUL ("My Radio\0…R" on the anchor + // radio), and re-padding an unchanged name would rewrite them. + let field = RADIO_ID_NAME..RADIO_ID_NAME + 16; + if let Some(name) = obj + .get(RADIO_NAME_KEY) + .and_then(Value::as_str) + .filter(|n| !n.is_empty() && *n != decode_ascii_field(&w[field.clone()])) + { + w[field].copy_from_slice(&encode_ascii_field(name, 16)); + } + } + if let Some(w) = patched.get_mut(&BITMAP_WINDOW) { + w[(RADIO_ID_BITMAP - BITMAP_WINDOW) as usize] |= 1; + } + } + + Ok(patched + .into_iter() + .filter(|(base, data)| windows.get(base) != Some(data)) + .map(|(base, data)| BankPlan { base, original: windows[&base].clone(), patched: data }) + .collect()) +} + +fn read_windows(p: &mut dyn serialport::SerialPort) -> Result>, String> { + let mut out = BTreeMap::new(); + for w in WRITE_WINDOWS { + out.insert(w, read_block(p, w, WRITE_WINDOW)?); + } + Ok(out) +} + +impl SettingsReader for AnytoneAtd578uv { + /// Read the settings windows and decode them. The table is built in, so the + /// schema argument is unused; the raw windows come back as the backup. + fn read_settings(&self, port: &str, _schema_json: &str) -> Result { + let mut p = open_port(port)?; + let _ident = enter(&mut *p)?; + let windows = read_windows(&mut *p); + let _ = end_session(&mut *p); + let windows = windows?; + let blocks: Vec<(u32, Vec)> = windows.iter().map(|(a, d)| (*a, d.clone())).collect(); + Ok(SettingsCapture { + settings: decode_settings(&windows), + backup: serialize_backup(&blocks), + backup_ext: "bin", + }) + } +} + +impl SettingsWriter for AnytoneAtd578uv { + /// Read the windows fresh, apply the profile, back up the originals and save + /// the expected image BEFORE any write, write the changed windows, END once. + fn write_settings( + &self, + port: &str, + settings: &Value, + _schema_json: &str, + backup_dir: &std::path::Path, + ) -> Result { + std::fs::create_dir_all(backup_dir).map_err(|e| e.to_string())?; + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let backup_path = backup_dir.join(format!("{SETTINGS_PREFIX}{stamp}.bin")); + let expected_path = backup_dir.join(format!("{SETTINGS_PREFIX}{stamp}.expected.bin")); + + let mut p = open_port(port)?; + let _ident = enter(&mut *p)?; + let planned = read_windows(&mut *p).and_then(|w| { + let plans = plan_settings_write(&w, settings)?; + // The rest of every half a write lands in, carried unchanged. + let mut reads = w; + for a in super::windows_of_touched_halves(&plans) { + if let std::collections::btree_map::Entry::Vacant(e) = reads.entry(a) { + e.insert(read_block(&mut *p, a, WRITE_WINDOW)?); + } + } + super::with_carried_windows(plans, &reads) + }); + let plans = match planned { + Ok(plans) => plans, + Err(e) => { + let _ = end_session(&mut *p); + return Err(e); + } + }; + if plans.is_empty() { + let _ = end_session(&mut *p); + return Err("the radio already matches this profile's settings — nothing to write".into()); + } + let fields_written = D578_SETTINGS_FIELDS + .iter() + .filter(|f| { + let base = f.base & !(WRITE_WINDOW as u32 - 1); + plans.iter().find(|pl| pl.base == base).is_some_and(|pl| { + let o = (f.base - base) as usize + f.byte as usize; + field_changed(&f.kind, &pl.original[o..], &pl.patched[o..]) + }) + }) + .count(); + + for (path, bytes) in [ + (&backup_path, serialize_backup(&super::plan_blocks(&plans, false))), + (&expected_path, serialize_backup(&super::plan_blocks(&plans, true))), + ] { + if let Err(e) = std::fs::write(path, bytes) { + let _ = end_session(&mut *p); + return Err(format!("could not write {}: {e}", path.display())); + } + } + let written = super::commit_windows(&mut *p, &plans).map_err(|e| { + crate::radios::driver::with_restore_hint( + e, + &backup_path, + "Power-cycle the radio, rescan the port, then use \"Restore backup\" in the \ + Program dialog.", + ) + })?; + let _ = end_session(&mut *p); + Ok(SettingsWriteReport { + fields_written, + // END commits and reboots the radio; verification is the dialog's + // fresh-session Verify against the expected image. + verified: None, + note: Some( + "Written and committed — the radio reboots and drops off USB. Reconnect it, \ + rescan, then run Verify against the expected image." + .to_string(), + ), + backup_path: backup_path.to_string_lossy().to_string(), + expected_path: Some(expected_path.to_string_lossy().to_string()), + windows_written: written.iter().map(|a| format!("0x{a:08X}")).collect(), + }) + } +} + +/// Did this field's own value change? Bit fields compare only their bits, so a +/// change to one flag in a shared byte counts once, not once per neighbour. +fn field_changed(kind: &SK, before: &[u8], after: &[u8]) -> bool { + match kind { + SK::BoolBit { lsb } => (before[0] ^ after[0]) >> lsb & 1 == 1, + SK::Bits { lsb, bits } => (before[0] ^ after[0]) >> lsb & ((1u8 << bits) - 1) != 0, + _ => { + let n = field_len(kind); + before[..n] != after[..n] + } + } +} + +/// Bytes a field occupies (bit fields: the one byte they live in). +fn field_len(kind: &SK) -> usize { + match kind { + SK::Uint { width } | SK::Enum { width, .. } => *width as usize, + SK::Text { len } => *len as usize, + SK::Utf16 { chars } => *chars as usize * 2, + SK::FreqBcd | SK::FreqLe | SK::BcdId => 4, + SK::Bool | SK::BoolBit { .. } | SK::Bits { .. } => 1, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The anchor radio's settings window (fw 1.21), the fields' regions only, + /// with the boot text — which carries the owner's phone number — blanked. + const SETTINGS: &[u8] = include_bytes!("testdata/d578_settings.bin"); + + fn windows() -> BTreeMap> { + let mut w: BTreeMap> = WRITE_WINDOWS.iter().map(|&a| (a, vec![0xFF; WRITE_WINDOW])).collect(); + for (addr, data) in crate::radios::anytone_atd890uv::parse_dump(SETTINGS).unwrap() { + let base = addr & !(WRITE_WINDOW as u32 - 1); + let o = (addr - base) as usize; + w.get_mut(&base).unwrap()[o..o + data.len()].copy_from_slice(&data); + } + w + } + + /// Values the anchor radio holds that are independently known: its owner is + /// in Colorado (UTC-7) and uses imperial units, talk-permit is on for DMR, + /// the P1 key is set to Scan, and APRS was never set up (factory call sign). + #[test] + fn decodes_known_values_off_the_real_radio() { + let v = decode_settings(&windows()); + assert_eq!(v["time-zone"], "UTC-07:00h"); + assert_eq!(v["units"], "Archaic"); + assert_eq!(v["talk-permit-tone"], "DMR"); + assert_eq!(v["p1-short-press"], "Scan"); + assert_eq!(v["source-call"], "BG6LKT"); + assert_eq!(v["destination-call"], "APAT51"); + assert_eq!(v["aprs-path"], "WIDE1-1,WIDE2-1"); + assert_eq!(v["aprs-symbol-table"], "/"); + assert_eq!(v["fm-aprs-frequency-1"], "145.000"); + assert_eq!(v["fm-aprs-frequency-2"], ""); + assert_eq!(v["vfo-scan-vhf-min-frequency"], "136.000"); + assert_eq!(v["uhf-auto-repeater-max-frequency"], "480.000"); + assert_eq!(v[DMR_ID_KEY], 3_108_176); + assert_eq!(v[RADIO_NAME_KEY], "My Radio"); + } + + /// Writing back exactly what was read changes nothing. + #[test] + fn writing_the_decoded_values_back_is_a_no_op() { + let w = windows(); + let plans = plan_settings_write(&w, &decode_settings(&w)).unwrap(); + assert!(plans.is_empty(), "{} windows changed", plans.len()); + } + + /// One field changes one field's bytes, and nothing else in the window. + #[test] + fn one_change_touches_only_its_bytes() { + let w = windows(); + let mut v = decode_settings(&w); + v["fm-aprs-frequency-1"] = Value::from("144.390"); + v["lock-knob"] = Value::from(true); + let plans = plan_settings_write(&w, &v).unwrap(); + assert_eq!(plans.len(), 1); + let diff: Vec = (0..WRITE_WINDOW).filter(|&i| plans[0].original[i] != plans[0].patched[i]).collect(); + // 145.000 -> 144.390 is 14 50 00 00 -> 14 43 90 00: bytes +1 and +2. + assert_eq!(diff, vec![0x0B0, 0x10AD, 0x10AE]); + assert_eq!(&plans[0].patched[0x10AC..0x10B0], &[0x14, 0x43, 0x90, 0x00]); + assert_eq!(plans[0].patched[0x0B0] & 1, 1); + let back = decode_settings(&plans.iter().map(|p| (p.base, p.patched.clone())).chain( + w.iter().filter(|(b, _)| **b != plans[0].base).map(|(b, d)| (*b, d.clone())), + ).collect()); + assert_eq!(back["fm-aprs-frequency-1"], "144.390"); + assert_eq!(back["lock-knob"], true); + } + + #[test] + fn one_flag_in_a_shared_byte_counts_as_one_field() { + let kind = |lsb| SK::BoolBit { lsb }; + assert!(field_changed(&kind(0), &[0b0000], &[0b0001])); + assert!(!field_changed(&kind(1), &[0b0000], &[0b0001]), "a neighbour bit is not a change"); + assert!(field_changed(&SK::Bits { lsb: 2, bits: 2 }, &[0], &[0b1000])); + } + + #[test] + fn a_new_dmr_id_sets_record_zero_and_its_present_bit() { + let mut w = windows(); + w.get_mut(&BITMAP_WINDOW).unwrap()[0x1320] = 0; + let mut v = Map::new(); + v.insert(DMR_ID_KEY.into(), Value::from(3_100_001)); + v.insert(RADIO_NAME_KEY.into(), Value::from("Shack")); + let plans = plan_settings_write(&w, &Value::Object(v)).unwrap(); + let rid = plans.iter().find(|p| p.base == RADIO_ID_BASE).unwrap(); + assert_eq!(&rid.patched[0..5], &[0x03, 0x10, 0x00, 0x01, 0x00]); + assert_eq!(&rid.patched[5..10], b"Shack"); + let bm = plans.iter().find(|p| p.base == BITMAP_WINDOW).unwrap(); + assert_eq!(bm.patched[0x1320] & 1, 1); + } + + #[test] + fn an_unparseable_frequency_leaves_the_radio_alone() { + let w = windows(); + let mut v = Map::new(); + v.insert("fm-aprs-frequency-1".into(), Value::from("not a frequency")); + assert!(plan_settings_write(&w, &Value::Object(v)).unwrap().is_empty()); + } + + /// The generated table and the profile schema name the same keys, so no form + /// field is dead and no table row is unreachable. + #[test] + fn table_and_schema_agree() { + let schema: Vec = + serde_json::from_str(include_str!("../../anytone_d578_settings_schema.json")).unwrap(); + let mut in_schema: Vec<&str> = schema + .iter() + .filter(|f| f["type"] != "section") + .map(|f| f["key"].as_str().unwrap()) + .filter(|k| *k != DMR_ID_KEY && *k != RADIO_NAME_KEY) + .collect(); + let mut in_table: Vec<&str> = D578_SETTINGS_FIELDS.iter().map(|f| f.key).collect(); + in_schema.sort_unstable(); + in_table.sort_unstable(); + assert_eq!(in_schema, in_table); + assert_eq!(in_table.len(), 264); + } + + /// Every field sits inside the one window a settings session reads. + #[test] + fn every_field_is_inside_the_settings_window() { + for f in D578_SETTINGS_FIELDS { + let end = f.base + f.byte as u32 + field_len(&f.kind) as u32; + assert!(f.base >= SETTINGS_WINDOW && end <= SETTINGS_WINDOW + WRITE_WINDOW as u32, "{}", f.key); + } + } + + #[test] + fn mhz_text_keeps_three_decimals_and_drops_the_rest() { + use crate::radios::anytone_atd890uv::settings::{mhz_text, parse_mhz}; + assert_eq!(mhz_text(14_500_000), "145.000"); + assert_eq!(mhz_text(14_439_000), "144.390"); + assert_eq!(mhz_text(14_439_125), "144.39125"); + assert_eq!(parse_mhz("144.390"), Some(14_439_000)); + assert_eq!(parse_mhz("x"), None); + assert_eq!(parse_mhz("-1"), None); + } +} diff --git a/src-tauri/src/radios/anytone_atd578uv/settings_table.rs b/src-tauri/src/radios/anytone_atd578uv/settings_table.rs new file mode 100644 index 0000000..da22656 --- /dev/null +++ b/src-tauri/src/radios/anytone_atd578uv/settings_table.rs @@ -0,0 +1,283 @@ +// GENERATED by scratchpad/anytone_atd578uv/gen_d578_settings.py from the +// dmr-tools/codeplugs d578uv v1.21 descriptor: General Settings (0x02500000), +// Boot Settings (0x02500600), APRS (0x02501000), the FM and DMR APRS messages +// (0x02501200 / 0x02501280) and the General Settings Extension (0x02501400). +// Do not hand-edit — change the generator and regenerate. +// +// How each row was checked (scratchpad/anytone_atd578uv/MEASURED.md): +// - the walk's size equals each element's declared size, in all six regions; +// - 189 of qdmr's 191 independently reverse-engineered offsets in these +// regions land on a field here; the two that do not are withheld or absent; +// - every value on a real D578UV (fw 1.21) decodes to a legal value, where +// the same check with offsets shifted one byte fails 13 fields. +// None of it has been confirmed on the radio's own screen yet. +// +// The generator lives under `scratchpad/`, which is gitignored, so treat this +// file as the artefact. LICENCE NOTE: as for the D890's generated table, the +// byte offsets are facts about the radio; see the Licence section of the README. +pub(crate) const D578_SETTINGS_FIELDS: &[SF] = &[ + SF { key: "key-tone", base: 0x02500000, byte: 0x000, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Level 1"), (2, "Level 2"), (3, "Level 3"), (4, "Level 4"), (5, "Level 5"), (6, "Level 6"), (7, "Level 7"), (8, "Level 8"), (9, "With RX Tone A"), (10, "With RX Tone B")] } }, + SF { key: "display-mode", base: 0x02500000, byte: 0x001, kind: SK::Enum { width: 1, labels: &[(0, "Channel"), (1, "Frequency")] } }, + SF { key: "enable-automatic-key-lock", base: 0x02500000, byte: 0x002, kind: SK::Bool }, + SF { key: "auto-shutoff-delay", base: 0x02500000, byte: 0x003, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "10 min"), (2, "30 min"), (3, "60 min"), (4, "120 min")] } }, + SF { key: "tot", base: 0x02500000, byte: 0x004, kind: SK::Uint { width: 1 } }, + SF { key: "language", base: 0x02500000, byte: 0x005, kind: SK::Enum { width: 1, labels: &[(0, "English"), (1, "German")] } }, + SF { key: "boot-display", base: 0x02500000, byte: 0x006, kind: SK::Enum { width: 1, labels: &[(0, "Default"), (1, "Custom Text"), (2, "Custom Image")] } }, + SF { key: "enable-boot-password", base: 0x02500000, byte: 0x007, kind: SK::Bool }, + SF { key: "frequency-step-size", base: 0x02500000, byte: 0x008, kind: SK::Enum { width: 1, labels: &[(0, "2.5 kHz"), (1, "5 kHz"), (2, "6.25 kHz"), (3, "8.33 kHz"), (4, "10 kHz"), (5, "12.5 kHz"), (6, "20 kHz"), (7, "25 kHz"), (8, "30 kHz"), (9, "50 kHz")] } }, + SF { key: "squelch-level-vfo-a", base: 0x02500000, byte: 0x009, kind: SK::Uint { width: 1 } }, + SF { key: "squelch-level-vfo-b", base: 0x02500000, byte: 0x00A, kind: SK::Uint { width: 1 } }, + SF { key: "vfo-scan-type", base: 0x02500000, byte: 0x00B, kind: SK::Enum { width: 1, labels: &[(0, "Fixed Time"), (1, "Carrier"), (2, "Stop")] } }, + SF { key: "dmr-mic-gain", base: 0x02500000, byte: 0x00C, kind: SK::Uint { width: 1 } }, + SF { key: "work-mode-a", base: 0x02500000, byte: 0x00D, kind: SK::Enum { width: 1, labels: &[(0, "Channel"), (1, "VFO")] } }, + SF { key: "work-mode-b", base: 0x02500000, byte: 0x00E, kind: SK::Enum { width: 1, labels: &[(0, "Channel"), (1, "VFO")] } }, + SF { key: "ste-type", base: 0x02500000, byte: 0x00F, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Silent"), (2, "120 deg"), (3, "180 deg"), (4, "240 deg")] } }, + SF { key: "ste-frequency", base: 0x02500000, byte: 0x010, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "55.2 Hz"), (2, "259.2 Hz")] } }, + SF { key: "group-call-hang-time", base: 0x02500000, byte: 0x011, kind: SK::Uint { width: 1 } }, + SF { key: "private-call-hang-time", base: 0x02500000, byte: 0x012, kind: SK::Uint { width: 1 } }, + SF { key: "preamble-duration", base: 0x02500000, byte: 0x014, kind: SK::Uint { width: 1 } }, + SF { key: "fm-broadcast-channel-index", base: 0x02500000, byte: 0x015, kind: SK::Uint { width: 1 } }, + SF { key: "fm-broadcast-mode", base: 0x02500000, byte: 0x016, kind: SK::Enum { width: 1, labels: &[(0, "Channel"), (1, "VFO")] } }, + SF { key: "current-zone-a", base: 0x02500000, byte: 0x017, kind: SK::Uint { width: 1 } }, + SF { key: "current-zone-b", base: 0x02500000, byte: 0x018, kind: SK::Uint { width: 1 } }, + SF { key: "enable-fm-broadcast", base: 0x02500000, byte: 0x019, kind: SK::Bool }, + SF { key: "enable-recording", base: 0x02500000, byte: 0x01A, kind: SK::Bool }, + SF { key: "display-brightness", base: 0x02500000, byte: 0x01D, kind: SK::Uint { width: 1 } }, + SF { key: "enable-gps", base: 0x02500000, byte: 0x01F, kind: SK::Bool }, + SF { key: "enable-sms-alert", base: 0x02500000, byte: 0x020, kind: SK::Bool }, + SF { key: "enable-fm-broadcast-monitor", base: 0x02500000, byte: 0x022, kind: SK::Bool }, + SF { key: "main-vfo", base: 0x02500000, byte: 0x023, kind: SK::Enum { width: 1, labels: &[(0, "A"), (1, "B")] } }, + SF { key: "enable-subchannel", base: 0x02500000, byte: 0x024, kind: SK::Bool }, + SF { key: "tbst", base: 0x02500000, byte: 0x025, kind: SK::Enum { width: 1, labels: &[(0, "1000 Hz"), (1, "1450 Hz"), (2, "1750 Hz"), (3, "2100 Hz")] } }, + SF { key: "enable-call-alert", base: 0x02500000, byte: 0x026, kind: SK::Bool }, + SF { key: "time-zone", base: 0x02500000, byte: 0x027, kind: SK::Enum { width: 1, labels: &[(0, "UTC-12:00h"), (1, "UTC-11:00h"), (2, "UTC-10:00h"), (3, "UTC-09:00h"), (4, "UTC-08:00h"), (5, "UTC-07:00h"), (6, "UTC-06:00h"), (7, "UTC-05:00h"), (8, "UTC-04:00h"), (9, "UTC-03:30h"), (10, "UTC-02:00h"), (11, "UTC-01:00h"), (12, "UTC"), (13, "UTC+01:00h"), (14, "UTC+02:00h"), (15, "UTC+03:00h"), (16, "UTC+03:30h"), (17, "UTC+04:00h"), (18, "UTC+04:30h"), (19, "UTC+05:00h"), (20, "UTC+05:30h"), (21, "UTC+05:45h"), (22, "UTC+06:00h"), (23, "UTC+07:00h"), (24, "UTC+08:00h"), (25, "UTC+08:30h"), (26, "UTC+09:00h"), (27, "UTC+10:00h"), (28, "UTC+11:00h"), (29, "UTC+12:00h"), (30, "UTC+13:00h")] } }, + SF { key: "talk-permit-tone", base: 0x02500000, byte: 0x028, kind: SK::Enum { width: 1, labels: &[(0, "None"), (1, "DMR"), (2, "FM"), (3, "Both")] } }, + SF { key: "enable-dmr-call-reset-tone", base: 0x02500000, byte: 0x029, kind: SK::Bool }, + SF { key: "dmr-channel-free-tone", base: 0x02500000, byte: 0x02A, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Type 1"), (2, "Type 2"), (3, "Type 3")] } }, + SF { key: "menu-exit-time", base: 0x02500000, byte: 0x02B, kind: SK::Uint { width: 1 } }, + SF { key: "enable-filter-own-id", base: 0x02500000, byte: 0x02C, kind: SK::Bool }, + SF { key: "enable-startup-tone", base: 0x02500000, byte: 0x02D, kind: SK::Bool }, + SF { key: "enable-call-end-notification", base: 0x02500000, byte: 0x02E, kind: SK::Uint { width: 1 } }, + SF { key: "max-speaker-volume", base: 0x02500000, byte: 0x02F, kind: SK::Uint { width: 1 } }, + SF { key: "enable-dmr-remote-stun-kill", base: 0x02500000, byte: 0x030, kind: SK::Bool }, + SF { key: "enable-remote-monitor", base: 0x02500000, byte: 0x031, kind: SK::Bool }, + SF { key: "enable-rx-dmr-aprs-positions", base: 0x02500000, byte: 0x032, kind: SK::Bool }, + SF { key: "long-press-duration", base: 0x02500000, byte: 0x033, kind: SK::Uint { width: 1 } }, + SF { key: "show-volume-bar", base: 0x02500000, byte: 0x034, kind: SK::Uint { width: 1 } }, + SF { key: "auto-repeater-a-direction", base: 0x02500000, byte: 0x035, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Positive"), (2, "Negative")] } }, + SF { key: "dmr-monitor-mode", base: 0x02500000, byte: 0x036, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Single slot"), (2, "Double slot")] } }, + SF { key: "dmr-monitor-match-cc", base: 0x02500000, byte: 0x037, kind: SK::Enum { width: 1, labels: &[(0, "Any"), (1, "Same")] } }, + SF { key: "dmr-monitor-match-id", base: 0x02500000, byte: 0x038, kind: SK::Enum { width: 1, labels: &[(0, "Any"), (1, "Same")] } }, + SF { key: "enable-dmr-monitor-slot-hold", base: 0x02500000, byte: 0x039, kind: SK::Bool }, + SF { key: "last-caller-display-mode", base: 0x02500000, byte: 0x03A, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "DMR ID"), (2, "Callsign"), (3, "Both")] } }, + SF { key: "fm-call-hold-time", base: 0x02500000, byte: 0x03C, kind: SK::Uint { width: 1 } }, + SF { key: "show-clock", base: 0x02500000, byte: 0x03D, kind: SK::Uint { width: 1 } }, + SF { key: "sent-dmr-aprs-message", base: 0x02500000, byte: 0x03E, kind: SK::Uint { width: 1 } }, + SF { key: "enable-enhanced-audio", base: 0x02500000, byte: 0x03F, kind: SK::Bool }, + SF { key: "vfo-scan-uhf-min-frequency", base: 0x02500000, byte: 0x040, kind: SK::FreqLe }, + SF { key: "vfo-scan-uhf-max-frequency", base: 0x02500000, byte: 0x044, kind: SK::FreqLe }, + SF { key: "vfo-scan-vhf-min-frequency", base: 0x02500000, byte: 0x048, kind: SK::FreqLe }, + SF { key: "vfo-scan-vhf-max-frequency", base: 0x02500000, byte: 0x04C, kind: SK::FreqLe }, + SF { key: "vhf-auto-repeater-min-frequency", base: 0x02500000, byte: 0x050, kind: SK::FreqLe }, + SF { key: "vhf-auto-repeater-max-frequency", base: 0x02500000, byte: 0x054, kind: SK::FreqLe }, + SF { key: "uhf-auto-repeater-min-frequency", base: 0x02500000, byte: 0x058, kind: SK::FreqLe }, + SF { key: "uhf-auto-repeater-max-frequency", base: 0x02500000, byte: 0x05C, kind: SK::FreqLe }, + SF { key: "uhf-auto-repeater-offset-frequency-1-index", base: 0x02500000, byte: 0x09C, kind: SK::Uint { width: 1 } }, + SF { key: "vhf-auto-repeater-offset-frequency-index", base: 0x02500000, byte: 0x09D, kind: SK::Uint { width: 1 } }, + SF { key: "priority-zone-index-a", base: 0x02500000, byte: 0x09F, kind: SK::Uint { width: 1 } }, + SF { key: "priority-zone-index-b", base: 0x02500000, byte: 0x0A0, kind: SK::Uint { width: 1 } }, + SF { key: "call-display-mode", base: 0x02500000, byte: 0x0A2, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Call"), (2, "Name")] } }, + SF { key: "enable-bluetooth", base: 0x02500000, byte: 0x0A4, kind: SK::Bool }, + SF { key: "bluetooth-and-internal-mic", base: 0x02500000, byte: 0x0A5, kind: SK::Uint { width: 1 } }, + SF { key: "bluetooth-and-internal-speaker", base: 0x02500000, byte: 0x0A6, kind: SK::Uint { width: 1 } }, + SF { key: "enable-plug-in-recording-tone", base: 0x02500000, byte: 0x0A7, kind: SK::Bool }, + SF { key: "ranging-interval", base: 0x02500000, byte: 0x0A8, kind: SK::Uint { width: 1 } }, + SF { key: "bluetooth-mic-gain", base: 0x02500000, byte: 0x0A9, kind: SK::Uint { width: 1 } }, + SF { key: "bluetooth-speaker-gain", base: 0x02500000, byte: 0x0AA, kind: SK::Uint { width: 1 } }, + SF { key: "channel-number-mode", base: 0x02500000, byte: 0x0AB, kind: SK::Enum { width: 1, labels: &[(0, "Total"), (1, "Within Zone")] } }, + SF { key: "enable-show-current-contact", base: 0x02500000, byte: 0x0AC, kind: SK::Bool }, + SF { key: "auto-roaming-period", base: 0x02500000, byte: 0x0AD, kind: SK::Uint { width: 1 } }, + SF { key: "call-sign-color", base: 0x02500000, byte: 0x0AE, kind: SK::Enum { width: 1, labels: &[(0, "Orange"), (1, "Red"), (2, "Yellow"), (3, "Green"), (4, "Turquoise"), (5, "Blue"), (6, "White")] } }, + SF { key: "units", base: 0x02500000, byte: 0x0AF, kind: SK::Enum { width: 1, labels: &[(0, "Metric"), (1, "Archaic")] } }, + SF { key: "force-key-lock", base: 0x02500000, byte: 0x0B0, kind: SK::BoolBit { lsb: 4 } }, + SF { key: "lock-side-keys", base: 0x02500000, byte: 0x0B0, kind: SK::BoolBit { lsb: 3 } }, + SF { key: "lock-keyboard", base: 0x02500000, byte: 0x0B0, kind: SK::BoolBit { lsb: 1 } }, + SF { key: "lock-knob", base: 0x02500000, byte: 0x0B0, kind: SK::BoolBit { lsb: 0 } }, + SF { key: "roaming-delay", base: 0x02500000, byte: 0x0B1, kind: SK::Uint { width: 1 } }, + SF { key: "standby-text-color", base: 0x02500000, byte: 0x0B2, kind: SK::Enum { width: 1, labels: &[(0, "White"), (1, "Black"), (2, "Orange"), (3, "Red"), (4, "Yellow"), (5, "Green"), (6, "Turquoise"), (7, "Blue")] } }, + SF { key: "standby-background-image", base: 0x02500000, byte: 0x0B3, kind: SK::Enum { width: 1, labels: &[(0, "Default"), (1, "Custom 1"), (2, "Custom 2")] } }, + SF { key: "show-last-caller-on-boot", base: 0x02500000, byte: 0x0B4, kind: SK::Uint { width: 1 } }, + SF { key: "sms-standard", base: 0x02500000, byte: 0x0B5, kind: SK::Enum { width: 1, labels: &[(0, "Motorola"), (1, "Hytera"), (2, "ETSI DMR")] } }, + SF { key: "auto-repeater-b-direction", base: 0x02500000, byte: 0x0B6, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Positive"), (2, "Negative")] } }, + SF { key: "send-selected-analog-contact", base: 0x02500000, byte: 0x0B7, kind: SK::Uint { width: 1 } }, + SF { key: "enable-default-boot-channel", base: 0x02500000, byte: 0x0B8, kind: SK::Bool }, + SF { key: "boot-zone-vfo-a", base: 0x02500000, byte: 0x0B9, kind: SK::Uint { width: 1 } }, + SF { key: "boot-zone-vfo-b", base: 0x02500000, byte: 0x0BA, kind: SK::Uint { width: 1 } }, + SF { key: "default-boot-channel-vfo-a", base: 0x02500000, byte: 0x0BB, kind: SK::Uint { width: 1 } }, + SF { key: "default-boot-channel-vfo-b", base: 0x02500000, byte: 0x0BC, kind: SK::Uint { width: 1 } }, + SF { key: "roaming-zone-index", base: 0x02500000, byte: 0x0BD, kind: SK::Uint { width: 1 } }, + SF { key: "enable-repeater-check", base: 0x02500000, byte: 0x0BE, kind: SK::Bool }, + SF { key: "repeater-check-interval", base: 0x02500000, byte: 0x0BF, kind: SK::Uint { width: 1 } }, + SF { key: "repeater-check-retry-count", base: 0x02500000, byte: 0x0C0, kind: SK::Uint { width: 1 } }, + SF { key: "auto-roaming-start-condition", base: 0x02500000, byte: 0x0C1, kind: SK::Enum { width: 1, labels: &[(0, "Periodic"), (1, "Out-of-range")] } }, + SF { key: "show-menu-separator", base: 0x02500000, byte: 0x0C3, kind: SK::Uint { width: 1 } }, + SF { key: "keep-last-caller", base: 0x02500000, byte: 0x0C4, kind: SK::Uint { width: 1 } }, + SF { key: "channel-a-name-color", base: 0x02500000, byte: 0x0C5, kind: SK::Enum { width: 1, labels: &[(0, "Orange"), (1, "Red"), (2, "Yellow"), (3, "Green"), (4, "Turquoise"), (5, "Blue"), (6, "White")] } }, + SF { key: "repeater-check-oor-notification", base: 0x02500000, byte: 0x0C6, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Tone"), (2, "Voice")] } }, + SF { key: "backlight-duration-tx", base: 0x02500000, byte: 0x0C7, kind: SK::Uint { width: 1 } }, + SF { key: "enable-auto-roaming", base: 0x02500000, byte: 0x0C8, kind: SK::Bool }, + SF { key: "p1-short-press", base: 0x02500000, byte: 0x0C9, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "p2-short-press", base: 0x02500000, byte: 0x0CA, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "p3-short-press", base: 0x02500000, byte: 0x0CB, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "p4-short-press", base: 0x02500000, byte: 0x0CC, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "p5-short-press", base: 0x02500000, byte: 0x0CD, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "p6-short-press", base: 0x02500000, byte: 0x0CE, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "pa-short-press", base: 0x02500000, byte: 0x0CF, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "pb-short-press", base: 0x02500000, byte: 0x0D0, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "pc-short-press", base: 0x02500000, byte: 0x0D1, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "pd-short-press", base: 0x02500000, byte: 0x0D2, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "p1-long-press", base: 0x02500000, byte: 0x0D3, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "p2-long-press", base: 0x02500000, byte: 0x0D4, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "p3-long-press", base: 0x02500000, byte: 0x0D5, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "p4-long-press", base: 0x02500000, byte: 0x0D6, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "p5-long-press", base: 0x02500000, byte: 0x0D7, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "p6-long-press", base: 0x02500000, byte: 0x0D8, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "pa-long-press", base: 0x02500000, byte: 0x0D9, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "pb-long-press", base: 0x02500000, byte: 0x0DA, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "pc-long-press", base: 0x02500000, byte: 0x0DB, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "pd-long-press", base: 0x02500000, byte: 0x0DC, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "repeater-check-oor-notification-count", base: 0x02500000, byte: 0x0DE, kind: SK::Uint { width: 1 } }, + SF { key: "bluetooth-rx-delay", base: 0x02500000, byte: 0x0E3, kind: SK::Uint { width: 1 } }, + SF { key: "boot-text-line-1", base: 0x02500600, byte: 0x000, kind: SK::Text { len: 14 } }, + SF { key: "boot-text-line-2", base: 0x02500600, byte: 0x010, kind: SK::Text { len: 14 } }, + SF { key: "boot-password", base: 0x02500600, byte: 0x020, kind: SK::Text { len: 8 } }, + SF { key: "fm-aprs-tx-delay", base: 0x02501000, byte: 0x005, kind: SK::Uint { width: 1 } }, + SF { key: "fm-aprs-sub-tone-type", base: 0x02501000, byte: 0x006, kind: SK::Enum { width: 1, labels: &[(0, "None"), (1, "CTCSS"), (2, "DCS")] } }, + SF { key: "fm-aprs-tx-ctcss-tone", base: 0x02501000, byte: 0x007, kind: SK::Enum { width: 1, labels: &[(0, "62.5 Hz"), (1, "67.0 Hz"), (2, "69.3 Hz"), (3, "71.9 Hz"), (4, "74.4 Hz"), (5, "77.0 Hz"), (6, "79.7 Hz"), (7, "82.5 Hz"), (8, "85.4 Hz"), (9, "88.5 Hz"), (10, "91.5 Hz"), (11, "94.8 Hz"), (12, "97.4 Hz"), (13, "100.0 Hz"), (14, "103.5 Hz"), (15, "107.2 Hz"), (16, "110.9 Hz"), (17, "114.8 Hz"), (18, "118.8 Hz"), (19, "123.0 Hz"), (20, "127.3 Hz"), (21, "131.8 Hz"), (22, "136.5 Hz"), (23, "141.3 Hz"), (24, "146.2 Hz"), (25, "151.4 Hz"), (26, "156.7 Hz"), (27, "159.8 Hz"), (28, "162.2 Hz"), (29, "165.5 Hz"), (30, "167.9 Hz"), (31, "171.3 Hz"), (32, "173.8 Hz"), (33, "177.3 Hz"), (34, "179.9 Hz"), (35, "183.5 Hz"), (36, "186.2 Hz"), (37, "189.9 Hz"), (38, "192.8 Hz"), (39, "196.6 Hz"), (40, "199.5 Hz"), (41, "203.5 Hz"), (42, "206.5 Hz"), (43, "210.7 Hz"), (44, "218.1 Hz"), (45, "225.7 Hz"), (46, "229.1 Hz"), (47, "233.6 Hz"), (48, "241.8 Hz"), (49, "250.3 Hz"), (50, "254.1 Hz")] } }, + SF { key: "fm-aprs-dcs-code", base: 0x02501000, byte: 0x008, kind: SK::Uint { width: 2 } }, + SF { key: "manual-tx-interval", base: 0x02501000, byte: 0x00A, kind: SK::Uint { width: 1 } }, + SF { key: "automatic-tx-interval", base: 0x02501000, byte: 0x00B, kind: SK::Uint { width: 1 } }, + SF { key: "aprs-monitor-enable", base: 0x02501000, byte: 0x00C, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "On")] } }, + SF { key: "enable-fixed-location", base: 0x02501000, byte: 0x00D, kind: SK::Uint { width: 1 } }, + SF { key: "fixed-latitude-degree", base: 0x02501000, byte: 0x00E, kind: SK::Uint { width: 1 } }, + SF { key: "fixed-latitude-minute", base: 0x02501000, byte: 0x00F, kind: SK::Uint { width: 1 } }, + SF { key: "fixed-latitude-seconds", base: 0x02501000, byte: 0x010, kind: SK::Uint { width: 1 } }, + SF { key: "fixed-latitude-hemisphere", base: 0x02501000, byte: 0x011, kind: SK::Enum { width: 1, labels: &[(0, "North"), (1, "South")] } }, + SF { key: "fixed-longitude-degree", base: 0x02501000, byte: 0x012, kind: SK::Uint { width: 1 } }, + SF { key: "fixed-longitude-minute", base: 0x02501000, byte: 0x013, kind: SK::Uint { width: 1 } }, + SF { key: "fixed-longitude-seconds", base: 0x02501000, byte: 0x014, kind: SK::Uint { width: 1 } }, + SF { key: "fixed-longitude-hemisphere", base: 0x02501000, byte: 0x015, kind: SK::Enum { width: 1, labels: &[(0, "East"), (1, "West")] } }, + SF { key: "destination-call", base: 0x02501000, byte: 0x016, kind: SK::Text { len: 6 } }, + SF { key: "destination-ssid", base: 0x02501000, byte: 0x01C, kind: SK::Uint { width: 1 } }, + SF { key: "source-call", base: 0x02501000, byte: 0x01D, kind: SK::Text { len: 6 } }, + SF { key: "source-ssid", base: 0x02501000, byte: 0x023, kind: SK::Uint { width: 1 } }, + SF { key: "aprs-path", base: 0x02501000, byte: 0x024, kind: SK::Text { len: 21 } }, + SF { key: "aprs-symbol-table", base: 0x02501000, byte: 0x039, kind: SK::Text { len: 1 } }, + SF { key: "aprs-symbol", base: 0x02501000, byte: 0x03A, kind: SK::Text { len: 1 } }, + SF { key: "fm-aprs-transmit-power", base: 0x02501000, byte: 0x03B, kind: SK::Enum { width: 1, labels: &[(0, "Low"), (1, "Mid"), (2, "High"), (3, "Max")] } }, + SF { key: "prewave-delay", base: 0x02501000, byte: 0x03C, kind: SK::Uint { width: 1 } }, + SF { key: "dmr-aprs-destination-id-1", base: 0x02501000, byte: 0x050, kind: SK::BcdId }, + SF { key: "dmr-aprs-destination-id-2", base: 0x02501000, byte: 0x054, kind: SK::BcdId }, + SF { key: "dmr-aprs-destination-id-3", base: 0x02501000, byte: 0x058, kind: SK::BcdId }, + SF { key: "dmr-aprs-destination-id-4", base: 0x02501000, byte: 0x05C, kind: SK::BcdId }, + SF { key: "dmr-aprs-destination-id-5", base: 0x02501000, byte: 0x060, kind: SK::BcdId }, + SF { key: "dmr-aprs-destination-id-6", base: 0x02501000, byte: 0x064, kind: SK::BcdId }, + SF { key: "dmr-aprs-destination-id-7", base: 0x02501000, byte: 0x068, kind: SK::BcdId }, + SF { key: "dmr-aprs-destination-id-8", base: 0x02501000, byte: 0x06C, kind: SK::BcdId }, + SF { key: "dmr-aprs-call-type-1", base: 0x02501000, byte: 0x070, kind: SK::Enum { width: 1, labels: &[(0, "Private Call"), (1, "Group Call"), (2, "All Call")] } }, + SF { key: "dmr-aprs-call-type-2", base: 0x02501000, byte: 0x071, kind: SK::Enum { width: 1, labels: &[(0, "Private Call"), (1, "Group Call"), (2, "All Call")] } }, + SF { key: "dmr-aprs-call-type-3", base: 0x02501000, byte: 0x072, kind: SK::Enum { width: 1, labels: &[(0, "Private Call"), (1, "Group Call"), (2, "All Call")] } }, + SF { key: "dmr-aprs-call-type-4", base: 0x02501000, byte: 0x073, kind: SK::Enum { width: 1, labels: &[(0, "Private Call"), (1, "Group Call"), (2, "All Call")] } }, + SF { key: "dmr-aprs-call-type-5", base: 0x02501000, byte: 0x074, kind: SK::Enum { width: 1, labels: &[(0, "Private Call"), (1, "Group Call"), (2, "All Call")] } }, + SF { key: "dmr-aprs-call-type-6", base: 0x02501000, byte: 0x075, kind: SK::Enum { width: 1, labels: &[(0, "Private Call"), (1, "Group Call"), (2, "All Call")] } }, + SF { key: "dmr-aprs-call-type-7", base: 0x02501000, byte: 0x076, kind: SK::Enum { width: 1, labels: &[(0, "Private Call"), (1, "Group Call"), (2, "All Call")] } }, + SF { key: "dmr-aprs-call-type-8", base: 0x02501000, byte: 0x077, kind: SK::Enum { width: 1, labels: &[(0, "Private Call"), (1, "Group Call"), (2, "All Call")] } }, + SF { key: "roaming-support", base: 0x02501000, byte: 0x078, kind: SK::Enum { width: 1, labels: &[(0, "Disabled"), (1, "Enabled")] } }, + SF { key: "dmr-aprs-time-slot-1", base: 0x02501000, byte: 0x079, kind: SK::Enum { width: 1, labels: &[(0, "Channel slot"), (1, "Time slot 1"), (2, "Time slot 2")] } }, + SF { key: "dmr-aprs-time-slot-2", base: 0x02501000, byte: 0x07A, kind: SK::Enum { width: 1, labels: &[(0, "Channel slot"), (1, "Time slot 1"), (2, "Time slot 2")] } }, + SF { key: "dmr-aprs-time-slot-3", base: 0x02501000, byte: 0x07B, kind: SK::Enum { width: 1, labels: &[(0, "Channel slot"), (1, "Time slot 1"), (2, "Time slot 2")] } }, + SF { key: "dmr-aprs-time-slot-4", base: 0x02501000, byte: 0x07C, kind: SK::Enum { width: 1, labels: &[(0, "Channel slot"), (1, "Time slot 1"), (2, "Time slot 2")] } }, + SF { key: "dmr-aprs-time-slot-5", base: 0x02501000, byte: 0x07D, kind: SK::Enum { width: 1, labels: &[(0, "Channel slot"), (1, "Time slot 1"), (2, "Time slot 2")] } }, + SF { key: "dmr-aprs-time-slot-6", base: 0x02501000, byte: 0x07E, kind: SK::Enum { width: 1, labels: &[(0, "Channel slot"), (1, "Time slot 1"), (2, "Time slot 2")] } }, + SF { key: "dmr-aprs-time-slot-7", base: 0x02501000, byte: 0x07F, kind: SK::Enum { width: 1, labels: &[(0, "Channel slot"), (1, "Time slot 1"), (2, "Time slot 2")] } }, + SF { key: "dmr-aprs-time-slot-8", base: 0x02501000, byte: 0x080, kind: SK::Enum { width: 1, labels: &[(0, "Channel slot"), (1, "Time slot 1"), (2, "Time slot 2")] } }, + SF { key: "repeater-activation-delay", base: 0x02501000, byte: 0x081, kind: SK::Uint { width: 1 } }, + SF { key: "aprs-path-extended", base: 0x02501000, byte: 0x082, kind: SK::Text { len: 35 } }, + SF { key: "aprs-info-display-duration", base: 0x02501000, byte: 0x0A5, kind: SK::Uint { width: 1 } }, + SF { key: "fixed-altitude", base: 0x02501000, byte: 0x0A6, kind: SK::Uint { width: 2 } }, + SF { key: "aprs-filter-status", base: 0x02501000, byte: 0x0A8, kind: SK::BoolBit { lsb: 7 } }, + SF { key: "aprs-filter-nmea", base: 0x02501000, byte: 0x0A8, kind: SK::BoolBit { lsb: 6 } }, + SF { key: "aprs-filter-weather", base: 0x02501000, byte: 0x0A8, kind: SK::BoolBit { lsb: 5 } }, + SF { key: "aprs-filter-messages", base: 0x02501000, byte: 0x0A8, kind: SK::BoolBit { lsb: 4 } }, + SF { key: "aprs-filter-items", base: 0x02501000, byte: 0x0A8, kind: SK::BoolBit { lsb: 3 } }, + SF { key: "aprs-filter-objects", base: 0x02501000, byte: 0x0A8, kind: SK::BoolBit { lsb: 2 } }, + SF { key: "aprs-filter-mic-e", base: 0x02501000, byte: 0x0A8, kind: SK::BoolBit { lsb: 1 } }, + SF { key: "aprs-filter-position", base: 0x02501000, byte: 0x0A8, kind: SK::BoolBit { lsb: 0 } }, + SF { key: "aprs-filter-other", base: 0x02501000, byte: 0x0A9, kind: SK::BoolBit { lsb: 0 } }, + SF { key: "fm-aprs-bandwidth", base: 0x02501000, byte: 0x0AA, kind: SK::Enum { width: 1, labels: &[(0, "Narrow"), (1, "Wide")] } }, + SF { key: "enable-ax-25-crc-check", base: 0x02501000, byte: 0x0AB, kind: SK::Enum { width: 1, labels: &[(0, "On"), (1, "Off")] } }, + SF { key: "fm-aprs-frequency-1", base: 0x02501000, byte: 0x0AC, kind: SK::FreqBcd }, + SF { key: "fm-aprs-frequency-2", base: 0x02501000, byte: 0x0B0, kind: SK::FreqBcd }, + SF { key: "fm-aprs-frequency-3", base: 0x02501000, byte: 0x0B4, kind: SK::FreqBcd }, + SF { key: "fm-aprs-frequency-4", base: 0x02501000, byte: 0x0B8, kind: SK::FreqBcd }, + SF { key: "fm-aprs-frequency-5", base: 0x02501000, byte: 0x0BC, kind: SK::FreqBcd }, + SF { key: "fm-aprs-frequency-6", base: 0x02501000, byte: 0x0C0, kind: SK::FreqBcd }, + SF { key: "fm-aprs-frequency-7", base: 0x02501000, byte: 0x0C4, kind: SK::FreqBcd }, + SF { key: "fm-aprs-frequency-8", base: 0x02501000, byte: 0x0C8, kind: SK::FreqBcd }, + SF { key: "fm-aprs-message", base: 0x02501200, byte: 0x000, kind: SK::Text { len: 60 } }, + SF { key: "dmr-aprs-message", base: 0x02501280, byte: 0x000, kind: SK::Text { len: 32 } }, + SF { key: "talker-alias-priority", base: 0x02501400, byte: 0x01E, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Contact"), (2, "Over the air")] } }, + SF { key: "talker-alias-encoding", base: 0x02501400, byte: 0x01F, kind: SK::Enum { width: 1, labels: &[(0, "ISO 7"), (1, "ISO 8"), (2, "Unicode")] } }, + SF { key: "enable-weather-alarm", base: 0x02501400, byte: 0x020, kind: SK::Bool }, + SF { key: "enable-repeater", base: 0x02501400, byte: 0x021, kind: SK::Uint { width: 1 } }, + SF { key: "speakers", base: 0x02501400, byte: 0x023, kind: SK::Enum { width: 1, labels: &[(0, "Microphone"), (1, "Radio"), (2, "Both")] } }, + SF { key: "microphone-speaker-source", base: 0x02501400, byte: 0x025, kind: SK::Enum { width: 1, labels: &[(0, "Main Channel"), (1, "Sub Channel")] } }, + SF { key: "gps-modes", base: 0x02501400, byte: 0x026, kind: SK::Enum { width: 1, labels: &[(0, "GPS"), (1, "BDS"), (2, "Both")] } }, + SF { key: "enable-bluetooth-ptt-latch", base: 0x02501400, byte: 0x027, kind: SK::Bool }, + SF { key: "bluetooth-ptt-sleep-delay", base: 0x02501400, byte: 0x028, kind: SK::Uint { width: 1 } }, + SF { key: "fan-control", base: 0x02501400, byte: 0x029, kind: SK::Enum { width: 1, labels: &[(0, "PTT"), (1, "Temperature"), (2, "Both")] } }, + SF { key: "weather-channel-index", base: 0x02501400, byte: 0x02A, kind: SK::Uint { width: 1 } }, + SF { key: "manual-dial-group-call-hang-time", base: 0x02501400, byte: 0x02B, kind: SK::Uint { width: 1 } }, + SF { key: "manual-dial-private-call-hang-time", base: 0x02501400, byte: 0x02C, kind: SK::Uint { width: 1 } }, + SF { key: "channel-knob-short-press-function", base: 0x02501400, byte: 0x02D, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "channel-knob-long-press-function", base: 0x02501400, byte: 0x02E, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "channel-b-name-color", base: 0x02501400, byte: 0x02F, kind: SK::Enum { width: 1, labels: &[(0, "Orange"), (1, "Red"), (2, "Yellow"), (3, "Green"), (4, "Turquoise"), (5, "Blue"), (6, "White")] } }, + SF { key: "encryption-type", base: 0x02501400, byte: 0x030, kind: SK::Enum { width: 1, labels: &[(0, "Common/DMR"), (1, "AES/ARC4")] } }, + SF { key: "ui-mode", base: 0x02501400, byte: 0x031, kind: SK::Enum { width: 1, labels: &[(0, "Amateur"), (1, "Professional")] } }, + SF { key: "ste-duration", base: 0x02501400, byte: 0x032, kind: SK::Uint { width: 1 } }, + SF { key: "microphone-type", base: 0x02501400, byte: 0x033, kind: SK::Enum { width: 1, labels: &[(0, "AnyTone"), (1, "Generic")] } }, + SF { key: "zone-a-name-color", base: 0x02501400, byte: 0x034, kind: SK::Enum { width: 1, labels: &[(0, "Orange"), (1, "Red"), (2, "Yellow"), (3, "Green"), (4, "Turquoise"), (5, "Blue"), (6, "White")] } }, + SF { key: "zone-b-name-color", base: 0x02501400, byte: 0x035, kind: SK::Enum { width: 1, labels: &[(0, "Orange"), (1, "Red"), (2, "Yellow"), (3, "Green"), (4, "Turquoise"), (5, "Blue"), (6, "White")] } }, + SF { key: "disable-auto-shutoff-timer-reset-on-call", base: 0x02501400, byte: 0x036, kind: SK::Bool }, + SF { key: "show-color-code", base: 0x02501400, byte: 0x03B, kind: SK::BoolBit { lsb: 2 } }, + SF { key: "show-time-slot", base: 0x02501400, byte: 0x03B, kind: SK::BoolBit { lsb: 1 } }, + SF { key: "show-channel-type", base: 0x02501400, byte: 0x03B, kind: SK::BoolBit { lsb: 0 } }, + SF { key: "enable-fm-channel-free-tone", base: 0x02501400, byte: 0x03C, kind: SK::Bool }, + SF { key: "date-format", base: 0x02501400, byte: 0x03D, kind: SK::Enum { width: 1, labels: &[(0, "Big Endian"), (1, "Little Endian")] } }, + SF { key: "fm-mic-gain", base: 0x02501400, byte: 0x03E, kind: SK::Uint { width: 1 } }, + SF { key: "bt-handset-key-1-short-press", base: 0x02501400, byte: 0x03F, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "bt-handset-key-2-short-press", base: 0x02501400, byte: 0x040, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "bt-handset-key-3-short-press", base: 0x02501400, byte: 0x041, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "bt-handset-key-1-long-press", base: 0x02501400, byte: 0x042, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "bt-handset-key-2-long-press", base: 0x02501400, byte: 0x043, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "bt-handset-key-3-long-press", base: 0x02501400, byte: 0x044, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "bt-handset-mic-gain", base: 0x02501400, byte: 0x045, kind: SK::Uint { width: 1 } }, + SF { key: "bt-handset-backlight-duration", base: 0x02501400, byte: 0x047, kind: SK::Enum { width: 1, labels: &[(0, "Infinity"), (1, "5s"), (2, "10s"), (3, "15s"), (4, "20s"), (5, "25s"), (6, "1min"), (7, "2min"), (8, "3min"), (9, "4min"), (10, "5min"), (11, "15min"), (12, "35min"), (13, "45min"), (14, "1h")] } }, + SF { key: "up-down-key-function", base: 0x02501400, byte: 0x048, kind: SK::Enum { width: 1, labels: &[(0, "Channel/Frequency"), (1, "Volume")] } }, + SF { key: "enable-tot-reminder", base: 0x02501400, byte: 0x049, kind: SK::Bool }, + SF { key: "enable-gps-roaming", base: 0x02501400, byte: 0x04A, kind: SK::Bool }, + SF { key: "repeater-mode-colorcode", base: 0x02501400, byte: 0x04B, kind: SK::Enum { width: 1, labels: &[(0, "None"), (1, "Colorcode VFO A"), (2, "Colorcode VFO B")] } }, + SF { key: "repeater-vfo-a-timeslot", base: 0x02501400, byte: 0x04C, kind: SK::Enum { width: 1, labels: &[(0, "Any"), (1, "TS 1 -> 2"), (2, "TS 2 -> 1")] } }, + SF { key: "repeater-vfo-b-timeslot", base: 0x02501400, byte: 0x04D, kind: SK::Enum { width: 1, labels: &[(0, "Any"), (1, "TS 1 -> 2"), (2, "TS 2 -> 1")] } }, + SF { key: "bt-handset-rx-noise-reduction-level", base: 0x02501400, byte: 0x04E, kind: SK::Uint { width: 1 } }, + SF { key: "enable-bt-handset-device-shutdown", base: 0x02501400, byte: 0x04F, kind: SK::Uint { width: 1 } }, + SF { key: "bt-handset-key-1-very-long-press", base: 0x02501400, byte: 0x050, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "bt-handset-key-2-very-long-press", base: 0x02501400, byte: 0x051, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "bt-handset-key-3-very-long-press", base: 0x02501400, byte: 0x052, kind: SK::Enum { width: 1, labels: &[(0, "Off"), (1, "Voltage"), (2, "Power"), (3, "Talk Around"), (4, "Reverse"), (5, "DMR Encryption"), (6, "Call"), (7, "VFO/Channel"), (8, "Scan"), (9, "FM Broadcast"), (10, "Alarm"), (11, "Record Switch"), (12, "Record"), (13, "SMS"), (14, "Dial"), (15, "GPS Information"), (16, "Monitor"), (17, "Main Channel Toggle"), (18, "Hot Key 1"), (19, "Hot Key 2"), (20, "Hot Key 3"), (21, "Hot Key 4"), (22, "Hot Key 5"), (23, "Hot Key 6"), (24, "Work Alone"), (25, "Nuisance Delete"), (26, "DMR Monitor"), (27, "Sub Channel Toggle"), (28, "Priority Zone"), (29, "VFO Scan"), (30, "Mic Sound Quality"), (31, "Last Call Reply"), (32, "Toggle Channel Type"), (33, "Ranging"), (34, "Roaming"), (35, "Channel Ranging"), (36, "Set Max Volume"), (37, "Timeslot Toggle"), (38, "APRS Type Toggle"), (39, "Select Zone"), (40, "A Mute"), (41, "B Mute"), (42, "Roaming Settings"), (43, "APRS Settings"), (44, "Zone increment"), (45, "Zone decrement"), (46, "Exit"), (47, "Menu"), (48, "Cross Band Repeater"), (49, "Speaker Mode"), (50, "Channel Name"), (51, "Bluetooth"), (52, "GPS"), (53, "CTCSS/DTS Scan"), (54, "TBST"), (55, "APRS Send"), (56, "FM APRS Info"), (57, "GPS Roaming"), (58, "Noise Reduction RX"), (59, "Noise Reduction TX")] } }, + SF { key: "bt-handset-tx-noise-reduction-level", base: 0x02501400, byte: 0x053, kind: SK::Uint { width: 1 } }, + SF { key: "bt-handset-vox-level", base: 0x02501400, byte: 0x054, kind: SK::Uint { width: 1 } }, + SF { key: "bt-handset-vox-delay", base: 0x02501400, byte: 0x055, kind: SK::Uint { width: 1 } }, + SF { key: "bt-handset-volume-vfo-a", base: 0x02501400, byte: 0x056, kind: SK::Uint { width: 1 } }, + SF { key: "bt-handset-volume-vfo-b", base: 0x02501400, byte: 0x057, kind: SK::Uint { width: 1 } }, + SF { key: "header-repetitions", base: 0x02501400, byte: 0x080, kind: SK::Uint { width: 1 } }, +]; diff --git a/src-tauri/src/radios/anytone_atd578uv/testdata/d578_codeplug_records.bin b/src-tauri/src/radios/anytone_atd578uv/testdata/d578_codeplug_records.bin new file mode 100644 index 0000000..602ad83 Binary files /dev/null and b/src-tauri/src/radios/anytone_atd578uv/testdata/d578_codeplug_records.bin differ diff --git a/src-tauri/src/radios/anytone_atd578uv/testdata/d578_contacts.bin b/src-tauri/src/radios/anytone_atd578uv/testdata/d578_contacts.bin new file mode 100644 index 0000000..17fc0be Binary files /dev/null and b/src-tauri/src/radios/anytone_atd578uv/testdata/d578_contacts.bin differ diff --git a/src-tauri/src/radios/anytone_atd578uv/testdata/d578_settings.bin b/src-tauri/src/radios/anytone_atd578uv/testdata/d578_settings.bin new file mode 100644 index 0000000..c385fb6 Binary files /dev/null and b/src-tauri/src/radios/anytone_atd578uv/testdata/d578_settings.bin differ diff --git a/src-tauri/src/radios/anytone_atd578uv/tests.rs b/src-tauri/src/radios/anytone_atd578uv/tests.rs new file mode 100644 index 0000000..89b8cbb --- /dev/null +++ b/src-tauri/src/radios/anytone_atd578uv/tests.rs @@ -0,0 +1,354 @@ +//! The D578UV's encoders, held against the radio's own records. +//! +//! `testdata/d578_codeplug_records.bin` is the record regions of a read-only dump +//! of a real D578UV (fw 1.21): the channel and zone/scan present bitmaps, every +//! present channel record, every zone list and name, and every scan list. Its +//! settings block, boot text and radio IDs were left out on purpose. + +use std::collections::BTreeMap; + +use super::*; +use crate::radios::anytone_atd890uv::{BankPlan, WRITE_WINDOW}; +use crate::radios::anytone_atd890uv::parse_dump; + +const FIXTURE: &[u8] = include_bytes!("testdata/d578_codeplug_records.bin"); + +fn fixture() -> BTreeMap> { + parse_dump(FIXTURE).unwrap().into_iter().collect() +} + +fn present(fx: &BTreeMap>, base: u32, off: usize, bytes: usize, n: usize) -> Vec { + let window = &fx[&base]; + bitmap_indices(&window[off..off + bytes], n) +} + +fn channel_slots(fx: &BTreeMap>) -> Vec { + present(fx, CHANNEL_BITMAP_BASE, 0, CHANNEL_BITMAP_BYTES, MAX_CHANNELS) +} + +/// ★ The gate: every channel the radio wrote decodes and re-encodes onto its own +/// bytes with nothing changed. A field read from one offset and written to +/// another, a dropped bit, or a lossy conversion fails this on a real record. +#[test] +fn every_radio_channel_reencodes_byte_identically() { + let fx = fixture(); + let slots = channel_slots(&fx); + assert_eq!(slots.len(), 392, "fixture should carry the anchor radio's 392 channels"); + for slot in slots { + let original = &fx[&channel_addr(slot)]; + let edit = decode_channel(original).unwrap_or_else(|| panic!("slot {slot} blank")); + let mut rec = original.clone(); + encode_channel_into(&mut rec, &edit).unwrap(); + assert_eq!(&rec, original, "slot {} ({}) re-encoded differently", slot + 1, edit.name); + } +} + +/// The stronger half: encode each decoded channel onto the BLANK template and +/// check every byte the encoder owns lands where the radio put it. The bytes it +/// does not own come from the template, so they are compared only where the +/// template and the radio agree by construction. +#[test] +fn every_radio_channel_encodes_onto_the_template_at_the_radios_offsets() { + let fx = fixture(); + let owned: Vec = (0x00..0x10) + .chain(0x14..0x18) + .chain([CH_SCAN_LIST, CH_COLOR_CODE]) + .chain(CH_NAME..CH_NAME + NAME_CHARS) + .collect(); + for slot in channel_slots(&fx) { + let original = &fx[&channel_addr(slot)]; + let edit = decode_channel(original).unwrap(); + let mut rec = channel_record_template().to_vec(); + encode_channel_into(&mut rec, &edit).unwrap(); + for &o in &owned { + // A side with no tone leaves that side's code bytes at the template, + // which is what an unset field holds; the radio's stale value there + // is unreachable through the enable bits. + let tone_byte = (CH_TX_CTCSS..CH_RX_DCS + 2).contains(&o); + if tone_byte && rec[o] != original[o] { + continue; + } + assert_eq!(rec[o], original[o], "slot {} byte 0x{o:02X}", slot + 1); + } + assert_eq!(rec[CH_TIME_SLOT] & 1, original[CH_TIME_SLOT] & 1, "slot {} TS", slot + 1); + assert_eq!(decode_channel(&rec), Some(edit), "slot {} round trip", slot + 1); + } +} + +/// The template is what the radio wrote on most channels, byte for byte, outside +/// the fields the encoder sets. +#[test] +fn the_template_matches_the_radios_majority_record() { + let fx = fixture(); + let t = channel_record_template(); + let recs: Vec<&Vec> = channel_slots(&fx).iter().map(|&s| &fx[&channel_addr(s)]).collect(); + for o in [0x10, 0x11, 0x12, 0x13, 0x15, 0x16, 0x17, 0x18, 0x1A, 0x1C, 0x1D, 0x1E, 0x1F, 0x22] + .into_iter() + .chain(0x33..0x40) + { + let agree = recs.iter().filter(|r| r[o] == t[o]).count(); + assert!(agree * 2 > recs.len(), "template byte 0x{o:02X} disagrees with the radio"); + } +} + +#[test] +fn every_radio_zone_reencodes_byte_identically() { + let fx = fixture(); + let zones = present(&fx, ZONE_BITMAP_BASE, 0, ZONE_BITMAP_BYTES, MAX_ZONES); + assert_eq!(zones.len(), 21); + for z in zones { + let list = &fx[&(ZONE_LIST_BASE + z as u32 * ZONE_LIST_STEP)]; + let members: Vec = list + .chunks(2) + .map(|p| u16::from_le_bytes([p[0], p[1]])) + .take_while(|&m| m != 0xFFFF) + .collect(); + assert_eq!(&encode_zone_list(&members).unwrap(), list, "zone {} list", z + 1); + // The radio leaves stale bytes after a shortened name's NUL ("2 - NOCO + // MIXED\0S"); the encoder zero-fills the field, so the name is held to + // the bytes up to and including its terminator. + let name = &fx[&(ZONE_NAME_BASE + z as u32 * ZONE_NAME_STEP)]; + let enc = encode_zone_name(&decode_ascii_field(&name[..16])); + let end = name.iter().position(|&b| b == 0).unwrap_or(16); + assert_eq!(&enc[..end], &name[..end], "zone {} name", z + 1); + assert!(enc[end..].iter().all(|&b| b == 0), "zone {} name padding", z + 1); + } +} + +/// Scan lists: the owned fields re-encode onto the radio's own record unchanged. +/// (The radio leaves stale bytes after a shortened name's NUL; the encoder +/// zero-fills the field, so names are compared through the decoder.) +#[test] +fn every_radio_scan_list_reencodes_its_owned_fields() { + let fx = fixture(); + let lists = present(&fx, ZONE_BITMAP_BASE, 0x40, SCAN_BITMAP_BYTES, MAX_SCAN_LISTS); + assert_eq!(lists.len(), 21); + for i in lists { + let original = &fx[&scan_list_addr(i)]; + let fields = decode_scan_list(original); + let mut rec = original.clone(); + encode_scan_list_into(&mut rec, &fields).unwrap(); + assert_eq!(&rec[..SCAN_NAME], &original[..SCAN_NAME], "list {} header", i + 1); + assert_eq!(&rec[SCAN_MEMBERS..], &original[SCAN_MEMBERS..], "list {} members+tail", i + 1); + assert_eq!(decode_scan_list(&rec), fields, "list {} round trip", i + 1); + } +} + +/// The anchor radio's list 17 sits in the SECOND scan bank — the 16-per-bank +/// layout is measured, not assumed. +#[test] +fn scan_list_17_starts_the_second_bank() { + assert_eq!(scan_list_addr(15), 0x0108_1E00); + assert_eq!(scan_list_addr(16), 0x010C_0000); + let fx = fixture(); + assert_eq!(decode_scan_list(&fx[&scan_list_addr(16)]).name, "RMH719 SOUTH"); +} + +/// Channel addresses: 128 per 0x40000 bank, 0x40 apart. +#[test] +fn channel_addresses_follow_the_bank_layout() { + assert_eq!(channel_addr(0), 0x0080_0000); + assert_eq!(channel_addr(127), 0x0080_1FC0); + assert_eq!(channel_addr(128), 0x0084_0000); + assert_eq!(channel_addr(3999), 0x0080_0000 + 31 * 0x40000 + 31 * 0x40); +} + +#[test] +fn ctcss_table_has_no_150_hz() { + assert_eq!(CTCSS.len(), 51); + assert_eq!(ctcss_index(146.2), Some(24)); + assert_eq!(ctcss_index(151.4), Some(25)); + assert_eq!(ctcss_index(150.0), None); + assert_eq!(ctcss_index(254.1), Some(50)); +} + +#[test] +fn contact_records_round_trip_and_bank() { + let rec = encode_contact(1, 3_100, "NA Wide").unwrap(); + assert_eq!(rec.len(), CONTACT_REC_LEN); + assert_eq!(&rec[CONTACT_DMR_ID..CONTACT_DMR_ID + 4], &[0x00, 0x00, 0x31, 0x00]); + assert_eq!(decode_contact(&rec), (1, 3_100, "NA Wide".to_string())); + assert_eq!(contact_addr(999), CONTACT_BASE + 999 * 0x64); + assert_eq!(contact_addr(1000), CONTACT_BASE + 0x40000); + assert!(encode_contact(1, 16_777_216, "x").is_err()); +} + +#[test] +fn bitmaps() { + assert_eq!(present_bitmap(10, 4), vec![0xFF, 0x03, 0x00, 0x00]); + let c = contact_bitmap(10); + assert_eq!(c.len(), CONTACT_BITMAP_BYTES); + assert_eq!(&c[..3], &[0x00, 0xFC, 0xFF]); + assert_eq!(bitmap_indices(&[0x05], 8), vec![0, 2]); +} + +#[test] +fn names_are_ascii_and_padded() { + assert_eq!(encode_ascii_field("AB", 4), b"AB\0\0"); + assert_eq!(encode_ascii_field("Zürich 12345678901", 4), b"Zuri"); + assert_eq!(encode_ascii_field("東京", 4), b"??\0\0"); + assert_eq!(decode_ascii_field(b"AB \0\x41"), "AB"); +} + +/// Every region this driver writes is a named window; nothing in the general +/// settings block or the call-sign database is. +#[test] +fn write_guard_names_only_codeplug_regions() { + for a in [ + CHANNEL_BASE, + CHANNEL_BASE + 31 * BANK_STEP, + ZONE_LIST_BASE, + ZONE_LIST_BASE + 0x1C000, + ZONE_NAME_BASE, + 0x024C_0000, + SCAN_LIST_BASE, + SCAN_LIST_BASE + 15 * BANK_STEP, + CONTACT_BASE, + CONTACT_BASE + 9 * CONTACT_BANK_STEP + 0x14000, + CONTACT_INDEX_BASE + 0x8000, + CONTACT_BITMAP_BASE, + CONTACT_ID_TABLE, + CONTACT_ID_TABLE + 0x10000, + ] { + assert!(writable_window_name(a).is_some(), "0x{a:08X} should be writable"); + } + assert_eq!(writable_window_name(0x0250_0000), Some("settings")); + assert_eq!(writable_window_name(0x0258_0000), Some("radio IDs")); + for a in [ + 0x0250_4000, // GPS roaming zones, next to the settings window + 0x0258_4000, // past radio ID 250 + 0x0550_0000, // call-sign DB + CHANNEL_BASE + 0x4000, // inside a channel bank, past the records + CHANNEL_BASE + 31 * BANK_STEP + 0x4000, + SCAN_LIST_BASE + 16 * BANK_STEP, + ZONE_LIST_BASE + 0x20000, + ] { + assert!(writable_window_name(a).is_none(), "0x{a:08X} must not be writable"); + } +} + +/// Bank 31 holds only channels 3969-4000 (0x800 bytes), and the two VFO records +/// start exactly where channel 4000 ends — inside the same write window. A +/// program that touches bank 31 rewrites that window read-modify-write, so the +/// VFOs pass through untouched; nothing may ever address a slot past 4000. +#[test] +fn the_vfos_share_bank_31s_window() { + assert_eq!(channel_addr(MAX_CHANNELS - 1) + CH_REC_LEN as u32, 0x00FC_0800); + assert_eq!(channel_addr(MAX_CHANNELS - 1) & !(WRITE_WINDOW as u32 - 1), 0x00FC_0000); +} + +/// The anchor radio's contact regions (a second read-only dump): 38 contacts, +/// the index list, the inverted present bitmap and the contact ID table. +const CONTACTS: &[u8] = include_bytes!("testdata/d578_contacts.bin"); + +fn contacts_fixture() -> BTreeMap> { + parse_dump(CONTACTS).unwrap().into_iter().collect() +} + +/// ★ Every contact the radio wrote re-encodes to its own 100 bytes, and the +/// index list and present bitmap the program writes for 38 contacts are the +/// radio's own. +#[test] +fn every_radio_contact_reencodes_byte_identically() { + let fx = contacts_fixture(); + let recs = &fx[&CONTACT_BASE]; + let n = 38; + for i in 0..n { + let original = &recs[i * CONTACT_REC_LEN..(i + 1) * CONTACT_REC_LEN]; + let (call_type, id, name) = decode_contact(original); + assert_eq!(encode_contact(call_type, id, &name).unwrap(), original, "contact {i} ({name})"); + } + let index = &fx[&CONTACT_INDEX_BASE]; + for i in 0..n { + assert_eq!(&index[i * 4..i * 4 + 4], &(i as u32).to_le_bytes()); + } + assert_eq!(&index[n * 4..n * 4 + 4], &[0xFF; 4]); + assert_eq!(&fx[&CONTACT_BITMAP_BASE][..CONTACT_BITMAP_BYTES], &contact_bitmap(n)[..]); +} + +/// ★ Rebuilding the contact ID table from the radio's own contacts reproduces +/// the radio's table byte for byte — key encoding, sort order and terminator. +#[test] +fn the_contact_id_table_rebuilds_byte_identically() { + let fx = contacts_fixture(); + let recs = &fx[&CONTACT_BASE]; + let contacts: Vec<(u8, u32, u32)> = (0..38) + .map(|i| { + let (t, id, _) = decode_contact(&recs[i * CONTACT_REC_LEN..(i + 1) * CONTACT_REC_LEN]); + (t, id, i as u32) + }) + .collect(); + let table = encode_contact_id_table(&contacts); + assert_eq!(table.len(), 39 * CONTACT_ID_ENTRY); + assert_eq!(&fx[&CONTACT_ID_TABLE][..table.len()], &table[..]); +} + +/// ★ The half rule, as measured: a write into a 128 KB half carries every other +/// window of that half that holds data, unchanged — the GPS-roaming table that +/// a lone settings-window write erased on the anchor radio is carried — while +/// erased windows and untouched halves are left out. +#[test] +fn a_write_carries_the_rest_of_its_flash_half() { + let settings = 0x0250_0000; + let gps_roaming = 0x0250_4000; + let mut reads: BTreeMap> = (0..8) + .map(|i| (settings + i * WRITE_WINDOW as u32, vec![0xFF; WRITE_WINDOW])) + .collect(); + reads.get_mut(&settings).unwrap()[0] = 1; + reads.get_mut(&gps_roaming).unwrap()[..0x400].fill(0); + let mut patched = reads[&settings].clone(); + patched[0x1D] = 2; + let plan = BankPlan { base: settings, original: reads[&settings].clone(), patched }; + let plans = with_carried_windows(vec![plan], &reads).unwrap(); + let bases: Vec = plans.iter().map(|p| p.base).collect(); + assert_eq!(bases, vec![settings, gps_roaming], "the data window is carried, erased ones are not"); + assert_eq!(plans[1].original, plans[1].patched, "a carried window is rewritten unchanged"); + + // A window whose only data is the radio's marker is not carried, and the + // marker frame of a half's last window is never sent. + let last = settings + 0x1_C000; + reads.get_mut(&last).unwrap()[0x3FF0..].copy_from_slice(&[ + 0xFF, 0xFF, 0xFF, 0xFF, 0x22, 0x33, 0x44, 0x55, 0xFF, 0xFF, 0xFF, 0xFF, 0x55, 0x55, 0xAA, 0xAA, + ]); + let plan = BankPlan { base: settings, original: reads[&settings].clone(), patched: plans[0].patched.clone() }; + let again = with_carried_windows(vec![plan], &reads).unwrap(); + assert!(again.iter().all(|p| p.base != last), "a marker-only window is not carried"); + assert_eq!(sendable(last, &reads[&last]).len(), WRITE_WINDOW - MARKER_FRAME); + assert_eq!(sendable(settings, &reads[&settings]).len(), WRITE_WINDOW); + assert_eq!(plan_blocks(&again, true).iter().map(|(_, d)| d.len()).max(), Some(WRITE_WINDOW)); + + // A half nothing is written to is not touched at all. + assert!(with_carried_windows(Vec::new(), &reads).unwrap().is_empty()); + // A half that was not fully read is an error, not a silent skip. + reads.remove(&(settings + 0x1_C000)); + let plan = BankPlan { base: settings, original: vec![0; 1], patched: vec![1; 1] }; + assert!(with_carried_windows(vec![plan], &reads).is_err()); +} + +/// Halves are 128 KB; the second half of each block (the radio's copy of the +/// previous contents) is a half of its own and nothing here writes it. +#[test] +fn halves_are_128k_and_the_settings_half_holds_gps_roaming() { + assert_eq!(half_of(0x0250_4000), 0x0250_0000); + assert_eq!(half_of(0x0252_0000), 0x0252_0000); + assert_eq!(half_of(0x024C_4000), 0x024C_0000, "AES keys share the bitmaps' half"); +} + +/// A restore may carry windows this app never owns, but only inside a half it +/// does own. +#[test] +fn restore_guard_accepts_carried_windows_only_beside_an_owned_one() { + let w = vec![0u8; WRITE_WINDOW]; + assert!(check_restore_blocks(&[(0x0250_0000, w.clone()), (0x0250_4000, w.clone())]).is_ok()); + assert!(check_restore_blocks(&[(0x0250_4000, w.clone())]).is_err()); + assert!(check_restore_blocks(&[(0x0480_0000, w.clone()), (0x0484_0000, w)]).is_err()); +} + +/// A backup stores a half's last window without its marker frame; the restore +/// guard accepts that shape, and only at the end of a half. +#[test] +fn restore_guard_accepts_a_trimmed_last_window() { + let trimmed = vec![0u8; WRITE_WINDOW - MARKER_FRAME]; + assert!(check_restore_blocks(&[(0x0250_0000, vec![0; WRITE_WINDOW]), (0x0251_C000, trimmed.clone())]).is_ok()); + assert!(check_restore_blocks(&[(0x0250_0000, trimmed)]).is_err()); +} diff --git a/src-tauri/src/radios/anytone_atd890uv/mod.rs b/src-tauri/src/radios/anytone_atd890uv/mod.rs index 35e0086..a7e5d11 100644 --- a/src-tauri/src/radios/anytone_atd890uv/mod.rs +++ b/src-tauri/src/radios/anytone_atd890uv/mod.rs @@ -637,13 +637,42 @@ pub(crate) fn read_exact(p: &mut dyn SerialPort, n: usize) -> Result, St /// real failure — clear the buffer and try again over a few-second window, the /// same approach the UV-5R driver uses (`reident_with_retry`). pub(crate) fn enter_program_and_ident(p: &mut dyn SerialPort) -> Result, String> { + enter_program_and_ident_as(p, D890_IDENT) +} + +/// The model a session expects to find on the port: the token its ident must +/// carry, and the name its messages use. The handshake and the frames are shared +/// across the AnyTone family; the memory maps are not, so every session names +/// the one model whose map it is about to read or write. +#[derive(Debug, Clone, Copy)] +pub(crate) struct IdentExpect { + pub token: &'static str, + pub name: &'static str, + /// The hardware/firmware revision the driver's memory map was built for + /// (the ident's `Vnnn` field), when it must match exactly. `None` accepts + /// any revision. + pub revision: Option<&'static str>, +} + +pub(crate) const D890_IDENT: IdentExpect = IdentExpect { + token: "890", + name: "D890UV", + revision: None, +}; + +/// [`enter_program_and_ident`] for any family member: retry the handshake, and +/// refuse a radio whose ident does not carry `expect.token`. +pub(crate) fn enter_program_and_ident_as( + p: &mut dyn SerialPort, + expect: IdentExpect, +) -> Result, String> { const ATTEMPTS: usize = 4; let mut last = String::from("no response"); for attempt in 0..ATTEMPTS { let _ = p.clear(ClearBuffer::All); // Let the port settle after the clear before talking. std::thread::sleep(Duration::from_millis(200)); - match try_enter_program_and_ident(p) { + match try_enter_program_and_ident(p, expect) { Ok(ident) => return Ok(ident), Err(e) => { last = e; @@ -655,14 +684,18 @@ pub(crate) fn enter_program_and_ident(p: &mut dyn SerialPort) -> Result, } Err(format!( "could not enter PC mode after {ATTEMPTS} tries ({last}). \ - Make sure the D890UV is powered on and idle — if its screen already \ - shows \"PC Mode\", power-cycle the radio and try again." + Make sure the {} is powered on and idle — if its screen already \ + shows \"PC Mode\", power-cycle the radio and try again.", + expect.name )) } /// One handshake attempt: `PROGRAM`, verify the `QX\x06` reply, send `0x02`, and /// read the ident terminated by `0x06`. -pub(crate) fn try_enter_program_and_ident(p: &mut dyn SerialPort) -> Result, String> { +pub(crate) fn try_enter_program_and_ident( + p: &mut dyn SerialPort, + expect: IdentExpect, +) -> Result, String> { p.write_all(PROGRAM).estr()?; let ack = read_exact(p, PROGRAM_ACK.len())?; if ack != PROGRAM_ACK { @@ -685,18 +718,36 @@ pub(crate) fn try_enter_program_and_ident(p: &mut dyn SerialPort) -> Result bool { - ascii(ident).contains("890") + ident_is(ident, D890_IDENT) +} + +/// Does the ident carry the expected model token? +pub(crate) fn ident_is(ident: &[u8], expect: IdentExpect) -> bool { + ascii(ident).contains(expect.token) } /// Probe one region: read it in `READ_FRAME`-sized frames until done or the @@ -2450,6 +2501,13 @@ mod tests { assert!(e.contains("not 0x4000-aligned"), "{e}"); } + #[test] + fn a_required_revision_is_checked() { + let expect = IdentExpect { token: "578", name: "D578UV", revision: Some("V111") }; + assert!(ascii(b"ID578UV\0\x0eV111\0\0").contains(expect.revision.unwrap())); + assert!(!ascii(b"ID578UV\0\x0eV110\0\0").contains(expect.revision.unwrap())); + } + #[test] fn ident_match_requires_the_890_token() { assert!(ident_is_d890(b"ID890UV")); diff --git a/src-tauri/src/radios/anytone_atd890uv/program.rs b/src-tauri/src/radios/anytone_atd890uv/program.rs index 2d70f0d..63e37b8 100644 --- a/src-tauri/src/radios/anytone_atd890uv/program.rs +++ b/src-tauri/src/radios/anytone_atd890uv/program.rs @@ -35,7 +35,7 @@ use crate::radios::driver::{ use crate::util::truncate; use super::{ - commit_channel_writes, ctcss_index, decode_utf16_name, encode_channel_into_record, + commit_channel_writes, decode_utf16_name, encode_channel_into_record, encode_contact_record, encode_scan_list_record, encode_utf16_field, encode_zone_list_record, end_session, enter_program_and_ident, is_empty_record, new_channel_record_template, open_port, plans_from_pre_read, read_block, serialize_backup, @@ -102,7 +102,7 @@ pub(crate) struct AnytoneProgramPlan { } impl AnytoneProgramPlan { - fn preview(&self) -> CodeplugPreview { + pub(crate) fn preview(&self) -> CodeplugPreview { CodeplugPreview { radio: self.radio.clone(), channels: self.channels.len(), @@ -126,9 +126,14 @@ impl AnytoneProgramPlan { /// DCS code degrades THAT side to no-tone with a warning instead of failing /// the whole program. Returns the tone edit plus warnings (unprefixed — the /// caller adds the channel name). -fn ctcss_side(hz: Option, side: &str, warnings: &mut Vec) -> Option { +fn ctcss_side( + hz: Option, + side: &str, + table: &[f64], + warnings: &mut Vec, +) -> Option { let hz = hz?; - if ctcss_index(hz).is_none() { + if !table.iter().any(|&t| (t - hz).abs() < 0.05) { warnings.push(format!( "{side} CTCSS {hz} Hz is not in the radio's tone table — dropped" )); @@ -137,12 +142,18 @@ fn ctcss_side(hz: Option, side: &str, warnings: &mut Vec) -> Option Some(AnytoneSubTone::Ctcss(hz)) } -fn dcs_side(code: Option<&str>, side: &str, warnings: &mut Vec) -> Option { +fn dcs_side( + code: Option<&str>, + inverted: Option, + side: &str, + warnings: &mut Vec, +) -> Option { let code = code?; // Stored DCS codes are the octal notation (e.g. "411"); the radio wants the - // raw binary value (411₈ = 265). HW-confirmed on the D890UV. + // raw binary value (411₈ = 265). HW-confirmed on the D890UV. `inverted` is + // the offset a reversed-polarity code is stored with (the D578's +512). match u16::from_str_radix(code.trim(), 8) { - Ok(c) => Some(AnytoneSubTone::Dcs(c)), + Ok(c) => Some(AnytoneSubTone::Dcs(c + inverted.unwrap_or(0))), Err(_) => { warnings.push(format!( "{side} DCS code '{code}' is not a valid octal DCS code — dropped" @@ -152,7 +163,13 @@ fn dcs_side(code: Option<&str>, side: &str, warnings: &mut Vec) -> Optio } } +/// `dcs_invert` is the model's stored offset for a reversed-polarity DCS code; +/// `None` (the D890, never measured) ignores polarity as it always has. +#[allow(clippy::too_many_arguments)] fn invert_tone( + ctcss: &[f64], + dcs_invert: Option, + polarity: &str, tone_mode: Option<&str>, cross_mode: &str, up: Option, @@ -169,9 +186,11 @@ fn invert_tone( down, dcs_tx, dcs_rx, - "NN", - |hz, side, w| ctcss_side(Some(hz), side, w), - |code, _inverted, side, w| dcs_side(Some(code), side, w), + if dcs_invert.is_some() { polarity } else { "NN" }, + |hz, side, w| ctcss_side(Some(hz), side, ctcss, w), + |code, inverted, side, w| { + dcs_side(Some(code), dcs_invert.filter(|_| inverted), side, w) + }, ); (AnytoneToneEdit { tx: sides.tx, rx: sides.rx }, warnings) } @@ -296,6 +315,7 @@ fn invert_channel( ec: &ExpandedChannel, name: String, contact_index: u16, + limits: &PlanLimits, warnings: &mut Vec, ) -> AnytoneChannelEdit { let c = &ec.channel; @@ -306,6 +326,9 @@ fn invert_channel( AnytoneToneEdit::default() } else { let (tone, tone_warnings) = invert_tone( + limits.ctcss, + limits.dcs_invert, + &c.dcs_polarity, c.tone_mode.as_deref(), &c.cross_mode, c.ctcss_uplink, @@ -356,11 +379,50 @@ fn invert_channel( /// without either. Errors only on structural problems (wrong model, over /// capacity); per-channel issues become `skipped` entries or `warnings`. pub(crate) fn plan_program(payload: &CodeplugPayload) -> Result { + plan_program_for(payload, &D890_LIMITS) +} + +/// What the shared planner needs to know about one AnyTone model: which model +/// row it accepts, the radio's CTCSS table (the D578's has no 150.0 Hz, the +/// D890's does), and its record capacities. The plan itself is model-neutral — +/// slots, channel edits, member lists — and each driver encodes it onto its own +/// memory map. +pub(crate) struct PlanLimits { + pub model: &'static str, + pub ctcss: &'static [f64], + /// Offset a reversed-polarity DCS code is stored with, or `None` to ignore + /// polarity (the D890: its encoding of an inverted code was never measured). + pub dcs_invert: Option, + pub max_channels: usize, + pub max_zones: usize, + pub zone_max_channels: usize, + pub max_scan_lists: usize, + pub scan_max_channels: usize, + pub max_contacts: usize, +} + +pub(crate) const D890_LIMITS: PlanLimits = PlanLimits { + model: "AT-D890UV", + ctcss: super::CTCSS, + dcs_invert: None, + max_channels: NUM_BANKS * CH_PER_BANK, + max_zones: MAX_ZONES, + zone_max_channels: ZONE_MAX_CHANNELS, + max_scan_lists: MAX_SCAN_LISTS, + scan_max_channels: SCAN_MAX_CHANNELS, + max_contacts: MAX_CONTACTS_READ, +}; + +/// [`plan_program`] for any family member. +pub(crate) fn plan_program_for( + payload: &CodeplugPayload, + limits: &PlanLimits, +) -> Result { let model = payload.model; - if model.model != "AT-D890UV" { + if model.model != limits.model { return Err(format!( - "direct programming is only wired up for the AT-D890UV (codeplug targets {})", - model.display_name + "this planner programs the {} (codeplug targets {})", + limits.model, model.display_name )); } @@ -379,7 +441,7 @@ pub(crate) fn plan_program(payload: &CodeplugPayload) -> Result> = HashMap::new(); - let max_slots = (model.memory_channels.unwrap_or(4000) as usize).min(NUM_BANKS * CH_PER_BANK); + let max_slots = (model.memory_channels.unwrap_or(4000) as usize).min(limits.max_channels); let planned_names = plan_channel_names(expanded, model); for (i, ec) in expanded.iter().enumerate() { if let Some(reason) = exclusion_reason(&ec.channel, model) { @@ -429,7 +491,13 @@ pub(crate) fn plan_program(payload: &CodeplugPayload) -> Result Result MAX_CONTACTS_READ { + if contacts.len() > limits.max_contacts { return Err(format!( - "codeplug references {} talkgroups (radio limit {MAX_CONTACTS_READ})", - contacts.len() + "codeplug references {} talkgroups (radio limit {})", + contacts.len(), + limits.max_contacts )); } sort_contact_bank(&mut contacts, &mut channels); @@ -460,13 +529,14 @@ pub(crate) fn plan_program(payload: &CodeplugPayload) -> Result ZONE_MAX_CHANNELS { + if members.len() > limits.zone_max_channels { warnings.push(format!( - "zone '{}' has {} channels — truncated to the radio's {ZONE_MAX_CHANNELS}", + "zone '{}' has {} channels — truncated to the radio's {}", g.list_name, - members.len() + members.len(), + limits.zone_max_channels )); - members.truncate(ZONE_MAX_CHANNELS); + members.truncate(limits.zone_max_channels); } if g.list_name.chars().count() > ZONE_NAME_CHARS { warnings.push(format!( @@ -479,12 +549,13 @@ pub(crate) fn plan_program(payload: &CodeplugPayload) -> Result MAX_ZONES { + if zones.len() > limits.max_zones { warnings.push(format!( - "codeplug has {} zones — only the first {MAX_ZONES} are programmed", - zones.len() + "codeplug has {} zones — only the first {} are programmed", + zones.len(), + limits.max_zones )); - zones.truncate(MAX_ZONES); + zones.truncate(limits.max_zones); } // Scan lists assigned to the codeplug (resolver hands them over in id order @@ -516,12 +587,13 @@ pub(crate) fn plan_program(payload: &CodeplugPayload) -> Result SCAN_MAX_CHANNELS { + if members.len() > limits.scan_max_channels { warnings.push(format!( - "scan list '{list_name}' has {} channels — truncated to the radio's {SCAN_MAX_CHANNELS}", - members.len() + "scan list '{list_name}' has {} channels — truncated to the radio's {}", + members.len(), + limits.scan_max_channels )); - members.truncate(SCAN_MAX_CHANNELS); + members.truncate(limits.scan_max_channels); } // Resolve priority channels to slots; if the select byte enables a // priority whose channel isn't in this codeplug, downgrade + warn rather @@ -559,13 +631,14 @@ pub(crate) fn plan_program(payload: &CodeplugPayload) -> Result MAX_SCAN_LISTS { + if scan_lists.len() > limits.max_scan_lists { warnings.push(format!( - "codeplug has {} scan lists — only the first {MAX_SCAN_LISTS} are programmed", - scan_lists.len() + "codeplug has {} scan lists — only the first {} are programmed", + scan_lists.len(), + limits.max_scan_lists )); - scan_lists.truncate(MAX_SCAN_LISTS); - scan_list_ids.truncate(MAX_SCAN_LISTS); + scan_lists.truncate(limits.max_scan_lists); + scan_list_ids.truncate(limits.max_scan_lists); } // DB scan_list id → planned slot byte (index into `scan_lists`). let index_by_list_id: HashMap = scan_list_ids @@ -754,7 +827,7 @@ fn zone_count_in(name_window: &[u8]) -> usize { /// A present-bitmap for `n` records: bits `0..n` set (LSB = record 1), all /// higher bits cleared so stale records are removed. Shared by the zone bitmap /// at `ZONE_BITMAP_BASE` and the scan-list bitmap at `SCAN_BITMAP_BASE`. -fn present_bitmap(n: usize, bytes: usize) -> Vec { +pub(crate) fn present_bitmap(n: usize, bytes: usize) -> Vec { let mut bitmap = vec![0u8; bytes]; for z in 0..n.min(bytes * 8) { bitmap[z / 8] |= 1 << (z % 8); @@ -765,7 +838,7 @@ fn present_bitmap(n: usize, bytes: usize) -> Vec { /// The contact twin of [`present_bitmap`], but **inverted**: the contact bitmap /// at `CONTACT_BITMAP_BASE` marks a record present with a CLEAR bit, so absent /// records are 1s (the flash erase state). See that constant for the HW proof. -fn contact_present_bitmap(n: usize, bytes: usize) -> Vec { +pub(crate) fn contact_present_bitmap(n: usize, bytes: usize) -> Vec { let mut bitmap = vec![0xFFu8; bytes]; for z in 0..n.min(bytes * 8) { bitmap[z / 8] &= !(1 << (z % 8)); @@ -1080,6 +1153,18 @@ mod tests { // --- tone inversion --------------------------------------------------- + /// The planner's tone inversion against the D890's own CTCSS table. + fn invert_d890_tone( + tone_mode: Option<&str>, + cross_mode: &str, + up: Option, + down: Option, + dcs_tx: Option<&str>, + dcs_rx: Option<&str>, + ) -> (AnytoneToneEdit, Vec) { + invert_tone(super::super::CTCSS, None, "NN", tone_mode, cross_mode, up, down, dcs_tx, dcs_rx) + } + /// `invert_tone` must be the exact inverse of the import's /// `map_anytone_tone` for every branch of its truth table: radio sub-tones /// → CHIRP columns → back to the same sub-tones, warning-free. @@ -1101,7 +1186,7 @@ mod tests { ]; for (tx, rx) in cases { let (mode, up, down, dcs_tx, dcs_rx, cross) = map_anytone_tone(&tx, &rx); - let (tone, warnings) = invert_tone( + let (tone, warnings) = invert_d890_tone( Some(&mode), &cross, up, @@ -1118,27 +1203,27 @@ mod tests { #[test] fn tone_inversion_degrades_bad_input_with_warnings() { // Off-table CTCSS: that side dropped, warned. - let (tone, w) = invert_tone(Some("Tone"), "Tone->Tone", Some(101.0), None, None, None); + let (tone, w) = invert_d890_tone(Some("Tone"), "Tone->Tone", Some(101.0), None, None, None); assert_eq!((tone.tx, tone.rx), (None, None)); assert_eq!(w.len(), 1); // Non-numeric DCS: dropped, warned. - let (tone, w) = invert_tone(Some("DTCS"), "Tone->Tone", None, None, Some("D23N"), None); + let (tone, w) = invert_d890_tone(Some("DTCS"), "Tone->Tone", None, None, Some("D23N"), None); assert_eq!((tone.tx, tone.rx), (None, None)); assert!(!w.is_empty()); // DCS codes are octal: digits 8/9 are invalid → dropped, warned. - let (tone, w) = invert_tone(Some("DTCS"), "Tone->Tone", None, None, Some("298"), None); + let (tone, w) = invert_d890_tone(Some("DTCS"), "Tone->Tone", None, None, Some("298"), None); assert_eq!((tone.tx, tone.rx), (None, None)); assert!(!w.is_empty()); // Octal parse: stored "411" (the printed code) → raw 265 on the radio. - let (tone, w) = invert_tone(Some("DTCS"), "Tone->Tone", None, None, Some("411"), None); + let (tone, w) = invert_d890_tone(Some("DTCS"), "Tone->Tone", None, None, Some("411"), None); assert_eq!(tone.tx, Some(AnytoneSubTone::Dcs(265))); assert!(w.is_empty()); // Unknown tone mode: off + warned. - let (tone, w) = invert_tone(Some("Sepulcher"), "Tone->Tone", None, None, None, None); + let (tone, w) = invert_d890_tone(Some("Sepulcher"), "Tone->Tone", None, None, None, None); assert_eq!((tone.tx, tone.rx), (None, None)); assert_eq!(w.len(), 1); // Plain off: silent. - let (tone, w) = invert_tone(None, "Tone->Tone", Some(100.0), None, None, None); + let (tone, w) = invert_d890_tone(None, "Tone->Tone", Some(100.0), None, None, None); assert_eq!((tone.tx, tone.rx), (None, None)); assert!(w.is_empty()); } diff --git a/src-tauri/src/radios/anytone_atd890uv/settings.rs b/src-tauri/src/radios/anytone_atd890uv/settings.rs index 62b3da3..13dfac1 100644 --- a/src-tauri/src/radios/anytone_atd890uv/settings.rs +++ b/src-tauri/src/radios/anytone_atd890uv/settings.rs @@ -146,6 +146,17 @@ pub(crate) enum SK { Text { len: u16 }, /// Fixed-length UTF-16LE text (`chars` characters = 2×bytes), NUL-terminated. Utf16 { chars: u16 }, + /// A frequency as 4 bytes of big-endian packed BCD in 10 Hz units (the + /// D578UV's FM APRS frequencies), shown as MHz text. Unset reads as blank. + #[allow(dead_code)] + FreqBcd, + /// A frequency as a little-endian u32 in 10 Hz units (the D578UV's VFO-scan + /// and auto-repeater limits), shown as MHz text. + #[allow(dead_code)] + FreqLe, + /// A DMR ID as 4 bytes of big-endian packed BCD, shown as a number. + #[allow(dead_code)] + BcdId, } include!("anytone_settings_table.rs"); @@ -177,53 +188,7 @@ pub fn decode_anytone_settings(windows: &[(u32, Vec)]) -> Value { }; let mut map = Map::new(); for f in ANYTONE_SETTINGS_FIELDS { - let buf = region(f.base); - let off = f.byte as usize; - let v = match &f.kind { - SK::Uint { width } => Value::from(read_le(buf, off, *width as usize)), - SK::Bool => Value::from(buf.get(off).copied().unwrap_or(0) != 0), - SK::BoolBit { lsb } => { - Value::from((buf.get(off).copied().unwrap_or(0) >> lsb) & 1 == 1) - } - SK::Bits { lsb, bits } => { - let raw = (buf.get(off).copied().unwrap_or(0) >> lsb) & ((1u8 << bits) - 1); - Value::from(raw) - } - SK::Enum { width, labels } => { - let raw = read_le(buf, off, *width as usize); - let label = labels - .iter() - .find(|(v, _)| *v == raw) - .map(|(_, l)| l.to_string()) - .unwrap_or_else(|| raw.to_string()); - Value::from(label) - } - SK::Text { len } => { - let end = (off + *len as usize).min(buf.len()); - let s: String = buf - .get(off..end) - .unwrap_or(&[]) - .iter() - .take_while(|&&b| b != 0) - .map(|&b| b as char) - .collect(); - Value::from(s) - } - SK::Utf16 { chars } => { - let end = (off + *chars as usize * 2).min(buf.len()); - let units: Vec = buf - .get(off..end) - .unwrap_or(&[]) - .as_chunks::<2>() - .0 - .iter() - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .take_while(|&u| u != 0) - .collect(); - Value::from(String::from_utf16_lossy(&units)) - } - }; - map.insert(f.key.to_string(), v); + map.insert(f.key.to_string(), decode_field(region(f.base), f.byte as usize, &f.kind)); } // The radio's own DMR ID + name live in a separate region (Radio Id element @@ -244,6 +209,85 @@ pub fn decode_anytone_settings(windows: &[(u32, Vec)]) -> Value { Value::Object(map) } +/// Four bytes of 10 Hz BCD or LE frequency → MHz text: three decimals at least, +/// trailing zeros past that dropped ("145.000", "144.39125"). +pub(crate) fn mhz_text(units_10hz: u32) -> String { + let s = format!("{:.5}", units_10hz as f64 / 100_000.0); + let (whole, frac) = s.split_once('.').unwrap_or((&s, "")); + let frac = frac.trim_end_matches('0'); + format!("{whole}.{frac:0<3}") +} + +/// MHz text → 10 Hz units, or `None` for anything that is not a frequency. +pub(crate) fn parse_mhz(s: &str) -> Option { + let mhz: f64 = s.trim().parse().ok()?; + if !(0.0..=999.999_99).contains(&mhz) { + return None; + } + Some((mhz * 100_000.0).round() as u32) +} + +/// Decode one field from its region buffer. Missing bytes read as zero, so a +/// short or absent buffer never panics. +pub(crate) fn decode_field(buf: &[u8], off: usize, kind: &SK) -> Value { + match kind { + SK::Uint { width } => Value::from(read_le(buf, off, *width as usize)), + SK::Bool => Value::from(buf.get(off).copied().unwrap_or(0) != 0), + SK::BoolBit { lsb } => { + Value::from((buf.get(off).copied().unwrap_or(0) >> lsb) & 1 == 1) + } + SK::Bits { lsb, bits } => { + let raw = (buf.get(off).copied().unwrap_or(0) >> lsb) & ((1u8 << bits) - 1); + Value::from(raw) + } + SK::Enum { width, labels } => { + let raw = read_le(buf, off, *width as usize); + let label = labels + .iter() + .find(|(v, _)| *v == raw) + .map(|(_, l)| l.to_string()) + .unwrap_or_else(|| raw.to_string()); + Value::from(label) + } + SK::Text { len } => { + let end = (off + *len as usize).min(buf.len()); + let s: String = buf + .get(off..end) + .unwrap_or(&[]) + .iter() + .take_while(|&&b| b != 0) + .map(|&b| b as char) + .collect(); + Value::from(s) + } + SK::Utf16 { chars } => { + let end = (off + *chars as usize * 2).min(buf.len()); + let units: Vec = buf + .get(off..end) + .unwrap_or(&[]) + .as_chunks::<2>() + .0 + .iter() + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .take_while(|&u| u != 0) + .collect(); + Value::from(String::from_utf16_lossy(&units)) + } + SK::FreqBcd => { + let b = buf.get(off..off + 4).unwrap_or(&[]); + match bcd_be_decode(b) { + Some(0) | None => Value::from(""), + Some(v) => Value::from(mhz_text(v)), + } + } + SK::FreqLe => match read_le(buf, off, 4) { + 0 | u32::MAX => Value::from(""), + v => Value::from(mhz_text(v)), + }, + SK::BcdId => Value::from(bcd_be_decode(buf.get(off..off + 4).unwrap_or(&[])).unwrap_or(0)), + } +} + /// Write `width` little-endian bytes of `v` at `off` into `buf` (bounds-safe). pub(crate) fn write_le(buf: &mut [u8], off: usize, width: usize, v: u32) { for i in 0..width { @@ -329,13 +373,32 @@ pub(crate) fn encode_field_into(buf: &mut [u8], off: usize, kind: &SK, v: &Value if let Some(s) = v.as_str() { let n = *len as usize; if off + n <= buf.len() { - let bytes = s.as_bytes(); + // Folded rather than filtered: a non-ASCII byte written as 0 + // would END the string there ("Zürich" stored as "Z"). + let bytes = crate::util::fold_ascii(s); for i in 0..n { - buf[off + i] = bytes.get(i).copied().filter(u8::is_ascii).unwrap_or(0); + buf[off + i] = bytes.get(i).copied().unwrap_or(0); } } } } + SK::FreqBcd => { + if let (Some(units), true) = (v.as_str().and_then(parse_mhz), off + 4 <= buf.len()) { + buf[off..off + 4].copy_from_slice(&bcd_be_encode(units)); + } + } + SK::FreqLe => { + if let Some(units) = v.as_str().and_then(parse_mhz) { + write_le(buf, off, 4, units); + } + } + SK::BcdId => { + if let (Some(id), true) = (as_u32(v), off + 4 <= buf.len()) { + if id <= 99_999_999 { + buf[off..off + 4].copy_from_slice(&bcd_be_encode(id)); + } + } + } SK::Utf16 { chars } => { if let Some(s) = v.as_str() { let n = *chars as usize; diff --git a/src-tauri/src/radios/mod.rs b/src-tauri/src/radios/mod.rs index f3ca4ea..531ef65 100644 --- a/src-tauri/src/radios/mod.rs +++ b/src-tauri/src/radios/mod.rs @@ -29,6 +29,7 @@ //! first with no serial port: the radio enumerates as an STM32 DFU device and //! its 256 KiB codeplug moves over USB control transfers. +pub(crate) mod anytone_atd578uv; pub(crate) mod anytone_atd890uv; pub(crate) mod baofeng_uv5r; pub(crate) mod binteradio_bt9000; diff --git a/src-tauri/src/radios/registry.rs b/src-tauri/src/radios/registry.rs index eecf5dd..720b3d5 100644 --- a/src-tauri/src/radios/registry.rs +++ b/src-tauri/src/radios/registry.rs @@ -23,11 +23,12 @@ 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; 12] = [ +static DRIVERS: [&dyn RadioDriver; 13] = [ &super::baofeng_uv5r::DRIVER, &super::binteradio_bt9000::DRIVER, &super::tidradio_tdh3::DRIVER, &super::anytone_atd890uv::DRIVER, + &super::anytone_atd578uv::DRIVER, &super::yaesu_ft5d::DRIVER, &super::icom_id52::DRIVER, &super::kenwood_thd75::DRIVER, @@ -83,6 +84,7 @@ mod tests { "binteradio_bt9000", "tidradio_tdh3", "anytone_atd890uv", + "anytone_atd578uv", "yaesu_ft5d", "icom_id52", "kenwood_thd75", @@ -180,11 +182,11 @@ mod tests { /// call-sign DB, so `write_callsign_db` must reject the others by name /// rather than opening their port. #[test] - fn only_the_anytone_and_md380_write_a_callsign_db() { + fn only_the_anytones_and_md380_write_a_callsign_db() { for d in all_drivers() { assert_eq!( d.as_callsign_db_writer().is_some(), - matches!(d.key(), "anytone_atd890uv" | "tyt_md380"), + matches!(d.key(), "anytone_atd890uv" | "anytone_atd578uv" | "tyt_md380"), "{} call-sign DB capability", d.key() ); diff --git a/src-tauri/src/seed.rs b/src-tauri/src/seed.rs index e30ed54..4540fd2 100644 --- a/src-tauri/src/seed.rs +++ b/src-tauri/src/seed.rs @@ -695,6 +695,65 @@ fn models() -> Vec { non_channel_settings_schema: include_str!("anytone_settings_schema.json"), }, // -------------------------------------------------------- + // 3b. AnyTone AT-D578UV — DMR Tier I/II + analog FM tri-band + // MOBILE (issue #47). Same clone protocol as the D890UV over + // its USB port, but the D868/D878-family memory map with D578 + // overrides — see radios/anytone_atd578uv/. 4000 channels, + // 250 zones of up to 250 channels, 250 scan lists of 50, + // 16-char ASCII names, 10,000 contacts. APRS (FM + DMR), GPS, + // Bluetooth, cross-band repeat, air-band AM receive (a separate + // 100-channel memory, not channel slots). + // Bands: the anchor radio's band byte is 0x0E, which qdmr maps to + // TX and RX on 136-174, 220-225 and 400-520 (an unlocked unit; a + // US-locked 0x12 unit transmits on less, and refuses the rest on + // its own). The 174-220 and 225-400 gaps are real, so a channel + // there is not programmable. UNVERIFIED until the step-7 band probe. + // -------------------------------------------------------- + ModelSeed { + manufacturer: "AnyTone", + model: "AT-D578UV", + driver_key: Some("anytone_atd578uv"), + programming_ui: Some("anytone"), + display_name: "AnyTone AT-D578UV", + analog_capable: true, + dmr_capable: true, + dstar_capable: false, + ysf_capable: false, + nxdn_capable: false, + p25_capable: false, + m17_capable: false, + aprs_capable: true, + covers_hf: false, + covers_vhf: true, + covers_uhf: true, + covers_220: true, + covers_900: false, + freq_min: 136.0, + freq_max: 520.0, + tx_bands: Some("[[136.0,174.0],[220.0,225.0],[400.0,520.0]]"), + // 108-136 is the air band: AM receive only, in its own memory (the + // PLUS model; others ignore it). Listed so the codeplug screens call + // an air channel receive-only rather than out of range; the D578 + // planner routes AM there and skips anything else in that span. + rx_bands: Some("[[108.0,136.0],[136.0,174.0],[220.0,225.0],[400.0,520.0]]"), + memory_channels: 4000, + zones_supported: true, + max_zones: Some(250), + channels_per_zone: Some(250), + scan_lists_supported: true, + max_scan_lists: Some(250), + banks_supported: false, + max_name_length: 16, + export_format: "anytone_csv", + connection_type: "USB (CPS cable)", + // 264 of the 323 fields in the general, boot, APRS and extension + // blocks, GENERATED with the Rust table from the dmr-tools d578uv + // v1.21 descriptor (scratchpad/anytone_atd578uv/gen_d578_settings.py), + // plus the radio's own DMR ID and name. The 59 withheld are melodies, + // raw channel indices and one field the two sources disagree on. + non_channel_settings_schema: include_str!("anytone_d578_settings_schema.json"), + }, + // -------------------------------------------------------- // 4. Yaesu FT5D — C4FM (System Fusion) + analog FM dual-band // HT, 900 memories in 24 banks, 16-char alpha tags, 5 W. // Covers the FT5DR (US/Asia/AU) and FT5DE (EU) variants; diff --git a/src-tauri/src/thd75_settings_schema.json b/src-tauri/src/thd75_settings_schema.json index 63c0983..d7f3aad 100644 --- a/src-tauri/src/thd75_settings_schema.json +++ b/src-tauri/src/thd75_settings_schema.json @@ -768,13 +768,13 @@ "key": "bluetooth-enable", "label": "Bluetooth Enable", "type": "boolean", - "group": "Connections" + "group": "Bluetooth" }, { "key": "bluetooth-auto-connect", "label": "Bluetooth Auto Connect", "type": "boolean", - "group": "Connections" + "group": "Bluetooth" }, { "key": "pf1-key", @@ -1199,7 +1199,7 @@ "key": "bluetooth-device-name", "label": "Bluetooth Device Name", "type": "text", - "group": "Connections", + "group": "Bluetooth", "max_length": 16 }, { diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs index 49c5dd6..d6bad6d 100644 --- a/src-tauri/src/util.rs +++ b/src-tauri/src/util.rs @@ -108,6 +108,33 @@ pub fn truncate(s: &str, max: usize) -> String { s.trim().chars().take(max).collect::().trim_end().to_string() } +/// Text for a radio field that stores plain single-byte text: accents dropped +/// (NFD, combining marks removed — René -> Rene), a few letters with no +/// decomposition spelled out, anything still non-ASCII as `?`, control +/// characters dropped. +pub(crate) fn fold_ascii(text: &str) -> Vec { + use unicode_normalization::char::is_combining_mark; + use unicode_normalization::UnicodeNormalization; + let mut out = Vec::with_capacity(text.len()); + for c in text.nfd().filter(|c| !is_combining_mark(*c)) { + match c { + c if c.is_ascii_control() => {} + c if c.is_ascii() => out.push(c as u8), + 'ß' => out.extend_from_slice(b"ss"), + 'æ' => out.extend_from_slice(b"ae"), + 'Æ' => out.extend_from_slice(b"AE"), + 'ø' => out.push(b'o'), + 'Ø' => out.push(b'O'), + 'ł' => out.push(b'l'), + 'Ł' => out.push(b'L'), + 'đ' => out.push(b'd'), + 'Đ' => out.push(b'D'), + _ => out.push(b'?'), + } + } + out +} + /// Generate the long (<=16 char) display name from callsign + city. pub fn gen_name_long(callsign: &str, city: &str) -> String { let combined = format!("{} {}", callsign.trim(), city.trim()); diff --git a/src/components/codeplugs/AnytoneProgramDialog.tsx b/src/components/codeplugs/AnytoneProgramDialog.tsx index 6ffae77..56db949 100644 --- a/src/components/codeplugs/AnytoneProgramDialog.tsx +++ b/src/components/codeplugs/AnytoneProgramDialog.tsx @@ -26,7 +26,11 @@ import type { import { FooterClose, Modal } from "../overlays"; import { DmrUserPicker, useDmrUserPick } from "./DmrUserPicker"; import { Button, Spinner, Select } from "../ui"; -import type { ProgramDialogProps } from "../../lib/radioProgramming"; +import { + driverKeyOf, + useDriverCapabilities, + type ProgramDialogProps, +} from "../../lib/radioProgramming"; type Payload = "channels" | "userdb" | "profile"; @@ -40,7 +44,7 @@ function fileName(path: string): string { } /** - * Direct AnyTone AT-D890UV programming from the database: a FULL REPLACE of + * Direct AnyTone programming (AT-D890UV, AT-D578UV) from the database: a FULL REPLACE of * the radio's channel set (channels + zones + scan lists + DMR contacts) in * one PC-mode session. The radio commits on END and reboots (USB * re-enumerates), so Verify runs as a separate step after a rescan. @@ -60,6 +64,10 @@ export function AnytoneProgramDialog({ model, }: ProgramDialogProps) { const modelName = model?.display_name ?? "this radio"; + const driverKey = driverKeyOf(model); + // The payloads past the channel set are capabilities a model may not have + // yet; offering one its driver lacks would only fail at the radio. + const caps = useDriverCapabilities(driverKey || null); // Shown on every exit the dialog refuses while a radio operation is running — // the ✕ and the footer Close both. const LOCKED_HINT = `Closing now would hide the write, not stop it — the ${modelName} is still being written to.`; @@ -97,7 +105,9 @@ export function AnytoneProgramDialog({ // settings backup described as "the last program" contradicts the filename // right above it in the confirm, which is the one place that has to be exact. const latestKind = - latest && fileName(latest.backup_path).startsWith("anytone-settings-write-") + // Every model's settings backup carries "-settings-write-" after its own + // prefix (`anytone-` on the D890, `atd578uv-` on the D578). + latest && fileName(latest.backup_path).includes("-settings-write-") ? "settings write" : "channel-set program"; const backupSource = result @@ -134,13 +144,13 @@ export function AnytoneProgramDialog({ setRestored(null); setCallsignResult(null); refreshPorts(); - api.latestAnytoneProgram().then(setLatest).catch(() => {}); + if (driverKey) api.latestAnytoneProgram(driverKey).then(setLatest).catch(() => {}); api .programAnytonePreview(codeplugId) .then(setPreview) .catch((e) => setPreviewError(typeof e === "string" ? e : String(e))); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); + }, [open, driverKey]); const run = async (kind: Exclude, fn: () => Promise) => { @@ -199,14 +209,14 @@ export function AnytoneProgramDialog({ setBusy(null); // Refresh the newest on-disk image so Verify/Restore are reachable even // if the result above never came back. - api.latestAnytoneProgram().then(setLatest).catch(() => {}); + if (driverKey) api.latestAnytoneProgram(driverKey).then(setLatest).catch(() => {}); } }; const doVerify = () => run("verify", async () => { if (!expectedPath) return; - setVerify(await api.verifyAnytoneProgram(port, expectedPath)); + setVerify(await api.verifyAnytoneProgram(driverKey, port, expectedPath)); }); /// Restore is a FULL write that commits and reboots the radio, and it was the @@ -236,11 +246,11 @@ export function AnytoneProgramDialog({ if (!ok) return; await run("restore", async () => { setVerify(null); - setRestored(await api.restoreAnytoneBackup(port, backupPath)); + setRestored(await api.restoreAnytoneBackup(driverKey, port, backupPath)); }); }; - const payloads: { + const allPayloads: { id: Payload; icon: typeof ListChecks; label: string; @@ -260,16 +270,17 @@ export function AnytoneProgramDialog({ label: "Call-sign DB", detail: "DMR caller-ID database from your DMR Contacts library — prioritized, capacity-capped. Includes your talkgroups, which name them on the TX screen.", - disabled: false, + disabled: !caps?.write_callsign_db, }, { id: "profile", icon: Settings2, label: "Profile", detail: "Radio menu settings + DMR ID from the profile — read-modify-write, channels untouched.", - disabled: false, + disabled: !caps?.write_settings, }, ]; + const payloads = allPayloads.filter((p) => !p.disabled); return ( - Power the {modelName} on and connect its USB-C port. Programming is a{" "} + Power the {modelName} on and connect it over USB. Programming is a{" "} full replace of the selected payload — a backup of every region touched is saved first. When the write commits, the radio{" "} reboots and drops off USB: rescan, reselect the port, then diff --git a/src/lib/api.ts b/src/lib/api.ts index 1457fc7..a2419eb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -453,12 +453,14 @@ export const api = { // ---- "Program radio" from the DB (AT-D890UV full-replace channel set) ---- programAnytonePreview: (codeplugId: number) => invoke("program_anytone_preview", { codeplugId }), - verifyAnytoneProgram: (port: string, expectedPath: string) => - invoke("verify_anytone_program", { port, expectedPath }), - restoreAnytoneBackup: (port: string, backupPath: string) => - invoke("restore_anytone_backup", { port, backupPath }), - latestAnytoneProgram: () => - invoke("latest_anytone_program"), + // The AnyTone dialog serves more than one model; each names its driver so + // a session refuses the wrong radio and only that model's backups are found. + verifyAnytoneProgram: (driverKey: string, port: string, expectedPath: string) => + invoke("verify_anytone_program", { driverKey, port, expectedPath }), + restoreAnytoneBackup: (driverKey: string, port: string, backupPath: string) => + invoke("restore_anytone_backup", { driverKey, port, backupPath }), + latestAnytoneProgram: (driverKey: string) => + invoke("latest_anytone_program", { driverKey }), }; // Wrap an async action so any rejection surfaces as a toast. Returns the diff --git a/src/lib/profiles.ts b/src/lib/profiles.ts index 6c7eefa..1433fb2 100644 --- a/src/lib/profiles.ts +++ b/src/lib/profiles.ts @@ -138,6 +138,7 @@ export const SETTINGS_GROUPS = [ "Digital", "GPS", "APRS", + "Bluetooth", "Connections", ] as const;