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    /// The identifier matches more than one nearby device.
293    Ambiguous {
294        /// The identifier that was looked up, trimmed.
295        identifier: String,
296        /// The matching devices, each as `"<name> (<identifier>)"`, sorted.
297        candidates: Vec<String>,
298    },
299    /// No device matches exactly; `similar` lists Aranet device names that
300    /// contain the identifier.
301    NoExactMatch {
302        /// The identifier that was looked up, trimmed.
303        identifier: String,
304        /// Names of Aranet devices the adapter knows (names containing
305        /// `aranet`) that contain the identifier, sorted. The error message
306        /// shows the first five.
307        similar: Vec<String>,
308    },
309}
310
311/// How many of `NoExactMatch`'s names its message shows.
312const SIMILAR_NAMES_SHOWN: usize = 5;
313
314impl std::fmt::Display for DeviceNotFoundReason {
315    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316        match self {
317            Self::NoDevicesInRange => write!(f, "no devices in range"),
318            Self::NotFound { identifier } => write!(f, "device '{}' not found", identifier),
319            Self::ScanTimeout { duration } => write!(f, "scan timed out after {:?}", duration),
320            Self::NoAdapter => write!(f, "no Bluetooth adapter available"),
321            Self::Ambiguous {
322                identifier,
323                candidates,
324            } => write!(
325                f,
326                "'{identifier}' matches {} devices: {}; use the address or UUID instead",
327                candidates.len(),
328                candidates.join(", ")
329            ),
330            Self::NoExactMatch {
331                identifier,
332                similar,
333            } => {
334                write!(
335                    f,
336                    "device '{identifier}' not found; names must match exactly"
337                )?;
338                if !similar.is_empty() {
339                    let shown = similar.len().min(SIMILAR_NAMES_SHOWN);
340                    let quoted: Vec<String> = similar[..shown]
341                        .iter()
342                        .map(|name| format!("'{name}'"))
343                        .collect();
344                    write!(f, " (did you mean {}?", quoted.join(" or "))?;
345                    match similar.len() - shown {
346                        0 => {}
347                        1 => write!(f, " 1 more name contains '{identifier}'")?,
348                        more => write!(f, " {more} more names contain '{identifier}'")?,
349                    }
350                    write!(f, ")")?;
351                }
352                Ok(())
353            }
354        }
355    }
356}
357
358impl Error {
359    /// Create a device not found error for a specific identifier.
360    pub fn device_not_found(identifier: impl Into<String>) -> Self {
361        Self::DeviceNotFound(DeviceNotFoundReason::NotFound {
362            identifier: identifier.into(),
363        })
364    }
365
366    /// Create a timeout error with operation context.
367    pub fn timeout(operation: impl Into<String>, duration: Duration) -> Self {
368        Self::Timeout {
369            operation: operation.into(),
370            duration,
371        }
372    }
373
374    /// Create a characteristic not found error.
375    pub fn characteristic_not_found(uuid: impl Into<String>, service_count: usize) -> Self {
376        Self::CharacteristicNotFound {
377            uuid: uuid.into(),
378            service_count,
379        }
380    }
381
382    /// Create an invalid reading format error.
383    pub fn invalid_reading(expected: usize, actual: usize) -> Self {
384        Self::InvalidReadingFormat { expected, actual }
385    }
386
387    /// Create a configuration error.
388    pub fn invalid_config(message: impl Into<String>) -> Self {
389        Self::InvalidConfig(message.into())
390    }
391
392    /// Create a connection failure with structured reason.
393    pub fn connection_failed(device_id: Option<String>, reason: ConnectionFailureReason) -> Self {
394        Self::ConnectionFailed { device_id, reason }
395    }
396
397    /// Create a connection failure with a string reason.
398    ///
399    /// This is a convenience method that wraps the string in `ConnectionFailureReason::Other`.
400    pub fn connection_failed_str(device_id: Option<String>, reason: impl Into<String>) -> Self {
401        Self::ConnectionFailed {
402            device_id,
403            reason: ConnectionFailureReason::Other(reason.into()),
404        }
405    }
406
407    /// Whether the link to the device is gone or unusable, so that reconnecting
408    /// can help. `ReconnectingDevice` reconnects only after these errors.
409    ///
410    /// btleplug reports every BlueZ D-Bus error as `btleplug::Error::Other`
411    /// (`bluez/adapter.rs`, `impl From<BluetoothError>`), so on Linux an
412    /// authentication failure counts too. A reconnect loop that can't fix it
413    /// still stops after `ReconnectOptions::max_attempts`.
414    pub(crate) fn is_connection_error(&self) -> bool {
415        match self {
416            Self::NotConnected | Self::Timeout { .. } | Self::ConnectionFailed { .. } => true,
417            Self::Bluetooth(e) => matches!(
418                e,
419                btleplug::Error::NotConnected
420                    | btleplug::Error::DeviceNotFound
421                    | btleplug::Error::TimedOut(_)
422                    | btleplug::Error::RuntimeError(_)
423                    | btleplug::Error::Other(_)
424            ),
425            _ => false,
426        }
427    }
428}
429
430impl From<aranet_types::ParseError> for Error {
431    fn from(err: aranet_types::ParseError) -> Self {
432        match err {
433            aranet_types::ParseError::InsufficientBytes { expected, actual } => {
434                Error::InvalidReadingFormat { expected, actual }
435            }
436            aranet_types::ParseError::InvalidValue(msg) => Error::InvalidData(msg),
437            aranet_types::ParseError::UnknownDeviceType(byte) => {
438                Error::InvalidData(format!("Unknown device type: 0x{:02X}", byte))
439            }
440            // Handle future ParseError variants (non_exhaustive)
441            _ => Error::InvalidData(format!("Parse error: {}", err)),
442        }
443    }
444}
445
446/// Result type alias using aranet-core's Error type.
447pub type Result<T> = std::result::Result<T, Error>;
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn test_error_display() {
455        let err = Error::device_not_found("Aranet4 12345");
456        assert!(err.to_string().contains("Aranet4 12345"));
457
458        let err = Error::NotConnected;
459        assert_eq!(err.to_string(), "Not connected to device");
460
461        let err = Error::characteristic_not_found("0x2A19", 5);
462        assert!(err.to_string().contains("0x2A19"));
463        assert!(err.to_string().contains("5 services"));
464
465        let err = Error::InvalidData("bad format".to_string());
466        assert_eq!(err.to_string(), "Invalid data: bad format");
467
468        let err = Error::timeout("read_current", Duration::from_secs(10));
469        assert!(err.to_string().contains("read_current"));
470        assert!(err.to_string().contains("10s"));
471    }
472
473    #[test]
474    fn test_error_debug() {
475        let err = Error::DeviceNotFound(DeviceNotFoundReason::NoDevicesInRange);
476        let debug_str = format!("{:?}", err);
477        assert!(debug_str.contains("DeviceNotFound"));
478    }
479
480    #[test]
481    fn test_device_not_found_reasons() {
482        let err = Error::DeviceNotFound(DeviceNotFoundReason::NoAdapter);
483        assert!(err.to_string().contains("no Bluetooth adapter"));
484
485        let err = Error::DeviceNotFound(DeviceNotFoundReason::ScanTimeout {
486            duration: Duration::from_secs(30),
487        });
488        assert!(err.to_string().contains("30s"));
489    }
490
491    #[test]
492    fn device_not_found_reason_display_lists_candidates() {
493        let ambiguous = Error::DeviceNotFound(DeviceNotFoundReason::Ambiguous {
494            identifier: "Aranet4 12345".to_string(),
495            candidates: vec![
496                "Aranet4 12345 (1f8893bf-9f7e-02b4-ef4a-7718f4f5d4be)".to_string(),
497                "Aranet4 12345 (387c18c7-299f-cc32-d01c-6cf29a8d3ca5)".to_string(),
498            ],
499        });
500        assert_eq!(
501            ambiguous.to_string(),
502            "Device not found: 'Aranet4 12345' matches 2 devices: \
503             Aranet4 12345 (1f8893bf-9f7e-02b4-ef4a-7718f4f5d4be), \
504             Aranet4 12345 (387c18c7-299f-cc32-d01c-6cf29a8d3ca5); \
505             use the address or UUID instead"
506        );
507
508        let one = Error::DeviceNotFound(DeviceNotFoundReason::NoExactMatch {
509            identifier: "2751B".to_string(),
510            similar: vec!["Aranet2 2751B".to_string()],
511        });
512        assert_eq!(
513            one.to_string(),
514            "Device not found: device '2751B' not found; names must match exactly \
515             (did you mean 'Aranet2 2751B'?)"
516        );
517
518        let two = DeviceNotFoundReason::NoExactMatch {
519            identifier: "Aranet".to_string(),
520            similar: vec!["Aranet2 2751B".to_string(), "AranetRn+ 306B8".to_string()],
521        };
522        assert_eq!(
523            two.to_string(),
524            "device 'Aranet' not found; names must match exactly \
525             (did you mean 'Aranet2 2751B' or 'AranetRn+ 306B8'?)"
526        );
527
528        let none = DeviceNotFoundReason::NoExactMatch {
529            identifier: "x".to_string(),
530            similar: Vec::new(),
531        };
532        assert_eq!(
533            none.to_string(),
534            "device 'x' not found; names must match exactly"
535        );
536
537        // Only the first five names are shown, then how many more there are.
538        let names = |count: usize| -> Vec<String> {
539            (1..=count).map(|n| format!("Aranet4 1000{n}")).collect()
540        };
541        let six = DeviceNotFoundReason::NoExactMatch {
542            identifier: "Aranet4".to_string(),
543            similar: names(6),
544        };
545        assert_eq!(
546            six.to_string(),
547            "device 'Aranet4' not found; names must match exactly \
548             (did you mean 'Aranet4 10001' or 'Aranet4 10002' or 'Aranet4 10003' \
549             or 'Aranet4 10004' or 'Aranet4 10005'? 1 more name contains 'Aranet4')"
550        );
551        let eight = DeviceNotFoundReason::NoExactMatch {
552            identifier: "Aranet4".to_string(),
553            similar: names(8),
554        };
555        assert!(
556            eight
557                .to_string()
558                .ends_with("or 'Aranet4 10005'? 3 more names contain 'Aranet4')"),
559            "{eight}"
560        );
561    }
562
563    #[test]
564    fn test_invalid_reading_format() {
565        let err = Error::invalid_reading(13, 7);
566        assert!(err.to_string().contains("13"));
567        assert!(err.to_string().contains("7"));
568    }
569
570    #[test]
571    fn test_btleplug_error_conversion() {
572        // btleplug::Error doesn't have public constructors for most variants,
573        // but we can verify the From impl exists by checking the type compiles
574        fn _assert_from_impl<T: From<btleplug::Error>>() {}
575        _assert_from_impl::<Error>();
576    }
577
578    #[test]
579    fn is_connection_error_table() {
580        let bluetooth = |e: btleplug::Error| Error::Bluetooth(e);
581        let connection_errors = [
582            Error::NotConnected,
583            Error::timeout("read_current", Duration::from_secs(10)),
584            Error::connection_failed_str(None, "the device reported no GATT services"),
585            bluetooth(btleplug::Error::NotConnected),
586            bluetooth(btleplug::Error::DeviceNotFound),
587            bluetooth(btleplug::Error::TimedOut(Duration::from_secs(1))),
588            bluetooth(btleplug::Error::RuntimeError(
589                "Peripheral disconnected".into(),
590            )),
591            bluetooth(btleplug::Error::Other("x".into())),
592        ];
593        for error in &connection_errors {
594            assert!(error.is_connection_error(), "{error:?} should reconnect");
595        }
596
597        let other_errors = [
598            Error::characteristic_not_found("f0cd1502", 3),
599            Error::InvalidData("bad".into()),
600            Error::Unsupported("history".into()),
601            Error::invalid_reading(13, 7),
602            Error::device_not_found("Aranet4 12345"),
603            Error::Cancelled,
604            Error::invalid_config("interval"),
605            Error::Io(std::io::Error::other("disk")),
606            Error::WriteFailed {
607                uuid: "f0cd1402".into(),
608                reason: "rejected".into(),
609            },
610            bluetooth(btleplug::Error::PermissionDenied),
611            bluetooth(btleplug::Error::NoSuchCharacteristic),
612            bluetooth(btleplug::Error::NotSupported("x".into())),
613        ];
614        for error in &other_errors {
615            assert!(
616                !error.is_connection_error(),
617                "{error:?} should not reconnect"
618            );
619        }
620    }
621
622    #[test]
623    fn test_io_error_conversion() {
624        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
625        let err: Error = io_err.into();
626        assert!(matches!(err, Error::Io(_)));
627        assert!(err.to_string().contains("file not found"));
628    }
629}