kenwood_thd75/protocol/
control.rs

1//! Control commands: AI, BY, DL, DW, RX, TX, LC, IO, BL, BE, VD, VG, VX.
2//!
3//! These commands control radio-wide functions including auto-info
4//! notifications, transmit/receive switching, lock control, battery level,
5//! frequency stepping, beep setting, and VOX (Voice-Operated Exchange)
6//! settings for hands-free operation.
7
8use crate::error::ProtocolError;
9use crate::types::Band;
10use crate::types::radio_params::{BatteryLevel, DetectOutputMode, VoxDelay, VoxGain};
11
12use super::Response;
13
14/// Parse a control command response from mnemonic and payload.
15///
16/// Returns `None` if the mnemonic is not a control command.
17pub(crate) fn parse_control(
18    mnemonic: &str,
19    payload: &str,
20) -> Option<Result<Response, ProtocolError>> {
21    match mnemonic {
22        "AI" => Some(parse_bool(payload, "AI").map(|enabled| Response::AutoInfo { enabled })),
23        "BY" => Some(parse_by(payload)),
24        "DL" => Some(parse_bool(payload, "DL").map(|enabled| Response::DualBand { enabled })),
25        "DW" => Some(Ok(Response::FrequencyDown)),
26        "BE" => Some(parse_bool(payload, "BE").map(|enabled| Response::Beep { enabled })),
27        "RX" | "TX" => Some(Ok(Response::Ok)),
28        "LC" => Some(parse_bool(payload, "LC").map(|locked| Response::Lock { locked })),
29        "IO" => Some(parse_u8_field(payload, "IO", "value").and_then(|raw| {
30            DetectOutputMode::try_from(raw)
31                .map(|value| Response::IoPort { value })
32                .map_err(|e| ProtocolError::FieldParse {
33                    command: "IO".into(),
34                    field: "value".into(),
35                    detail: e.to_string(),
36                })
37        })),
38        "BL" => Some(parse_bl(payload)),
39        "VD" => Some(parse_u8_field(payload, "VD", "delay").and_then(|raw| {
40            VoxDelay::try_from(raw)
41                .map(|delay| Response::VoxDelay { delay })
42                .map_err(|e| ProtocolError::FieldParse {
43                    command: "VD".into(),
44                    field: "delay".into(),
45                    detail: e.to_string(),
46                })
47        })),
48        "VG" => Some(parse_u8_field(payload, "VG", "gain").and_then(|raw| {
49            VoxGain::try_from(raw)
50                .map(|gain| Response::VoxGain { gain })
51                .map_err(|e| ProtocolError::FieldParse {
52                    command: "VG".into(),
53                    field: "gain".into(),
54                    detail: e.to_string(),
55                })
56        })),
57        "VX" => Some(parse_bool(payload, "VX").map(|enabled| Response::Vox { enabled })),
58        _ => None,
59    }
60}
61
62/// Parse a boolean field ("0" or "1").
63///
64/// Empty/missing value is treated as `false` (observed on BE, AI echo).
65fn parse_bool(payload: &str, cmd: &str) -> Result<bool, ProtocolError> {
66    match payload.trim() {
67        "" | "0" => Ok(false),
68        "1" => Ok(true),
69        _ => Err(ProtocolError::FieldParse {
70            command: cmd.to_owned(),
71            field: "value".to_owned(),
72            detail: format!("expected 0 or 1, got {payload:?}"),
73        }),
74    }
75}
76
77/// Parse a `u8` from a string field.
78fn parse_u8_field(s: &str, cmd: &str, field: &str) -> Result<u8, ProtocolError> {
79    s.parse::<u8>().map_err(|_| ProtocolError::FieldParse {
80        command: cmd.to_owned(),
81        field: field.to_owned(),
82        detail: format!("invalid u8: {s:?}"),
83    })
84}
85
86/// Split a `"band,value"` payload into (band, `value_str`).
87fn split_band_value<'a>(payload: &'a str, cmd: &str) -> Result<(Band, &'a str), ProtocolError> {
88    let parts: Vec<&str> = payload.splitn(2, ',').collect();
89    if parts.len() != 2 {
90        return Err(ProtocolError::FieldParse {
91            command: cmd.to_owned(),
92            field: "all".to_owned(),
93            detail: format!("expected band,value, got {payload:?}"),
94        });
95    }
96    let band_val = parse_u8_field(parts[0], cmd, "band")?;
97    let band = Band::try_from(band_val).map_err(|e| ProtocolError::FieldParse {
98        command: cmd.to_owned(),
99        field: "band".to_owned(),
100        detail: e.to_string(),
101    })?;
102    Ok((band, parts[1]))
103}
104
105/// Parse BL (battery level): bare `"level"` response.
106///
107/// 0=Empty (Red), 1=1/3 (Yellow), 2=2/3 (Green), 3=Full (Green),
108/// 4=Charging (USB power connected).
109///
110/// The radio sends `BL 3` for a polled read, but AI-mode unsolicited
111/// notifications may push `BL 0,3` (band-prefixed). Taking the last
112/// comma-separated field handles both formats.
113fn parse_bl(payload: &str) -> Result<Response, ProtocolError> {
114    let level_str = if let Some((_prefix, level)) = payload.split_once(',') {
115        level
116    } else {
117        payload
118    };
119    let raw = parse_u8_field(level_str.trim(), "BL", "level")?;
120    let level = BatteryLevel::try_from(raw).map_err(|e| ProtocolError::FieldParse {
121        command: "BL".to_owned(),
122        field: "level".to_owned(),
123        detail: e.to_string(),
124    })?;
125    Ok(Response::BatteryLevel { level })
126}
127
128/// Parse BY (busy): "band,busy".
129fn parse_by(payload: &str) -> Result<Response, ProtocolError> {
130    let (band, val_str) = split_band_value(payload, "BY")?;
131    let val = parse_u8_field(val_str, "BY", "busy")?;
132    Ok(Response::Busy {
133        band,
134        busy: val != 0,
135    })
136}