kenwood_thd75/
error.rs

1//! Error types for the kenwood-thd75 library.
2//!
3//! This module defines a three-level error hierarchy that mirrors the
4//! library's architecture:
5//!
6//! 1. **[`enum@Error`]** — The top-level enum returned by all public API
7//!    methods. It wraps the three lower-level categories below, plus
8//!    radio-specific conditions like [`Error::RadioError`] (`?` response),
9//!    [`Error::NotAvailable`] (`N` response), [`Error::Timeout`], and
10//!    MCP memory-related errors.
11//!
12//! 2. **[`TransportError`]** — Failures in the serial/Bluetooth I/O
13//!    layer. These occur when opening, reading from, or writing to the
14//!    serial port. A `TransportError` generally means the physical link
15//!    is broken or was never established. Wrapped by
16//!    [`Error::Transport`].
17//!
18//! 3. **[`ProtocolError`]** — Failures in CAT command framing and
19//!    parsing. These occur when the radio sends a response that cannot
20//!    be decoded: wrong field count, unparseable field value, unknown
21//!    command prefix, or a malformed frame (e.g., missing `\r`
22//!    terminator). Wrapped by [`Error::Protocol`].
23//!
24//! 4. **[`ValidationError`]** — Failures when a caller-supplied value
25//!    is outside the valid range for its type (e.g., band index > 13,
26//!    tone code > 49, power level > 3). These are raised **before** any
27//!    I/O occurs, during construction of typed wrappers. Wrapped by
28//!    [`Error::Validation`].
29//!
30//! All three lower-level error types implement `From` conversion into
31//! [`enum@Error`], so the `?` operator propagates them naturally.
32
33use std::time::Duration;
34
35use thiserror::Error;
36
37/// Top-level error type for all radio operations.
38#[derive(Debug, Error)]
39pub enum Error {
40    /// A transport-layer (serial/Bluetooth) error occurred.
41    #[error(transparent)]
42    Transport(#[from] TransportError),
43
44    /// A protocol-layer error occurred while parsing or encoding a command.
45    #[error(transparent)]
46    Protocol(#[from] ProtocolError),
47
48    /// A validation error occurred on a user-supplied value.
49    #[error(transparent)]
50    Validation(#[from] ValidationError),
51
52    /// The radio returned an error response (`?\r`).
53    #[error("radio returned error response")]
54    RadioError,
55
56    /// The radio returned "not available" (`N\r`) — command not supported in current mode.
57    #[error("command not available in current radio mode")]
58    NotAvailable,
59
60    /// A command timed out waiting for a response.
61    #[error("command timed out after {0:?}")]
62    Timeout(Duration),
63
64    /// The radio has not been identified yet; call `identify()` first.
65    #[error("radio not identified \u{2014} call identify() first")]
66    NotIdentified,
67
68    /// A write was attempted to a protected memory region (factory calibration).
69    #[error("write to protected page 0x{page:04X} denied (factory calibration region)")]
70    MemoryWriteProtected {
71        /// The page address that was denied.
72        page: u16,
73    },
74
75    /// The radio did not ACK a write command.
76    #[error("write to page 0x{page:04X} not acknowledged (expected ACK 0x06, got 0x{got:02X})")]
77    WriteNotAcknowledged {
78        /// The page address that was being written.
79        page: u16,
80        /// The byte received instead of ACK.
81        got: u8,
82    },
83
84    /// The supplied memory image has an invalid size.
85    #[error("invalid memory image size: {actual} bytes (expected {expected})")]
86    InvalidImageSize {
87        /// The actual size in bytes.
88        actual: usize,
89        /// The expected size in bytes.
90        expected: usize,
91    },
92}
93
94/// Errors originating from the transport layer (serial port / Bluetooth).
95#[derive(Debug, Error)]
96pub enum TransportError {
97    /// Failed to open the serial port at the given path.
98    #[error("failed to open serial port at {path}")]
99    Open {
100        /// The filesystem path that could not be opened.
101        path: String,
102        /// The underlying I/O error.
103        source: std::io::Error,
104    },
105
106    /// No matching serial device was found.
107    #[error("no matching serial device found")]
108    NotFound,
109
110    /// The serial connection was lost.
111    #[error("serial connection lost")]
112    Disconnected(
113        /// The underlying I/O error.
114        std::io::Error,
115    ),
116
117    /// A write to the serial port failed.
118    #[error("serial write failed")]
119    Write(
120        /// The underlying I/O error.
121        std::io::Error,
122    ),
123
124    /// A read from the serial port failed.
125    #[error("serial read failed")]
126    Read(
127        /// The underlying I/O error.
128        std::io::Error,
129    ),
130}
131
132/// Errors in the CAT protocol layer (framing, field parsing, etc.).
133#[derive(Debug, Error)]
134pub enum ProtocolError {
135    /// The radio returned an unknown command identifier.
136    #[error("unknown command: {0}")]
137    UnknownCommand(
138        /// The unrecognised command string.
139        String,
140    ),
141
142    /// A command response had the wrong number of fields.
143    #[error("command {command}: expected {expected} fields, got {actual}")]
144    FieldCount {
145        /// The two-letter command identifier.
146        command: String,
147        /// The expected number of fields.
148        expected: usize,
149        /// The actual number of fields received.
150        actual: usize,
151    },
152
153    /// A single field in a command response could not be parsed.
154    #[error("command {command}: failed to parse field {field}: {detail}")]
155    FieldParse {
156        /// The two-letter command identifier.
157        command: String,
158        /// The name or index of the problematic field.
159        field: String,
160        /// A human-readable description of the parse failure.
161        detail: String,
162    },
163
164    /// The response did not match the expected command.
165    #[error("unexpected response: expected {expected}, got {actual:?}")]
166    UnexpectedResponse {
167        /// The expected command prefix.
168        expected: String,
169        /// The raw bytes actually received.
170        actual: Vec<u8>,
171    },
172
173    /// A received frame was not valid (e.g. missing terminator).
174    #[error("malformed frame: {0:?}")]
175    MalformedFrame(
176        /// The raw bytes of the malformed frame.
177        Vec<u8>,
178    ),
179}
180
181/// Errors raised when a user-supplied value fails validation.
182#[derive(Debug, Error)]
183pub enum ValidationError {
184    /// The CTCSS tone code is outside the valid range 0-49.
185    #[error("tone code {0} out of range (must be 0-49)")]
186    ToneCodeOutOfRange(
187        /// The invalid tone code.
188        u8,
189    ),
190
191    /// The band index is outside the valid range 0-13.
192    #[error("band index {0} out of range (must be 0-13)")]
193    BandOutOfRange(
194        /// The invalid band index.
195        u8,
196    ),
197
198    /// The operating mode is outside the valid range 0-7.
199    #[error("mode {0} out of range (must be 0-7: FM/DV/AM/LSB/USB/CW/NFM/DR)")]
200    ModeOutOfRange(
201        /// The invalid mode value.
202        u8,
203    ),
204
205    /// The memory (flash) mode is outside the valid range 0-7.
206    #[error("memory mode {0} out of range (must be 0-7: FM/DV/AM/LSB/USB/CW/NFM/DR)")]
207    MemoryModeOutOfRange(
208        /// The invalid memory mode value.
209        u8,
210    ),
211
212    /// The power level is outside the valid range 0-3.
213    #[error("power level {0} out of range (must be 0-3: High/Medium/Low/ExtraLow)")]
214    PowerLevelOutOfRange(
215        /// The invalid power level.
216        u8,
217    ),
218
219    /// The tone mode is outside the valid range 0-2.
220    #[error("tone mode {0} out of range (must be 0-2: Off/CTCSS/DCS)")]
221    ToneModeOutOfRange(
222        /// The invalid tone mode.
223        u8,
224    ),
225
226    /// The shift direction is outside the valid 4-bit range 0-15.
227    #[error("shift direction {0} out of range (must be 0-15)")]
228    ShiftOutOfRange(
229        /// The invalid shift direction.
230        u8,
231    ),
232
233    /// The step size index is outside the valid range 0-11.
234    #[error("step size {0} out of range (must be 0-11)")]
235    StepSizeOutOfRange(
236        /// The invalid step size.
237        u8,
238    ),
239
240    /// The fine step index is outside the valid range 0-3.
241    #[error("fine step {0} out of range (must be 0-3)")]
242    FineStepOutOfRange(
243        /// The invalid fine step.
244        u8,
245    ),
246
247    /// The data speed is outside the valid range 0-1.
248    #[error("data speed {0} out of range (must be 0-1)")]
249    DataSpeedOutOfRange(
250        /// The invalid data speed.
251        u8,
252    ),
253
254    /// The lockout mode is outside the valid range 0-2.
255    #[error("lockout mode {0} out of range (must be 0-2)")]
256    LockoutOutOfRange(
257        /// The invalid lockout mode.
258        u8,
259    ),
260
261    /// The DCS code index is not in the valid code table.
262    #[error("DCS code index {0} not in valid code table")]
263    DcsCodeInvalid(
264        /// The invalid DCS code index.
265        u8,
266    ),
267
268    /// The channel name exceeds the maximum length of 8 characters.
269    #[error("channel name too long ({len} chars, max 8)")]
270    ChannelNameTooLong {
271        /// The actual length of the channel name.
272        len: usize,
273    },
274
275    /// The frequency is outside the valid range for the band.
276    #[error("frequency {0} Hz out of range for band")]
277    FrequencyOutOfRange(
278        /// The invalid frequency in Hz.
279        u32,
280    ),
281
282    /// The digital squelch code is outside the valid range 0-99.
283    #[error("digital squelch code {0} out of range (must be 0-99)")]
284    DigitalSquelchCodeOutOfRange(
285        /// The invalid digital squelch code.
286        u8,
287    ),
288
289    /// The cross-tone type is outside the valid range 0-3.
290    #[error("cross-tone type {0} out of range (must be 0-3)")]
291    CrossToneTypeOutOfRange(
292        /// The invalid cross-tone type value.
293        u8,
294    ),
295
296    /// The flash digital squelch mode is outside the valid range 0-2.
297    #[error("flash digital squelch mode {0} out of range (must be 0-2)")]
298    FlashDigitalSquelchOutOfRange(
299        /// The invalid flash digital squelch value.
300        u8,
301    ),
302
303    /// The channel number is outside the valid range.
304    #[error("channel {channel} out of range (max {max})")]
305    ChannelOutOfRange {
306        /// The invalid channel number.
307        channel: u16,
308        /// The maximum valid channel number.
309        max: u16,
310    },
311
312    /// A settings/configuration enum value is outside its valid range.
313    ///
314    /// Used for MCP binary settings types (backlight, EQ, language, etc.)
315    /// where adding a dedicated variant per type would be excessive.
316    #[error("{name} value {value} out of range ({detail})")]
317    SettingOutOfRange {
318        /// The setting type name (e.g., "backlight control").
319        name: &'static str,
320        /// The invalid raw value.
321        value: u8,
322        /// Human-readable valid range description (e.g., "must be 0-2").
323        detail: &'static str,
324    },
325
326    /// A runtime APRS wire-format value failed validation.
327    ///
328    /// Used by the `aprs` and `ax25-codec` crates for typed primitives
329    /// such as `Callsign`, `Latitude`, `Longitude`, `Course`, and
330    /// `MessageId` where the failing value may be too wide to fit in a
331    /// `u8`.
332    #[error("{field} out of range: {detail}")]
333    AprsWireOutOfRange {
334        /// Field name, e.g. `"Latitude"`, `"Callsign byte"`.
335        field: &'static str,
336        /// Human-readable explanation (e.g. `"length 7 exceeds max 6"`).
337        detail: &'static str,
338    },
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use std::time::Duration;
345
346    #[test]
347    fn validation_error_display() {
348        let err = ValidationError::ToneCodeOutOfRange(50);
349        assert_eq!(err.to_string(), "tone code 50 out of range (must be 0-49)");
350    }
351
352    #[test]
353    fn protocol_error_display() {
354        let err = ProtocolError::FieldCount {
355            command: "FO".to_owned(),
356            expected: 21,
357            actual: 19,
358        };
359        assert!(err.to_string().contains("21"));
360        assert!(err.to_string().contains("19"));
361    }
362
363    #[test]
364    fn error_from_validation() {
365        let val_err = ValidationError::BandOutOfRange(14);
366        let err: Error = val_err.into();
367        assert!(matches!(err, Error::Validation(_)));
368    }
369
370    #[test]
371    fn channel_out_of_range_display() {
372        let err = ValidationError::ChannelOutOfRange {
373            channel: 1200,
374            max: 1199,
375        };
376        assert!(err.to_string().contains("1200"));
377        assert!(err.to_string().contains("1199"));
378    }
379
380    #[test]
381    fn setting_out_of_range_display() {
382        let err = ValidationError::SettingOutOfRange {
383            name: "backlight control",
384            value: 5,
385            detail: "must be 0-2",
386        };
387        let msg = err.to_string();
388        assert!(msg.contains("backlight control"));
389        assert!(msg.contains('5'));
390        assert!(msg.contains("must be 0-2"));
391    }
392
393    #[test]
394    fn error_from_transport() {
395        let t_err = TransportError::NotFound;
396        let err: Error = t_err.into();
397        assert!(matches!(err, Error::Transport(_)));
398    }
399
400    #[test]
401    fn error_from_protocol() {
402        let p_err = ProtocolError::MalformedFrame(vec![0xFF]);
403        let err: Error = p_err.into();
404        assert!(matches!(err, Error::Protocol(_)));
405    }
406
407    #[test]
408    fn timeout_error_display() {
409        let err = Error::Timeout(Duration::from_secs(5));
410        assert!(err.to_string().contains("5s"));
411    }
412}