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}