aranet_core/
error.rs

1//! Error types for aranet-core.
2//!
3//! This module defines all error types that can occur when communicating with
4//! Aranet devices via Bluetooth Low Energy.
5//!
6//! # Error Recovery Strategies
7//!
8//! Different errors require different recovery approaches. This guide helps you
9//! choose the right strategy for each error type.
10//!
11//! ## Retry vs Reconnect
12//!
13//! | Error Type | Strategy | Rationale |
14//! |------------|----------|-----------|
15//! | [`Error::Timeout`] | Retry (2-3 times) | Transient BLE congestion |
16//! | [`Error::Bluetooth`] | Retry, then reconnect | May be transient or connection lost |
17//! | [`Error::NotConnected`] | Reconnect | Connection was lost |
18//! | [`Error::ConnectionFailed`] | Retry with backoff | Device may be temporarily busy |
19//! | [`Error::WriteFailed`] | Retry (1-2 times) | BLE write can fail transiently |
20//! | [`Error::InvalidData`] | Do not retry | Data corruption, report to user |
21//! | [`Error::DeviceNotFound`] | Do not retry | Device not in range or wrong name |
22//! | [`Error::CharacteristicNotFound`] | Do not retry | Firmware incompatibility |
23//! | [`Error::InvalidConfig`] | Do not retry | Fix configuration and restart |
24//!
25//! ## Recommended Timeouts
26//!
27//! | Operation | Recommended Timeout | Notes |
28//! |-----------|---------------------|-------|
29//! | Device scan | 10-30 seconds | Aranet4 advertises every ~4s |
30//! | Connection | 10-15 seconds | May take longer if device is busy |
31//! | Read current | 5 seconds | Usually completes in <1s |
32//! | Read device info | 5 seconds | Multiple characteristic reads |
33//! | History download | 2-5 minutes | Depends on record count |
34//! | Write settings | 5 seconds | Includes verification read |
35//!
36//! ## Using RetryConfig
37//!
38//! For transient failures, use [`crate::RetryConfig`] with [`crate::with_retry`]:
39//!
40//! ```ignore
41//! use aranet_core::{RetryConfig, with_retry};
42//!
43//! // Default: 3 retries with exponential backoff (100ms -> 200ms -> 400ms)
44//! let config = RetryConfig::default();
45//!
46//! // For unreliable connections: 5 retries, more aggressive
47//! let aggressive = RetryConfig::aggressive();
48//!
49//! // Wrap your operation
50//! let reading = with_retry(&config, "read_current", || async {
51//!     device.read_current().await
52//! }).await?;
53//! ```
54//!
55//! ## Using ReconnectingDevice
56//!
57//! For long-running applications, use [`crate::ReconnectingDevice`] which
58//! automatically handles reconnection:
59//!
60//! ```ignore
61//! use aranet_core::{ReconnectingDevice, ReconnectOptions};
62//!
63//! // Default: 5 attempts with exponential backoff (1s -> 2s -> 4s -> 8s -> 16s)
64//! let options = ReconnectOptions::default();
65//!
66//! // For always-on services: unlimited retries
67//! let unlimited = ReconnectOptions::unlimited();
68//!
69//! // Connect with auto-reconnect
70//! let device = ReconnectingDevice::connect("Aranet4 12345", options).await?;
71//!
72//! // Operations automatically reconnect if connection is lost
73//! let reading = device.read_current().await?;
74//! ```
75//!
76//! ## Error Classification
77//!
78//! The retry module internally classifies errors as retryable or not.
79//! The following errors are considered retryable:
80//!
81//! - [`Error::Timeout`] - BLE operations can time out due to interference
82//! - [`Error::Bluetooth`] - Generic BLE errors are often transient
83//! - [`Error::NotConnected`] - Connection may have been lost, reconnect and retry
84//! - [`Error::WriteFailed`] - Write operations can fail transiently
85//! - [`Error::ConnectionFailed`] with `OutOfRange`, `Timeout`, or `BleError` reasons
86//! - [`Error::Io`] - I/O errors may be transient
87//!
88//! The following errors should NOT be retried:
89//!
90//! - [`Error::InvalidData`] - Data is corrupted, retrying won't help
91//! - [`Error::InvalidHistoryData`] - History data format error
92//! - [`Error::InvalidReadingFormat`] - Reading format error
93//! - [`Error::DeviceNotFound`] - Device is not available
94//! - [`Error::CharacteristicNotFound`] - Device doesn't support this feature
95//! - [`Error::Cancelled`] - Operation was intentionally cancelled
96//! - [`Error::InvalidConfig`] - Configuration error, fix and restart
97//!
98//! ## Example: Robust Reading Loop
99//!
100//! ```ignore
101//! use aranet_core::{Device, Error, RetryConfig, with_retry};
102//! use std::time::Duration;
103//!
104//! async fn read_with_recovery(device: &Device) -> Result<CurrentReading, Error> {
105//!     let config = RetryConfig::new(3);
106//!
107//!     with_retry(&config, "read_current", || async {
108//!         device.read_current().await
109//!     }).await
110//! }
111//!
112//! // For long-running monitoring
113//! async fn monitoring_loop(identifier: &str) {
114//!     let options = ReconnectOptions::default()
115//!         .max_attempts(10)
116//!         .initial_delay(Duration::from_secs(2));
117//!
118//!     let device = ReconnectingDevice::connect(identifier, options).await?;
119//!
120//!     loop {
121//!         match device.read_current().await {
122//!             Ok(reading) => println!("CO2: {} ppm", reading.co2),
123//!             Err(Error::Cancelled) => break, // Graceful shutdown
124//!             Err(e) => eprintln!("Error (will retry): {}", e),
125//!         }
126//!         tokio::time::sleep(Duration::from_secs(60)).await;
127//!     }
128//! }
129//! ```
130
131use std::time::Duration;
132
133use thiserror::Error;
134
135use crate::history::HistoryParam;
136
137/// Errors that can occur when communicating with Aranet devices.
138///
139/// This enum is marked `#[non_exhaustive]` to allow adding new error variants
140/// in future versions without breaking downstream code.
141#[derive(Debug, Error)]
142#[non_exhaustive]
143pub enum Error {
144    /// Bluetooth Low Energy error.
145    #[error("Bluetooth error: {0}")]
146    Bluetooth(#[from] btleplug::Error),
147
148    /// Device not found during scan or connection.
149    #[error("Device not found: {0}")]
150    DeviceNotFound(DeviceNotFoundReason),
151
152    /// Operation attempted while not connected to device.
153    #[error("Not connected to device")]
154    NotConnected,
155
156    /// Required BLE characteristic not found on device.
157    #[error("Characteristic not found: {uuid} (searched in {service_count} services)")]
158    CharacteristicNotFound {
159        /// The UUID that was not found.
160        uuid: String,
161        /// Number of services that were searched.
162        service_count: usize,
163    },
164
165    /// Failed to parse data received from device.
166    #[error("Invalid data: {0}")]
167    InvalidData(String),
168
169    /// Invalid history data format.
170    #[error(
171        "Invalid history data: {message} (param={param:?}, expected {expected} bytes, got {actual})"
172    )]
173    InvalidHistoryData {
174        /// Description of the error.
175        message: String,
176        /// The history parameter being downloaded.
177        param: Option<HistoryParam>,
178        /// Expected data size.
179        expected: usize,
180        /// Actual data size received.
181        actual: usize,
182    },
183
184    /// Invalid reading format from sensor.
185    #[error("Invalid reading format: expected {expected} bytes, got {actual}")]
186    InvalidReadingFormat {
187        /// Expected data size.
188        expected: usize,
189        /// Actual data size received.
190        actual: usize,
191    },
192
193    /// Operation timed out.
194    #[error("Operation '{operation}' timed out after {duration:?}")]
195    Timeout {
196        /// The operation that timed out.
197        operation: String,
198        /// The timeout duration.
199        duration: Duration,
200    },
201
202    /// Operation was cancelled.
203    #[error("Operation cancelled")]
204    Cancelled,
205
206    /// I/O error.
207    #[error(transparent)]
208    Io(#[from] std::io::Error),
209
210    /// Connection failed with specific reason.
211    #[error("Connection failed: {reason}")]
212    ConnectionFailed {
213        /// The device identifier that failed to connect.
214        device_id: Option<String>,
215        /// The structured reason for the failure.
216        reason: ConnectionFailureReason,
217    },
218
219    /// Write operation failed.
220    #[error("Write failed to characteristic {uuid}: {reason}")]
221    WriteFailed {
222        /// The characteristic UUID.
223        uuid: String,
224        /// The reason for the failure.
225        reason: String,
226    },
227
228    /// Invalid configuration provided.
229    #[error("Invalid configuration: {0}")]
230    InvalidConfig(String),
231}
232
233/// Structured reasons for connection failures.
234///
235/// This enum is marked `#[non_exhaustive]` to allow adding new reasons
236/// in future versions without breaking downstream code.
237#[derive(Debug, Clone, PartialEq, Eq)]
238#[non_exhaustive]
239pub enum ConnectionFailureReason {
240    /// Bluetooth adapter not available or powered off.
241    AdapterUnavailable,
242    /// Device is out of range.
243    OutOfRange,
244    /// Device rejected the connection.
245    Rejected,
246    /// Connection attempt timed out.
247    Timeout,
248    /// Already connected to another central.
249    AlreadyConnected,
250    /// Pairing failed.
251    PairingFailed,
252    /// Generic BLE error.
253    BleError(String),
254    /// Other/unknown error.
255    Other(String),
256}
257
258impl std::fmt::Display for ConnectionFailureReason {
259    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260        match self {
261            Self::AdapterUnavailable => write!(f, "Bluetooth adapter unavailable"),
262            Self::OutOfRange => write!(f, "device out of range"),
263            Self::Rejected => write!(f, "connection rejected by device"),
264            Self::Timeout => write!(f, "connection timed out"),
265            Self::AlreadyConnected => write!(f, "device already connected"),
266            Self::PairingFailed => write!(f, "pairing failed"),
267            Self::BleError(msg) => write!(f, "BLE error: {}", msg),
268            Self::Other(msg) => write!(f, "{}", msg),
269        }
270    }
271}
272
273/// Reason why a device was not found.
274///
275/// This enum is marked `#[non_exhaustive]` to allow adding new reasons
276/// in future versions without breaking downstream code.
277#[derive(Debug, Clone)]
278#[non_exhaustive]
279pub enum DeviceNotFoundReason {
280    /// No devices found during scan.
281    NoDevicesInRange,
282    /// Device with specified name/address not found.
283    NotFound { identifier: String },
284    /// Scan timed out before finding device.
285    ScanTimeout { duration: Duration },
286    /// No Bluetooth adapter available.
287    NoAdapter,
288}
289
290impl std::fmt::Display for DeviceNotFoundReason {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        match self {
293            Self::NoDevicesInRange => write!(f, "no devices in range"),
294            Self::NotFound { identifier } => write!(f, "device '{}' not found", identifier),
295            Self::ScanTimeout { duration } => write!(f, "scan timed out after {:?}", duration),
296            Self::NoAdapter => write!(f, "no Bluetooth adapter available"),
297        }
298    }
299}
300
301impl Error {
302    /// Create a device not found error for a specific identifier.
303    pub fn device_not_found(identifier: impl Into<String>) -> Self {
304        Self::DeviceNotFound(DeviceNotFoundReason::NotFound {
305            identifier: identifier.into(),
306        })
307    }
308
309    /// Create a timeout error with operation context.
310    pub fn timeout(operation: impl Into<String>, duration: Duration) -> Self {
311        Self::Timeout {
312            operation: operation.into(),
313            duration,
314        }
315    }
316
317    /// Create a characteristic not found error.
318    pub fn characteristic_not_found(uuid: impl Into<String>, service_count: usize) -> Self {
319        Self::CharacteristicNotFound {
320            uuid: uuid.into(),
321            service_count,
322        }
323    }
324
325    /// Create an invalid reading format error.
326    pub fn invalid_reading(expected: usize, actual: usize) -> Self {
327        Self::InvalidReadingFormat { expected, actual }
328    }
329
330    /// Create a configuration error.
331    pub fn invalid_config(message: impl Into<String>) -> Self {
332        Self::InvalidConfig(message.into())
333    }
334
335    /// Create a connection failure with structured reason.
336    pub fn connection_failed(device_id: Option<String>, reason: ConnectionFailureReason) -> Self {
337        Self::ConnectionFailed { device_id, reason }
338    }
339
340    /// Create a connection failure with a string reason.
341    ///
342    /// This is a convenience method that wraps the string in `ConnectionFailureReason::Other`.
343    pub fn connection_failed_str(device_id: Option<String>, reason: impl Into<String>) -> Self {
344        Self::ConnectionFailed {
345            device_id,
346            reason: ConnectionFailureReason::Other(reason.into()),
347        }
348    }
349}
350
351impl From<aranet_types::ParseError> for Error {
352    fn from(err: aranet_types::ParseError) -> Self {
353        match err {
354            aranet_types::ParseError::InsufficientBytes { expected, actual } => {
355                Error::InvalidReadingFormat { expected, actual }
356            }
357            aranet_types::ParseError::InvalidValue(msg) => Error::InvalidData(msg),
358            aranet_types::ParseError::UnknownDeviceType(byte) => {
359                Error::InvalidData(format!("Unknown device type: 0x{:02X}", byte))
360            }
361            // Handle future ParseError variants (non_exhaustive)
362            _ => Error::InvalidData(format!("Parse error: {}", err)),
363        }
364    }
365}
366
367/// Result type alias using aranet-core's Error type.
368pub type Result<T> = std::result::Result<T, Error>;
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn test_error_display() {
376        let err = Error::device_not_found("Aranet4 12345");
377        assert!(err.to_string().contains("Aranet4 12345"));
378
379        let err = Error::NotConnected;
380        assert_eq!(err.to_string(), "Not connected to device");
381
382        let err = Error::characteristic_not_found("0x2A19", 5);
383        assert!(err.to_string().contains("0x2A19"));
384        assert!(err.to_string().contains("5 services"));
385
386        let err = Error::InvalidData("bad format".to_string());
387        assert_eq!(err.to_string(), "Invalid data: bad format");
388
389        let err = Error::timeout("read_current", Duration::from_secs(10));
390        assert!(err.to_string().contains("read_current"));
391        assert!(err.to_string().contains("10s"));
392    }
393
394    #[test]
395    fn test_error_debug() {
396        let err = Error::DeviceNotFound(DeviceNotFoundReason::NoDevicesInRange);
397        let debug_str = format!("{:?}", err);
398        assert!(debug_str.contains("DeviceNotFound"));
399    }
400
401    #[test]
402    fn test_device_not_found_reasons() {
403        let err = Error::DeviceNotFound(DeviceNotFoundReason::NoAdapter);
404        assert!(err.to_string().contains("no Bluetooth adapter"));
405
406        let err = Error::DeviceNotFound(DeviceNotFoundReason::ScanTimeout {
407            duration: Duration::from_secs(30),
408        });
409        assert!(err.to_string().contains("30s"));
410    }
411
412    #[test]
413    fn test_invalid_reading_format() {
414        let err = Error::invalid_reading(13, 7);
415        assert!(err.to_string().contains("13"));
416        assert!(err.to_string().contains("7"));
417    }
418
419    #[test]
420    fn test_btleplug_error_conversion() {
421        // btleplug::Error doesn't have public constructors for most variants,
422        // but we can verify the From impl exists by checking the type compiles
423        fn _assert_from_impl<T: From<btleplug::Error>>() {}
424        _assert_from_impl::<Error>();
425    }
426
427    #[test]
428    fn test_io_error_conversion() {
429        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
430        let err: Error = io_err.into();
431        assert!(matches!(err, Error::Io(_)));
432        assert!(err.to_string().contains("file not found"));
433    }
434}