Skip to main content

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    /// Operation not supported for this device type.
166    #[error("Unsupported: {0}")]
167    Unsupported(String),
168
169    /// Failed to parse data received from device.
170    #[error("Invalid data: {0}")]
171    InvalidData(String),
172
173    /// Invalid history data format.
174    #[error(
175        "Invalid history data: {message} (param={param:?}, expected {expected} bytes, got {actual})"
176    )]
177    InvalidHistoryData {
178        /// Description of the error.
179        message: String,
180        /// The history parameter being downloaded.
181        param: Option<HistoryParam>,
182        /// Expected data size.
183        expected: usize,
184        /// Actual data size received.
185        actual: usize,
186    },
187
188    /// Invalid reading format from sensor.
189    #[error("Invalid reading format: expected {expected} bytes, got {actual}")]
190    InvalidReadingFormat {
191        /// Expected data size.
192        expected: usize,
193        /// Actual data size received.
194        actual: usize,
195    },
196
197    /// Operation timed out.
198    #[error("Operation '{operation}' timed out after {duration:?}")]
199    Timeout {
200        /// The operation that timed out.
201        operation: String,
202        /// The timeout duration.
203        duration: Duration,
204    },
205
206    /// Operation was cancelled.
207    #[error("Operation cancelled")]
208    Cancelled,
209
210    /// I/O error.
211    #[error(transparent)]
212    Io(#[from] std::io::Error),
213
214    /// Connection failed with specific reason.
215    #[error("Connection failed: {reason}")]
216    ConnectionFailed {
217        /// The device identifier that failed to connect.
218        device_id: Option<String>,
219        /// The structured reason for the failure.
220        reason: ConnectionFailureReason,
221    },
222
223    /// Write operation failed.
224    #[error("Write failed to characteristic {uuid}: {reason}")]
225    WriteFailed {
226        /// The characteristic UUID.
227        uuid: String,
228        /// The reason for the failure.
229        reason: String,
230    },
231
232    /// Invalid configuration provided.
233    #[error("Invalid configuration: {0}")]
234    InvalidConfig(String),
235}
236
237/// Structured reasons for connection failures.
238///
239/// This enum is marked `#[non_exhaustive]` to allow adding new reasons
240/// in future versions without breaking downstream code.
241#[derive(Debug, Clone, PartialEq, Eq)]
242#[non_exhaustive]
243pub enum ConnectionFailureReason {
244    /// Bluetooth adapter not available or powered off.
245    AdapterUnavailable,
246    /// Device is out of range.
247    OutOfRange,
248    /// Device rejected the connection.
249    Rejected,
250    /// Connection attempt timed out.
251    Timeout,
252    /// Already connected to another central.
253    AlreadyConnected,
254    /// Pairing failed.
255    PairingFailed,
256    /// Generic BLE error.
257    BleError(String),
258    /// Other/unknown error.
259    Other(String),
260}
261
262impl std::fmt::Display for ConnectionFailureReason {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        match self {
265            Self::AdapterUnavailable => write!(f, "Bluetooth adapter unavailable"),
266            Self::OutOfRange => write!(f, "device out of range"),
267            Self::Rejected => write!(f, "connection rejected by device"),
268            Self::Timeout => write!(f, "connection timed out"),
269            Self::AlreadyConnected => write!(f, "device already connected"),
270            Self::PairingFailed => write!(f, "pairing failed"),
271            Self::BleError(msg) => write!(f, "BLE error: {}", msg),
272            Self::Other(msg) => write!(f, "{}", msg),
273        }
274    }
275}
276
277/// Reason why a device was not found.
278///
279/// This enum is marked `#[non_exhaustive]` to allow adding new reasons
280/// in future versions without breaking downstream code.
281#[derive(Debug, Clone)]
282#[non_exhaustive]
283pub enum DeviceNotFoundReason {
284    /// No devices found during scan.
285    NoDevicesInRange,
286    /// Device with specified name/address not found.
287    NotFound { identifier: String },
288    /// Scan timed out before finding device.
289    ScanTimeout { duration: Duration },
290    /// No Bluetooth adapter available.
291    NoAdapter,
292}
293
294impl std::fmt::Display for DeviceNotFoundReason {
295    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296        match self {
297            Self::NoDevicesInRange => write!(f, "no devices in range"),
298            Self::NotFound { identifier } => write!(f, "device '{}' not found", identifier),
299            Self::ScanTimeout { duration } => write!(f, "scan timed out after {:?}", duration),
300            Self::NoAdapter => write!(f, "no Bluetooth adapter available"),
301        }
302    }
303}
304
305impl Error {
306    /// Create a device not found error for a specific identifier.
307    pub fn device_not_found(identifier: impl Into<String>) -> Self {
308        Self::DeviceNotFound(DeviceNotFoundReason::NotFound {
309            identifier: identifier.into(),
310        })
311    }
312
313    /// Create a timeout error with operation context.
314    pub fn timeout(operation: impl Into<String>, duration: Duration) -> Self {
315        Self::Timeout {
316            operation: operation.into(),
317            duration,
318        }
319    }
320
321    /// Create a characteristic not found error.
322    pub fn characteristic_not_found(uuid: impl Into<String>, service_count: usize) -> Self {
323        Self::CharacteristicNotFound {
324            uuid: uuid.into(),
325            service_count,
326        }
327    }
328
329    /// Create an invalid reading format error.
330    pub fn invalid_reading(expected: usize, actual: usize) -> Self {
331        Self::InvalidReadingFormat { expected, actual }
332    }
333
334    /// Create a configuration error.
335    pub fn invalid_config(message: impl Into<String>) -> Self {
336        Self::InvalidConfig(message.into())
337    }
338
339    /// Create a connection failure with structured reason.
340    pub fn connection_failed(device_id: Option<String>, reason: ConnectionFailureReason) -> Self {
341        Self::ConnectionFailed { device_id, reason }
342    }
343
344    /// Create a connection failure with a string reason.
345    ///
346    /// This is a convenience method that wraps the string in `ConnectionFailureReason::Other`.
347    pub fn connection_failed_str(device_id: Option<String>, reason: impl Into<String>) -> Self {
348        Self::ConnectionFailed {
349            device_id,
350            reason: ConnectionFailureReason::Other(reason.into()),
351        }
352    }
353}
354
355impl From<aranet_types::ParseError> for Error {
356    fn from(err: aranet_types::ParseError) -> Self {
357        match err {
358            aranet_types::ParseError::InsufficientBytes { expected, actual } => {
359                Error::InvalidReadingFormat { expected, actual }
360            }
361            aranet_types::ParseError::InvalidValue(msg) => Error::InvalidData(msg),
362            aranet_types::ParseError::UnknownDeviceType(byte) => {
363                Error::InvalidData(format!("Unknown device type: 0x{:02X}", byte))
364            }
365            // Handle future ParseError variants (non_exhaustive)
366            _ => Error::InvalidData(format!("Parse error: {}", err)),
367        }
368    }
369}
370
371/// Result type alias using aranet-core's Error type.
372pub type Result<T> = std::result::Result<T, Error>;
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn test_error_display() {
380        let err = Error::device_not_found("Aranet4 12345");
381        assert!(err.to_string().contains("Aranet4 12345"));
382
383        let err = Error::NotConnected;
384        assert_eq!(err.to_string(), "Not connected to device");
385
386        let err = Error::characteristic_not_found("0x2A19", 5);
387        assert!(err.to_string().contains("0x2A19"));
388        assert!(err.to_string().contains("5 services"));
389
390        let err = Error::InvalidData("bad format".to_string());
391        assert_eq!(err.to_string(), "Invalid data: bad format");
392
393        let err = Error::timeout("read_current", Duration::from_secs(10));
394        assert!(err.to_string().contains("read_current"));
395        assert!(err.to_string().contains("10s"));
396    }
397
398    #[test]
399    fn test_error_debug() {
400        let err = Error::DeviceNotFound(DeviceNotFoundReason::NoDevicesInRange);
401        let debug_str = format!("{:?}", err);
402        assert!(debug_str.contains("DeviceNotFound"));
403    }
404
405    #[test]
406    fn test_device_not_found_reasons() {
407        let err = Error::DeviceNotFound(DeviceNotFoundReason::NoAdapter);
408        assert!(err.to_string().contains("no Bluetooth adapter"));
409
410        let err = Error::DeviceNotFound(DeviceNotFoundReason::ScanTimeout {
411            duration: Duration::from_secs(30),
412        });
413        assert!(err.to_string().contains("30s"));
414    }
415
416    #[test]
417    fn test_invalid_reading_format() {
418        let err = Error::invalid_reading(13, 7);
419        assert!(err.to_string().contains("13"));
420        assert!(err.to_string().contains("7"));
421    }
422
423    #[test]
424    fn test_btleplug_error_conversion() {
425        // btleplug::Error doesn't have public constructors for most variants,
426        // but we can verify the From impl exists by checking the type compiles
427        fn _assert_from_impl<T: From<btleplug::Error>>() {}
428        _assert_from_impl::<Error>();
429    }
430
431    #[test]
432    fn test_io_error_conversion() {
433        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
434        let err: Error = io_err.into();
435        assert!(matches!(err, Error::Io(_)));
436        assert!(err.to_string().contains("file not found"));
437    }
438}