kenwood_thd75/protocol/
core.rs

1//! Core commands: FQ, FO, FV, PS, ID, PC, BC, VM, FR (FM radio).
2//!
3//! Provides serialization of write commands and parsing of responses for
4//! the 9 core CAT protocol commands.
5
6use crate::error::ProtocolError;
7use crate::types::Band;
8use crate::types::channel::{ChannelMemory, ChannelName, CrossToneType, FlashDigitalSquelch};
9use crate::types::frequency::Frequency;
10use crate::types::mode::{PowerLevel, ShiftDirection, StepSize};
11use crate::types::radio_params::VfoMemoryMode;
12use crate::types::tone::{CtcssMode, DcsCode, ToneCode};
13
14use super::{Command, Response};
15
16/// Number of comma-separated fields in an FO/FQ response (including band).
17const FO_FIELD_COUNT: usize = 21;
18
19/// Number of channel-data fields (everything after the band/channel prefix).
20pub(crate) const CHANNEL_FIELD_COUNT: usize = 20;
21
22/// Serialize a core write command into its wire-format body (without trailing `\r`).
23///
24/// Returns `None` if the command is not a core write command that needs
25/// special serialization (i.e. read commands already handled by the
26/// main dispatcher).
27pub(crate) fn serialize_core_write(cmd: &Command) -> Option<String> {
28    match cmd {
29        Command::SetFrequencyFull { band, channel } => {
30            Some(format!("FO {}", format_fo_fields(*band, channel)))
31        }
32        Command::SetFrequency { band, channel } => {
33            Some(format!("FQ {}", format_fo_fields(*band, channel)))
34        }
35        _ => None,
36    }
37}
38
39/// Parse a core command response from mnemonic and payload.
40///
41/// Returns `None` if the mnemonic is not a core command.
42pub(crate) fn parse_core(mnemonic: &str, payload: &str) -> Option<Result<Response, ProtocolError>> {
43    match mnemonic {
44        "ID" => Some(Ok(Response::RadioId {
45            model: payload.to_owned(),
46        })),
47        "FV" => Some(Ok(Response::FirmwareVersion {
48            version: payload.to_owned(),
49        })),
50        "PS" => Some(parse_bool_field(payload, "PS").map(|on| Response::PowerStatus { on })),
51        "PC" => Some(parse_pc(payload)),
52        "BC" => Some(parse_bc(payload)),
53        "VM" => Some(parse_vm(payload)),
54        "FR" => Some(parse_bool_field(payload, "FR").map(|enabled| Response::FmRadio { enabled })),
55        "FO" => Some(parse_fo_fq(payload, "FO")),
56        "FQ" => Some(parse_fq(payload)),
57        _ => None,
58    }
59}
60
61// ---------------------------------------------------------------------------
62// Helpers
63// ---------------------------------------------------------------------------
64
65/// Serialize just the 20 channel-data fields (no band/channel prefix).
66///
67/// Used by both FO/FQ (with a band prefix) and ME (with a channel prefix).
68pub(crate) fn serialize_channel_fields(ch: &ChannelMemory) -> String {
69    // Unpack byte[10] (flags_0a_raw) into individual wire fields [7..13]:
70    //   bit 7 = tone_enable, bit 6 = ctcss, bit 5 = dcs, bit 4 = cross-tone,
71    //   bit 3 = reverse, bit 2 = split, bits 1:0 = shift direction
72    let tone_en = (ch.flags_0a_raw >> 7) & 1;
73    let ctcss_en = (ch.flags_0a_raw >> 6) & 1;
74    let dcs_en = (ch.flags_0a_raw >> 5) & 1;
75    let cross_tone = (ch.flags_0a_raw >> 4) & 1;
76    let reverse = (ch.flags_0a_raw >> 3) & 1;
77    let shift_dir = ch.flags_0a_raw & 0x07; // bits 2:0 = split + shift combined
78
79    // Build exactly 20 comma-separated fields matching the real D75 FO wire format.
80    // Verified against hardware: see probes/fo_field_map.rs
81    format!(
82        "{},{},{:X},0,0,0,0,{},{},{},{},{},{},{:02},{:02},{:03},{},{},{},{:02}",
83        ch.rx_frequency.to_wire_string(), // [0]  freq
84        ch.tx_offset.to_wire_string(),    // [1]  offset
85        u8::from(ch.step_size),           // [2]  step (hex: TABLE C A=50kHz, B=100kHz)
86        //                                   [3]  tx_step=0
87        //                                   [4]  mode=0
88        //                                   [5]  fine=0
89        //                                   [6]  fine_step=0
90        tone_en,                       // [7]  tone encode
91        ctcss_en,                      // [8]  CTCSS
92        dcs_en,                        // [9]  DCS
93        cross_tone,                    // [10] cross-tone
94        reverse,                       // [11] reverse
95        shift_dir,                     // [12] shift direction
96        ch.tone_code.index(),          // [13] tone code
97        ch.ctcss_code.index(),         // [14] CTCSS code
98        ch.dcs_code.index(),           // [15] DCS code
99        u8::from(ch.cross_tone_combo), // [16] cross-tone combo
100        ch.urcall.as_str(),            // [17] URCALL
101        u8::from(ch.digital_squelch),  // [18] digital squelch
102        ch.data_mode,                  // [19] digital code
103    )
104}
105
106/// Format a `ChannelMemory` into the 21 comma-separated FO/FQ wire fields.
107fn format_fo_fields(band: Band, ch: &ChannelMemory) -> String {
108    format!("{},{}", u8::from(band), serialize_channel_fields(ch))
109}
110
111/// Parse a boolean field ("0" or "1").
112fn parse_bool_field(payload: &str, cmd: &str) -> Result<bool, ProtocolError> {
113    match payload {
114        "0" => Ok(false),
115        "1" => Ok(true),
116        _ => Err(ProtocolError::FieldParse {
117            command: cmd.to_owned(),
118            field: "value".to_owned(),
119            detail: format!("expected 0 or 1, got {payload:?}"),
120        }),
121    }
122}
123
124/// Parse a `u8` from a string field (decimal).
125fn parse_u8_field(s: &str, cmd: &str, field: &str) -> Result<u8, ProtocolError> {
126    s.parse::<u8>().map_err(|_| ProtocolError::FieldParse {
127        command: cmd.to_owned(),
128        field: field.to_owned(),
129        detail: format!("invalid u8: {s:?}"),
130    })
131}
132
133/// Parse a `u8` from a hex string field (e.g., step size in FO/ME uses TABLE C hex indices).
134///
135/// Confirmed by KI4LAX TABLE C (indices A=10, B=11) and ARFC-D75 decompilation
136/// (`NumberStyles.HexNumber` in response parsing).
137fn parse_hex_u8_field(s: &str, cmd: &str, field: &str) -> Result<u8, ProtocolError> {
138    u8::from_str_radix(s, 16).map_err(|_| ProtocolError::FieldParse {
139        command: cmd.to_owned(),
140        field: field.to_owned(),
141        detail: format!("invalid hex u8: {s:?}"),
142    })
143}
144
145/// Parse a PC (power level) response: "band,level".
146fn parse_pc(payload: &str) -> Result<Response, ProtocolError> {
147    let parts: Vec<&str> = payload.splitn(2, ',').collect();
148    if parts.len() != 2 {
149        return Err(ProtocolError::FieldParse {
150            command: "PC".to_owned(),
151            field: "all".to_owned(),
152            detail: format!("expected band,level, got {payload:?}"),
153        });
154    }
155    let band_val = parse_u8_field(parts[0], "PC", "band")?;
156    let band = Band::try_from(band_val).map_err(|e| ProtocolError::FieldParse {
157        command: "PC".to_owned(),
158        field: "band".to_owned(),
159        detail: e.to_string(),
160    })?;
161    let level_val = parse_u8_field(parts[1], "PC", "level")?;
162    let level = PowerLevel::try_from(level_val).map_err(|e| ProtocolError::FieldParse {
163        command: "PC".to_owned(),
164        field: "level".to_owned(),
165        detail: e.to_string(),
166    })?;
167    Ok(Response::PowerLevel { band, level })
168}
169
170/// Parse a VM (VFO/Memory mode) response: "band,mode".
171///
172/// Mode values: 0 = VFO, 1 = Memory, 2 = Call, 3 = WX.
173fn parse_vm(payload: &str) -> Result<Response, ProtocolError> {
174    let parts: Vec<&str> = payload.splitn(2, ',').collect();
175    if parts.len() != 2 {
176        return Err(ProtocolError::FieldParse {
177            command: "VM".to_owned(),
178            field: "all".to_owned(),
179            detail: format!("expected band,mode, got {payload:?}"),
180        });
181    }
182    let band_val = parse_u8_field(parts[0], "VM", "band")?;
183    let band = Band::try_from(band_val).map_err(|e| ProtocolError::FieldParse {
184        command: "VM".to_owned(),
185        field: "band".to_owned(),
186        detail: e.to_string(),
187    })?;
188    let mode_raw = parse_u8_field(parts[1], "VM", "mode")?;
189    let mode = VfoMemoryMode::try_from(mode_raw).map_err(|e| ProtocolError::FieldParse {
190        command: "VM".to_owned(),
191        field: "mode".to_owned(),
192        detail: e.to_string(),
193    })?;
194    Ok(Response::VfoMemoryMode { band, mode })
195}
196
197/// Parse a BC (band) response: single band number.
198fn parse_bc(payload: &str) -> Result<Response, ProtocolError> {
199    let band_val = parse_u8_field(payload, "BC", "band")?;
200    let band = Band::try_from(band_val).map_err(|e| ProtocolError::FieldParse {
201        command: "BC".to_owned(),
202        field: "band".to_owned(),
203        detail: e.to_string(),
204    })?;
205    Ok(Response::BandResponse { band })
206}
207
208/// Parse 20 channel-data fields into a [`ChannelMemory`].
209///
210/// `fields` must contain exactly 20 elements (the data fields after the
211/// band or channel prefix). `cmd` is used for error attribution.
212#[allow(clippy::too_many_lines, clippy::similar_names)]
213pub(crate) fn parse_channel_fields(
214    fields: &[&str],
215    cmd: &str,
216) -> Result<ChannelMemory, ProtocolError> {
217    if fields.len() != CHANNEL_FIELD_COUNT {
218        return Err(ProtocolError::FieldCount {
219            command: cmd.to_owned(),
220            expected: CHANNEL_FIELD_COUNT,
221            actual: fields.len(),
222        });
223    }
224
225    // ── Wire field layout (hardware-verified via MCP↔ME correlation) ──
226    //
227    // FO wire: 21 fields total (1 band + 20 channel). CHANNEL_FIELD_COUNT = 20.
228    // ME wire: 23 fields total (1 channel# + 20 channel + 2 ME-specific).
229    // The 20 channel fields (shared between FO and ME) are:
230    //
231    //  [0]  RX frequency (10 digits)         → byte[0..4]
232    //  [1]  TX offset / split TX freq        → byte[4..8]
233    //  [2]  RX step size                     → byte[8] high nibble
234    //  [3]  TX step size                     → byte[8] low nibble (always 0 on regular channels)
235    //  [4]  Mode (0=FM,1=DV,6=NFM,...)       → byte[9] upper nibble
236    //  [5]  Fine tuning (0/1)                → byte[9] bit 3 (always 0 on regular channels)
237    //  [6]  Fine step size                   → byte[9] bits 2:0 (always 0 on regular channels)
238    //  [7]  Tone encode enable (0/1)         → byte[10] bit 7
239    //  [8]  CTCSS enable (0/1)               → byte[10] bit 6
240    //  [9]  DCS enable (0/1)                 → byte[10] bit 5
241    // [10]  Cross-tone enable (0/1)          → byte[10] bit 4
242    // [11]  Reverse (0/1)                    → byte[10] bit 3
243    // [12]  Shift direction (bits 2:0)       → byte[10] bits 2:0 (0=simplex,1=+,2=-,4=split)
244    // [13]  Tone frequency code (2 digits)   → byte[11]
245    // [14]  CTCSS frequency code (2 digits)  → byte[12]
246    // [15]  DCS code (3 digits)              → byte[13]
247    // [16]  Cross-tone combination (0-3)     → byte[14] bits 5:4
248    // [17]  URCALL callsign                  → byte[15..39]
249    // [18]  Digital squelch (0-2)            → separate from channel struct
250    // [19]  Digital code (2 digits)          → separate from channel struct
251    //
252    // Verified across 20 real channels with zero mismatches between MCP binary
253    // and ME CAT response. See probes/fo_field_map.rs.
254
255    // field 0: RX frequency (10 digits)
256    let rx_frequency = Frequency::from_wire_string(fields[0])?;
257
258    // field 1: TX offset or split TX frequency (10 digits)
259    let tx_offset = Frequency::from_wire_string(fields[1])?;
260
261    // field 2: RX step size (hex per KI4LAX TABLE C: A=50kHz, B=100kHz)
262    let step_val = parse_hex_u8_field(fields[2], cmd, "step_size")?;
263    let step_size = StepSize::try_from(step_val).map_err(|e| ProtocolError::FieldParse {
264        command: cmd.to_owned(),
265        field: "step_size".to_owned(),
266        detail: e.to_string(),
267    })?;
268
269    // fields 3-6: byte[9] components (mode, fine tuning)
270    // Reconstruct byte[9] from wire fields for binary round-trip.
271    let _tx_step = parse_u8_field(fields[3], cmd, "tx_step")?;
272    let mode_val = parse_u8_field(fields[4], cmd, "mode")?;
273    let fine_tuning = parse_u8_field(fields[5], cmd, "fine_tuning")?;
274    let fine_step = parse_u8_field(fields[6], cmd, "fine_step")?;
275    let mode_flags_raw = ((mode_val & 0x07) << 4) | ((fine_tuning & 1) << 3) | (fine_step & 0x07);
276
277    // fields 7-12: byte[10] bits unpacked into 6 individual wire fields
278    // (verified: real D75 sends exactly 6 fields between fine_step and tone_code)
279    let tone_enable = parse_u8_field(fields[7], cmd, "tone_enable")? != 0;
280    let ctcss_enable = parse_u8_field(fields[8], cmd, "ctcss_enable")? != 0;
281    let dcs_enable = parse_u8_field(fields[9], cmd, "dcs_enable")? != 0;
282    let cross_tone = parse_u8_field(fields[10], cmd, "cross_tone")? != 0;
283    let reverse = parse_u8_field(fields[11], cmd, "reverse")? != 0;
284    // field[12]: shift direction — combines split + direction in one value
285    // (0=simplex, 1=shift+, 2=shift-, 4=split — byte[10] bits 2:0)
286    let shift_val = parse_u8_field(fields[12], cmd, "shift")?;
287    let shift = ShiftDirection::try_from(shift_val).map_err(|e| ProtocolError::FieldParse {
288        command: cmd.to_owned(),
289        field: "shift".to_owned(),
290        detail: e.to_string(),
291    })?;
292
293    // Reconstruct byte[10] from the individual wire fields for flags_0a_raw
294    let flags_0a_raw = (u8::from(tone_enable) << 7)
295        | (u8::from(ctcss_enable) << 6)
296        | (u8::from(dcs_enable) << 5)
297        | (u8::from(cross_tone) << 4)
298        | (u8::from(reverse) << 3)
299        | (shift_val & 0x07);
300
301    // Reconstruct the CTCSS mode from the ctcss_enable flag
302    let ctcss_mode = if ctcss_enable {
303        CtcssMode::try_from(1u8).unwrap_or_else(|_| CtcssMode::try_from(0u8).expect("valid"))
304    } else {
305        CtcssMode::try_from(0u8).expect("zero is valid")
306    };
307
308    // field 13: tone frequency code (2 digits)
309    let tone_val = parse_u8_field(fields[13], cmd, "tone_code")?;
310    let tone_code = ToneCode::new(tone_val).map_err(|e| ProtocolError::FieldParse {
311        command: cmd.to_owned(),
312        field: "tone_code".to_owned(),
313        detail: e.to_string(),
314    })?;
315
316    // field 14: CTCSS frequency code (2 digits)
317    let ct_code_val = parse_u8_field(fields[14], cmd, "ctcss_code")?;
318    let ctcss_code = ToneCode::new(ct_code_val).map_err(|e| ProtocolError::FieldParse {
319        command: cmd.to_owned(),
320        field: "ctcss_code".to_owned(),
321        detail: e.to_string(),
322    })?;
323
324    // field 15: DCS code (3 digits)
325    let dcs_val = parse_u8_field(fields[15], cmd, "dcs_code")?;
326    let dcs_code = DcsCode::new(dcs_val).map_err(|e| ProtocolError::FieldParse {
327        command: cmd.to_owned(),
328        field: "dcs_code".to_owned(),
329        detail: e.to_string(),
330    })?;
331
332    // field 16: cross-tone combination (byte[14] bits 5:4, range 0-3)
333    let ct_val = parse_u8_field(fields[16], cmd, "cross_tone_combo")?;
334    let cross_tone_combo =
335        CrossToneType::try_from(ct_val & 0x03).map_err(|e| ProtocolError::FieldParse {
336            command: cmd.to_owned(),
337            field: "cross_tone_combo".to_owned(),
338            detail: e.to_string(),
339        })?;
340
341    // field 17: URCALL callsign (may be empty)
342    let urcall = ChannelName::new(fields[17]).map_err(|e| ProtocolError::FieldParse {
343        command: cmd.to_owned(),
344        field: "urcall".to_owned(),
345        detail: e.to_string(),
346    })?;
347
348    // field 18: digital squelch (0=Off, 1=Code Squelch, 2=Callsign Squelch)
349    let ds_val = parse_u8_field(fields[18], cmd, "digital_squelch")?;
350    let digital_squelch =
351        FlashDigitalSquelch::try_from(ds_val & 0x03).map_err(|e| ProtocolError::FieldParse {
352            command: cmd.to_owned(),
353            field: "digital_squelch".to_owned(),
354            detail: e.to_string(),
355        })?;
356
357    // field 19: digital code (2 digits)
358    let data_mode = if fields.len() > 19 {
359        parse_u8_field(fields[19], cmd, "digital_code")?
360    } else {
361        0
362    };
363
364    Ok(ChannelMemory {
365        rx_frequency,
366        tx_offset,
367        step_size,
368        mode_flags_raw,
369        shift,
370        reverse,
371        tone_enable,
372        ctcss_mode,
373        dcs_enable,
374        cross_tone_reverse: cross_tone,
375        flags_0a_raw,
376        tone_code,
377        ctcss_code,
378        dcs_code,
379        cross_tone_combo,
380        digital_squelch,
381        urcall,
382        data_mode,
383    })
384}
385
386/// Parse an FQ response.
387///
388/// The radio may return either a short 2-field response (`band,frequency`)
389/// or a full 21-field response (same format as FO). Both are handled.
390fn parse_fq(payload: &str) -> Result<Response, ProtocolError> {
391    let fields: Vec<&str> = payload.split(',').collect();
392    if fields.len() == 2 {
393        // Short format: band, frequency
394        let band_val = parse_u8_field(fields[0], "FQ", "band")?;
395        let band = Band::try_from(band_val).map_err(|e| ProtocolError::FieldParse {
396            command: "FQ".to_owned(),
397            field: "band".to_owned(),
398            detail: e.to_string(),
399        })?;
400        let rx_frequency = Frequency::from_wire_string(fields[1])?;
401        let channel = ChannelMemory {
402            rx_frequency,
403            ..ChannelMemory::default()
404        };
405        return Ok(Response::Frequency { band, channel });
406    }
407    // Fall back to full 21-field FO-style parsing.
408    parse_fo_fq(payload, "FQ")
409}
410
411/// Parse the 21 comma-separated fields of an FO or FQ response.
412fn parse_fo_fq(payload: &str, cmd: &str) -> Result<Response, ProtocolError> {
413    let fields: Vec<&str> = payload.split(',').collect();
414    if fields.len() != FO_FIELD_COUNT {
415        return Err(ProtocolError::FieldCount {
416            command: cmd.to_owned(),
417            expected: FO_FIELD_COUNT,
418            actual: fields.len(),
419        });
420    }
421
422    // field 0: band
423    let band_val = parse_u8_field(fields[0], cmd, "band")?;
424    let band = Band::try_from(band_val).map_err(|e| ProtocolError::FieldParse {
425        command: cmd.to_owned(),
426        field: "band".to_owned(),
427        detail: e.to_string(),
428    })?;
429
430    // Remaining 20 fields are channel data
431    let channel = parse_channel_fields(&fields[1..], cmd)?;
432
433    match cmd {
434        "FO" => Ok(Response::FrequencyFull { band, channel }),
435        "FQ" => Ok(Response::Frequency { band, channel }),
436        _ => Err(ProtocolError::UnknownCommand(cmd.to_owned())),
437    }
438}