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}