kenwood_thd75/protocol/
programming.rs

1//! Binary programming protocol for MCP (Memory Control Program) access.
2//!
3//! The TH-D75 supports a binary programming protocol entered via
4//! `0M PROGRAM`. This provides access to data not available through
5//! standard CAT commands, including channel display names.
6//!
7//! # Protocol
8//!
9//! - Entry: `0M PROGRAM\r` -> `0M\r`
10//! - Read: `R` + 2-byte page + `0x00 0x00` -> `W` + 4-byte address + 256-byte data (261 bytes)
11//! - ACK: `0x06`
12//! - Exit: `E`
13//!
14//! # Safety
15//!
16//! Entering programming mode makes the radio stop responding to normal
17//! CAT commands. Always exit programming mode when done.
18//!
19//! The `0M` handler is at firmware address `0xC002F01C`.
20
21/// Entry command to enter programming mode (ASCII).
22pub const ENTER_PROGRAMMING: &[u8] = b"0M PROGRAM\r";
23
24/// Expected response when entering programming mode (ASCII).
25pub const ENTER_RESPONSE: &[u8] = b"0M\r";
26
27/// ACK byte sent after receiving a data block.
28pub const ACK: u8 = 0x06;
29
30/// Exit byte to leave programming mode.
31pub const EXIT: u8 = b'E';
32
33// ---------------------------------------------------------------------------
34// Memory geometry
35// ---------------------------------------------------------------------------
36
37/// Size of data payload in each page (256 bytes).
38pub const PAGE_SIZE: usize = 256;
39
40/// Total number of pages in the radio memory (0x0000-0x07A2).
41pub const TOTAL_PAGES: u16 = 1955;
42
43/// Total radio memory in bytes (1955 * 256).
44pub const TOTAL_SIZE: usize = 500_480;
45
46/// Number of factory calibration pages at the end that must never be written.
47pub const FACTORY_CAL_PAGES: u16 = 2;
48
49/// Last page that may be safely written (inclusive).
50pub const MAX_WRITABLE_PAGE: u16 = TOTAL_PAGES - FACTORY_CAL_PAGES - 1; // 0x07A0 = 1952
51
52// ---------------------------------------------------------------------------
53// Memory region page addresses
54// ---------------------------------------------------------------------------
55
56/// First page of system settings (radio state, global config).
57pub const SETTINGS_START: u16 = 0x0000;
58/// Last page of system settings (inclusive).
59pub const SETTINGS_END: u16 = 0x001F;
60
61/// First page of channel flags (1200 entries x 4 bytes = 4800 bytes).
62pub const CHANNEL_FLAGS_START: u16 = 0x0020;
63/// Last page of channel flags (inclusive).
64pub const CHANNEL_FLAGS_END: u16 = 0x0032;
65
66/// First page of channel memory data (192 memgroups x 256 bytes).
67pub const CHANNEL_DATA_START: u16 = 0x0040;
68/// Last page of channel memory data (inclusive).
69pub const CHANNEL_DATA_END: u16 = 0x00FF;
70
71/// First page of channel names (1200 entries x 16 bytes).
72pub const CHANNEL_NAMES_START: u16 = 0x0100;
73/// Last page of channel names (inclusive).
74pub const CHANNEL_NAMES_END: u16 = 0x014A;
75
76/// First page of group names (within the names array, indices 1152-1181).
77pub const GROUP_NAMES_START: u16 = 0x0148;
78/// Last page of group names (inclusive).
79pub const GROUP_NAMES_END: u16 = 0x014A;
80
81/// APRS message status header page.
82pub const APRS_STATUS_PAGE: u16 = 0x0151;
83/// First page of APRS messages and settings.
84pub const APRS_START: u16 = 0x0152;
85
86/// First page of D-STAR repeater list and callsign list.
87pub const DSTAR_RPT_START: u16 = 0x02A1;
88
89/// First page of Bluetooth device data and remaining config.
90pub const BT_START: u16 = 0x04D1;
91
92// ---------------------------------------------------------------------------
93// Channel name constants
94// ---------------------------------------------------------------------------
95
96/// Starting page address for channel name data.
97pub const NAME_START_PAGE: u16 = CHANNEL_NAMES_START;
98
99/// Number of pages containing channel name data (63 pages, channels 0-1007).
100pub const NAME_PAGE_COUNT: u16 = 63;
101
102/// Number of pages containing all channel name data including extended entries
103/// (75 pages, channels 0-1199: scan edges, WX, call channels).
104pub const NAME_ALL_PAGE_COUNT: u16 = CHANNEL_NAMES_END - CHANNEL_NAMES_START + 1;
105
106/// Bytes per channel name entry.
107pub const NAME_ENTRY_SIZE: usize = 16;
108
109/// Channel name entries per 256-byte page (256 / 16 = 16).
110pub const NAMES_PER_PAGE: usize = 16;
111
112/// Maximum number of usable channel names (channels 0-999).
113pub const MAX_CHANNELS: usize = 1000;
114
115/// Total channel entries including extended channels (scan edges, WX, call).
116pub const TOTAL_CHANNEL_ENTRIES: usize = 1200;
117
118// ---------------------------------------------------------------------------
119// Channel data constants
120// ---------------------------------------------------------------------------
121
122/// Size of one channel memory record in bytes.
123pub const CHANNEL_RECORD_SIZE: usize = 40;
124
125/// Channels per memgroup (6 channels + 16 bytes padding = 256 bytes).
126pub const CHANNELS_PER_MEMGROUP: usize = 6;
127
128/// Padding bytes at the end of each memgroup.
129pub const MEMGROUP_PADDING: usize = 16;
130
131/// Number of memgroups (200 memgroups, 192 used for 1152 channels + 8 spare).
132pub const MEMGROUP_COUNT: usize = 192;
133
134// ---------------------------------------------------------------------------
135// Channel flag constants
136// ---------------------------------------------------------------------------
137
138/// Size of one channel flag record in bytes.
139pub const FLAG_RECORD_SIZE: usize = 4;
140
141/// Flag `used` value indicating an empty/unused channel slot.
142pub const FLAG_EMPTY: u8 = 0xFF;
143/// Flag `used` value indicating a VHF channel (freq < 150 MHz).
144pub const FLAG_VHF: u8 = 0x00;
145/// Flag `used` value indicating a 220 MHz channel (150-400 MHz).
146pub const FLAG_220: u8 = 0x01;
147/// Flag `used` value indicating a UHF channel (freq >= 400 MHz).
148pub const FLAG_UHF: u8 = 0x02;
149
150// ---------------------------------------------------------------------------
151// Wire protocol sizes
152// ---------------------------------------------------------------------------
153
154/// Total size of a W response (1 opcode + 4 address + 256 data).
155pub const W_RESPONSE_SIZE: usize = 261;
156
157/// Size of the W response header (W + 2-byte block address + 2-byte data size).
158pub const W_HEADER_SIZE: usize = 5;
159
160/// Build a binary read command for a given page address.
161///
162/// Format: `R` + 2-byte big-endian page + `0x00 0x00` (5 bytes total).
163#[must_use]
164pub const fn build_read_command(page: u16) -> [u8; 5] {
165    let addr = page.to_be_bytes();
166    [b'R', addr[0], addr[1], 0x00, 0x00]
167}
168
169/// Build a binary write command for a given page address with 256-byte data.
170///
171/// Format: `W` + 2-byte big-endian page + `0x00 0x00` + 256-byte data = 261 bytes.
172///
173/// The radio responds with a single ACK byte (`0x06`) on success.
174#[must_use]
175pub fn build_write_command(page: u16, data: &[u8; PAGE_SIZE]) -> Vec<u8> {
176    let addr = page.to_be_bytes();
177    let mut cmd = Vec::with_capacity(W_RESPONSE_SIZE);
178    cmd.extend_from_slice(&[b'W', addr[0], addr[1], 0x00, 0x00]);
179    cmd.extend_from_slice(data);
180    cmd
181}
182
183/// Returns `true` if the given page is within the factory calibration region
184/// that must never be overwritten.
185#[must_use]
186pub const fn is_factory_calibration_page(page: u16) -> bool {
187    page > MAX_WRITABLE_PAGE
188}
189
190/// Parse a write response from the radio.
191///
192/// Format: `W` + 4-byte address + 256-byte data = 261 bytes total.
193/// Bytes 1-2 are the page address (big-endian), bytes 3-4 are the
194/// offset (always zero).
195///
196/// Returns `(page_address, data_slice)` on success.
197///
198/// # Errors
199///
200/// Returns an error string if the response is too short or has an
201/// invalid marker byte.
202pub fn parse_write_response(buf: &[u8]) -> Result<(u16, &[u8]), String> {
203    if buf.len() < W_RESPONSE_SIZE {
204        return Err(format!(
205            "W response too short: {} bytes, expected {}",
206            buf.len(),
207            W_RESPONSE_SIZE
208        ));
209    }
210    if buf[0] != b'W' {
211        return Err(format!("expected W response marker, got 0x{:02X}", buf[0]));
212    }
213    // 4-byte address: bytes 1-2 are the page, bytes 3-4 are offset (always 0).
214    let page = u16::from_be_bytes([buf[1], buf[2]]);
215    // Data starts at byte 5, 256 bytes.
216    Ok((page, &buf[5..5 + PAGE_SIZE]))
217}
218
219/// Extract a channel name from a 16-byte entry.
220///
221/// Names are null-terminated ASCII/UTF-8 within a fixed 16-byte field.
222/// Returns the name as a trimmed string, stopping at the first null byte.
223#[must_use]
224pub fn extract_name(entry: &[u8]) -> String {
225    let end = entry
226        .iter()
227        .position(|&b| b == 0)
228        .unwrap_or(entry.len())
229        .min(NAME_ENTRY_SIZE);
230    String::from_utf8_lossy(&entry[..end]).trim().to_string()
231}
232
233/// Parse a 4-byte channel flag record.
234///
235/// Format: `[used, lockout_byte, group, 0xFF]`.
236///
237/// - `used`: `0xFF` = empty, `0x00` = VHF, `0x01` = 220, `0x02` = UHF
238/// - `lockout_byte` bit 0: `1` = locked out from scan
239/// - `group`: bank/group assignment (0-29)
240#[must_use]
241pub fn parse_channel_flag(bytes: &[u8]) -> Option<ChannelFlag> {
242    if bytes.len() < FLAG_RECORD_SIZE {
243        return None;
244    }
245    let used = bytes[0];
246    let lockout = bytes[1] & 0x01 != 0;
247    let group = bytes[2];
248    Some(ChannelFlag {
249        used,
250        lockout,
251        group,
252    })
253}
254
255/// A single channel's flag data (4 bytes per channel at MCP offset 0x2000+).
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub struct ChannelFlag {
258    /// Band indicator: `0xFF` = empty, `0x00` = VHF, `0x01` = 220 MHz, `0x02` = UHF.
259    pub used: u8,
260    /// `true` if the channel is locked out from scanning.
261    pub lockout: bool,
262    /// Bank/group assignment (0-29, 30 groups).
263    pub group: u8,
264}
265
266impl ChannelFlag {
267    /// Returns `true` if this channel slot is empty/unused.
268    #[must_use]
269    pub const fn is_empty(&self) -> bool {
270        self.used == FLAG_EMPTY
271    }
272
273    /// Serialize this flag back to a 4-byte record.
274    #[must_use]
275    pub const fn to_bytes(&self) -> [u8; FLAG_RECORD_SIZE] {
276        [
277            self.used,
278            if self.lockout { 0x01 } else { 0x00 },
279            self.group,
280            0xFF,
281        ]
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn build_read_command_page_256() {
291        let cmd = build_read_command(256);
292        assert_eq!(cmd, [b'R', 0x01, 0x00, 0x00, 0x00]);
293    }
294
295    #[test]
296    fn build_read_command_page_318() {
297        // Channel 999 is on page 256 + (999/16) = 256 + 62 = 318
298        let cmd = build_read_command(318);
299        assert_eq!(cmd, [b'R', 0x01, 0x3E, 0x00, 0x00]);
300    }
301
302    #[test]
303    fn build_read_command_page_zero() {
304        let cmd = build_read_command(0);
305        assert_eq!(cmd, [b'R', 0x00, 0x00, 0x00, 0x00]);
306    }
307
308    #[test]
309    fn build_write_command_format() {
310        let data = [0xAA; PAGE_SIZE];
311        let cmd = build_write_command(0x0100, &data);
312        assert_eq!(cmd.len(), W_RESPONSE_SIZE);
313        assert_eq!(cmd[0], b'W');
314        assert_eq!(cmd[1], 0x01); // page high byte
315        assert_eq!(cmd[2], 0x00); // page low byte
316        assert_eq!(cmd[3], 0x00); // offset high
317        assert_eq!(cmd[4], 0x00); // offset low
318        assert!(cmd[5..].iter().all(|&b| b == 0xAA));
319    }
320
321    #[test]
322    fn build_write_command_page_zero() {
323        let data = [0u8; PAGE_SIZE];
324        let cmd = build_write_command(0, &data);
325        assert_eq!(cmd[1], 0x00);
326        assert_eq!(cmd[2], 0x00);
327    }
328
329    #[test]
330    fn factory_calibration_page_detection() {
331        // Pages 0x07A1 and 0x07A2 are factory calibration
332        assert!(!is_factory_calibration_page(0x07A0)); // last writable
333        assert!(is_factory_calibration_page(0x07A1)); // factory cal
334        assert!(is_factory_calibration_page(0x07A2)); // factory cal
335        assert!(!is_factory_calibration_page(0x0000)); // system settings
336        assert!(!is_factory_calibration_page(0x0100)); // channel names
337    }
338
339    #[test]
340    fn parse_write_response_valid() {
341        let mut resp = vec![b'W', 0x01, 0x00, 0x00, 0x00]; // W + 4-byte address
342        resp.extend_from_slice(&[0x41; 256]); // 256 bytes of 'A'
343        assert_eq!(resp.len(), 261);
344        let (addr, data) = parse_write_response(&resp).unwrap();
345        assert_eq!(addr, 256);
346        assert_eq!(data.len(), 256);
347        assert!(data.iter().all(|&b| b == 0x41));
348    }
349
350    #[test]
351    fn parse_write_response_full_page() {
352        let mut resp = vec![b'W', 0x01, 0x3E, 0x00, 0x00]; // page 318
353        resp.extend_from_slice(&[0u8; 256]);
354        assert_eq!(resp.len(), 261);
355        let (addr, data) = parse_write_response(&resp).unwrap();
356        assert_eq!(addr, 318);
357        assert_eq!(data.len(), 256);
358    }
359
360    #[test]
361    fn parse_write_response_invalid_marker() {
362        let mut resp = vec![b'X', 0x01, 0x00, 0x00, 0x00];
363        resp.extend_from_slice(&[0u8; 256]);
364        assert!(parse_write_response(&resp).is_err());
365    }
366
367    #[test]
368    fn parse_write_response_empty() {
369        let resp: Vec<u8> = vec![];
370        assert!(parse_write_response(&resp).is_err());
371    }
372
373    #[test]
374    fn parse_write_response_too_short() {
375        let resp = vec![b'W', 0x01, 0x00, 0x00, 0x00, 0x41]; // only 6 bytes
376        assert!(parse_write_response(&resp).is_err());
377    }
378
379    #[test]
380    fn extract_name_null_terminated() {
381        let mut entry = [0u8; 16];
382        entry[..4].copy_from_slice(b"RPT1");
383        assert_eq!(extract_name(&entry), "RPT1");
384    }
385
386    #[test]
387    fn extract_name_full_length() {
388        let entry = *b"ForestCityPD\x00\x00\x00\x00";
389        assert_eq!(extract_name(&entry), "ForestCityPD");
390    }
391
392    #[test]
393    fn extract_name_empty() {
394        let entry = [0u8; 16];
395        assert_eq!(extract_name(&entry), "");
396    }
397
398    #[test]
399    fn extract_name_max_16_chars() {
400        let entry = *b"1234567890ABCDEF";
401        assert_eq!(extract_name(&entry), "1234567890ABCDEF");
402    }
403
404    #[test]
405    fn extract_name_trims_whitespace() {
406        let mut entry = [0u8; 16];
407        entry[..6].copy_from_slice(b"RPT1  ");
408        assert_eq!(extract_name(&entry), "RPT1");
409    }
410
411    #[test]
412    fn name_page_calculation() {
413        /// Compute the page address for a given channel number.
414        fn page_for(channel: u16) -> u16 {
415            NAME_START_PAGE + channel / 16
416        }
417        // Channel 0 is on page 256, slot 0
418        assert_eq!(page_for(0), 256);
419        // Channel 15 is still on page 256, slot 15
420        assert_eq!(page_for(15), 256);
421        // Channel 16 is on page 257, slot 0
422        assert_eq!(page_for(16), 257);
423        // Channel 999 is on page 256 + 62 = 318
424        assert_eq!(page_for(999), 318);
425    }
426
427    #[test]
428    fn total_name_slots() {
429        let total = NAME_PAGE_COUNT as usize * NAMES_PER_PAGE;
430        assert_eq!(total, 1008);
431        assert!(total >= MAX_CHANNELS);
432    }
433
434    #[test]
435    fn constants_consistent() {
436        assert_eq!(ENTER_PROGRAMMING, b"0M PROGRAM\r");
437        assert_eq!(ENTER_RESPONSE, b"0M\r");
438        assert_eq!(ACK, 0x06);
439        assert_eq!(EXIT, b'E');
440    }
441
442    #[test]
443    fn memory_geometry_consistent() {
444        assert_eq!(TOTAL_SIZE, TOTAL_PAGES as usize * PAGE_SIZE);
445        // These are compile-time truths but we assert them to catch
446        // regressions if someone edits the constants.
447        #[allow(clippy::assertions_on_constants)]
448        {
449            assert!(MAX_WRITABLE_PAGE < TOTAL_PAGES);
450        }
451        assert_eq!(FACTORY_CAL_PAGES, 2);
452    }
453
454    #[test]
455    fn region_boundaries_non_overlapping() {
456        // These are all compile-time truths verified at test time to
457        // catch regressions if the constants are ever changed.
458        #[allow(clippy::assertions_on_constants)]
459        {
460            // Settings end before flags start
461            assert!(SETTINGS_END < CHANNEL_FLAGS_START);
462            // Flags end before data starts
463            assert!(CHANNEL_FLAGS_END < CHANNEL_DATA_START);
464            // Data ends before names start
465            assert!(CHANNEL_DATA_END < CHANNEL_NAMES_START);
466            // Names end before APRS starts
467            assert!(CHANNEL_NAMES_END < APRS_START);
468            // APRS region before D-STAR
469            assert!(APRS_START < DSTAR_RPT_START);
470            // D-STAR before Bluetooth
471            assert!(DSTAR_RPT_START < BT_START);
472        }
473    }
474
475    #[test]
476    fn channel_flag_parse_vhf() {
477        let bytes = [FLAG_VHF, 0x00, 0x05, 0xFF];
478        let flag = parse_channel_flag(&bytes).unwrap();
479        assert!(!flag.is_empty());
480        assert!(!flag.lockout);
481        assert_eq!(flag.group, 5);
482        assert_eq!(flag.used, FLAG_VHF);
483    }
484
485    #[test]
486    fn channel_flag_parse_empty() {
487        let bytes = [FLAG_EMPTY, 0x00, 0x00, 0xFF];
488        let flag = parse_channel_flag(&bytes).unwrap();
489        assert!(flag.is_empty());
490    }
491
492    #[test]
493    fn channel_flag_parse_locked_out() {
494        let bytes = [FLAG_UHF, 0x01, 0x0A, 0xFF];
495        let flag = parse_channel_flag(&bytes).unwrap();
496        assert!(!flag.is_empty());
497        assert!(flag.lockout);
498        assert_eq!(flag.group, 10);
499    }
500
501    #[test]
502    fn channel_flag_round_trip() {
503        let flag = ChannelFlag {
504            used: FLAG_220,
505            lockout: true,
506            group: 15,
507        };
508        let bytes = flag.to_bytes();
509        let parsed = parse_channel_flag(&bytes).unwrap();
510        assert_eq!(parsed, flag);
511    }
512
513    #[test]
514    fn channel_flag_too_short() {
515        let bytes = [0xFF, 0x00, 0x00]; // only 3 bytes
516        assert!(parse_channel_flag(&bytes).is_none());
517    }
518}