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}