aranet_core/messages.rs
1//! Message types for UI/worker communication.
2//!
3//! This module defines the command and event enums used for bidirectional
4//! communication between UI threads and background BLE workers. These types
5//! are shared between TUI and GUI applications.
6//!
7//! # Architecture
8//!
9//! ```text
10//! +------------------+ Command +------------------+
11//! | UI Thread | --------------> | SensorWorker |
12//! | (egui/ratatui) | | (tokio runtime) |
13//! | | <-------------- | |
14//! +------------------+ SensorEvent +------------------+
15//! ```
16//!
17//! - [`Command`]: Messages sent from the UI thread to the background worker
18//! - [`SensorEvent`]: Events sent from the worker back to the UI thread
19
20use std::time::Duration;
21
22use crate::DiscoveredDevice;
23use crate::settings::DeviceSettings;
24use aranet_types::{CurrentReading, DeviceType, HistoryRecord};
25
26/// Describes why an error occurred and whether it can be retried.
27#[derive(Debug, Clone)]
28pub struct ErrorContext {
29 /// The error message.
30 pub message: String,
31 /// Whether this error is likely transient and worth retrying.
32 pub retryable: bool,
33 /// A user-friendly suggestion for resolving the error.
34 pub suggestion: Option<String>,
35}
36
37impl ErrorContext {
38 /// Create a new non-retryable error.
39 pub fn permanent(message: impl Into<String>) -> Self {
40 Self {
41 message: message.into(),
42 retryable: false,
43 suggestion: None,
44 }
45 }
46
47 /// Create a new retryable error with a suggestion.
48 pub fn transient(message: impl Into<String>, suggestion: impl Into<String>) -> Self {
49 Self {
50 message: message.into(),
51 retryable: true,
52 suggestion: Some(suggestion.into()),
53 }
54 }
55
56 /// Create from an aranet_core::Error with automatic classification.
57 pub fn from_error(error: &crate::Error) -> Self {
58 use crate::error::ConnectionFailureReason;
59
60 match error {
61 crate::Error::Timeout { operation, .. } => Self::transient(
62 error.to_string(),
63 format!(
64 "The {} operation timed out. The device may be out of range or busy. Try moving closer.",
65 operation
66 ),
67 ),
68 crate::Error::ConnectionFailed { reason, .. } => match reason {
69 ConnectionFailureReason::OutOfRange => Self::transient(
70 error.to_string(),
71 "Device is out of Bluetooth range. Move closer and try again.",
72 ),
73 ConnectionFailureReason::Timeout => Self::transient(
74 error.to_string(),
75 "Connection timed out. The device may be busy or out of range.",
76 ),
77 ConnectionFailureReason::BleError(_) => Self::transient(
78 error.to_string(),
79 "Bluetooth error occurred. Try toggling Bluetooth off and on.",
80 ),
81 ConnectionFailureReason::AdapterUnavailable => Self {
82 message: error.to_string(),
83 retryable: false,
84 suggestion: Some(
85 "Bluetooth adapter is unavailable. Enable Bluetooth and try again."
86 .to_string(),
87 ),
88 },
89 ConnectionFailureReason::Rejected => Self {
90 message: error.to_string(),
91 retryable: false,
92 suggestion: Some(
93 "Connection was rejected by the device. Try re-pairing.".to_string(),
94 ),
95 },
96 ConnectionFailureReason::AlreadyConnected => Self {
97 message: error.to_string(),
98 retryable: false,
99 suggestion: Some("Device is already connected.".to_string()),
100 },
101 ConnectionFailureReason::PairingFailed => Self {
102 message: error.to_string(),
103 retryable: false,
104 suggestion: Some(
105 "Pairing failed. Try removing the device and re-pairing.".to_string(),
106 ),
107 },
108 ConnectionFailureReason::Other(_) => Self::transient(
109 error.to_string(),
110 "Connection failed. Try again or restart the device.",
111 ),
112 },
113 crate::Error::NotConnected => Self::transient(
114 error.to_string(),
115 "Device disconnected unexpectedly. Reconnecting...",
116 ),
117 crate::Error::Bluetooth(_) => Self::transient(
118 error.to_string(),
119 "Bluetooth error. Try moving closer to the device or restarting Bluetooth.",
120 ),
121 crate::Error::DeviceNotFound(_) => Self::permanent(error.to_string()),
122 crate::Error::CharacteristicNotFound { .. } => Self {
123 message: error.to_string(),
124 retryable: false,
125 suggestion: Some(
126 "This device may have incompatible firmware. Check for updates.".to_string(),
127 ),
128 },
129 crate::Error::InvalidData(_)
130 | crate::Error::InvalidHistoryData { .. }
131 | crate::Error::InvalidReadingFormat { .. } => Self::permanent(error.to_string()),
132 crate::Error::Cancelled => Self::permanent("Operation was cancelled.".to_string()),
133 crate::Error::WriteFailed { .. } => {
134 Self::transient(error.to_string(), "Failed to write to device. Try again.")
135 }
136 crate::Error::Io(_) => {
137 Self::transient(error.to_string(), "I/O error occurred. Try again.")
138 }
139 crate::Error::InvalidConfig(_) | crate::Error::Unsupported(_) => {
140 Self::permanent(error.to_string())
141 }
142 }
143 }
144}
145
146/// Commands sent from the UI thread to the background worker.
147///
148/// These commands represent user-initiated actions that require
149/// Bluetooth operations or other background processing.
150#[derive(Debug, Clone)]
151pub enum Command {
152 /// Load cached devices and readings from the store on startup.
153 LoadCachedData,
154
155 /// Scan for nearby Aranet devices.
156 Scan {
157 /// How long to scan for devices.
158 duration: Duration,
159 },
160
161 /// Connect to a specific device.
162 Connect {
163 /// The device identifier to connect to.
164 device_id: String,
165 },
166
167 /// Disconnect from a specific device.
168 Disconnect {
169 /// The device identifier to disconnect from.
170 device_id: String,
171 },
172
173 /// Refresh the current reading for a single device.
174 RefreshReading {
175 /// The device identifier to refresh.
176 device_id: String,
177 },
178
179 /// Refresh readings for all connected devices.
180 RefreshAll,
181
182 /// Sync history from device (download from BLE and save to store).
183 SyncHistory {
184 /// The device identifier to sync history for.
185 device_id: String,
186 },
187
188 /// Set the measurement interval for a device.
189 SetInterval {
190 /// The device identifier.
191 device_id: String,
192 /// The new interval in seconds.
193 interval_secs: u16,
194 },
195
196 /// Set the Bluetooth range for a device.
197 SetBluetoothRange {
198 /// The device identifier.
199 device_id: String,
200 /// Whether to use extended range (true) or standard (false).
201 extended: bool,
202 },
203
204 /// Set Smart Home integration mode for a device.
205 SetSmartHome {
206 /// The device identifier.
207 device_id: String,
208 /// Whether to enable Smart Home mode.
209 enabled: bool,
210 },
211
212 /// Refresh the aranet-service status.
213 RefreshServiceStatus,
214
215 /// Start the aranet-service collector.
216 StartServiceCollector,
217
218 /// Stop the aranet-service collector.
219 StopServiceCollector,
220
221 /// Set a friendly alias/name for a device.
222 SetAlias {
223 /// The device identifier.
224 device_id: String,
225 /// The new alias (or None to clear).
226 alias: Option<String>,
227 },
228
229 /// Forget (remove) a device from the known devices list and store.
230 ForgetDevice {
231 /// The device identifier.
232 device_id: String,
233 },
234
235 /// Cancel the current long-running operation (scan, history sync, etc.).
236 CancelOperation,
237
238 /// Start automatic background polling for a device.
239 StartBackgroundPolling {
240 /// The device identifier.
241 device_id: String,
242 /// Polling interval in seconds.
243 interval_secs: u64,
244 },
245
246 /// Stop automatic background polling for a device.
247 StopBackgroundPolling {
248 /// The device identifier.
249 device_id: String,
250 },
251
252 /// Shut down the worker thread.
253 Shutdown,
254
255 /// Install aranet-service as a system service.
256 InstallSystemService {
257 /// Install as user-level service (no root/admin required).
258 user_level: bool,
259 },
260
261 /// Uninstall aranet-service system service.
262 UninstallSystemService {
263 /// Uninstall user-level service.
264 user_level: bool,
265 },
266
267 /// Start the aranet-service system service.
268 StartSystemService {
269 /// Start user-level service.
270 user_level: bool,
271 },
272
273 /// Stop the aranet-service system service.
274 StopSystemService {
275 /// Stop user-level service.
276 user_level: bool,
277 },
278
279 /// Check the status of the aranet-service system service.
280 CheckSystemServiceStatus {
281 /// Check user-level service status.
282 user_level: bool,
283 },
284
285 /// Fetch the service configuration.
286 FetchServiceConfig,
287
288 /// Add a device to the service's monitored device list.
289 AddServiceDevice {
290 /// Device address/ID.
291 address: String,
292 /// Optional alias.
293 alias: Option<String>,
294 /// Poll interval in seconds.
295 poll_interval: u64,
296 },
297
298 /// Update a device in the service's monitored device list.
299 UpdateServiceDevice {
300 /// Device address/ID.
301 address: String,
302 /// Optional new alias.
303 alias: Option<String>,
304 /// New poll interval in seconds.
305 poll_interval: u64,
306 },
307
308 /// Remove a device from the service's monitored device list.
309 RemoveServiceDevice {
310 /// Device address/ID to remove.
311 address: String,
312 },
313}
314
315/// Cached device data loaded from the store.
316#[derive(Debug, Clone)]
317pub struct CachedDevice {
318 /// Device identifier.
319 pub id: String,
320 /// Device name.
321 pub name: Option<String>,
322 /// Device type.
323 pub device_type: Option<DeviceType>,
324 /// Latest reading, if available.
325 pub reading: Option<CurrentReading>,
326 /// When history was last synced.
327 pub last_sync: Option<time::OffsetDateTime>,
328}
329
330/// Events sent from the background worker to the UI thread.
331///
332/// These events represent the results of background operations
333/// and are used to update the UI state.
334#[derive(Debug, Clone)]
335pub enum SensorEvent {
336 /// Cached data loaded from the store on startup.
337 CachedDataLoaded {
338 /// Cached devices with their latest readings.
339 devices: Vec<CachedDevice>,
340 },
341
342 /// A device scan has started.
343 ScanStarted,
344
345 /// A device scan has completed successfully.
346 ScanComplete {
347 /// The list of discovered devices.
348 devices: Vec<DiscoveredDevice>,
349 },
350
351 /// A device scan failed.
352 ScanError {
353 /// Description of the error.
354 error: String,
355 },
356
357 /// Attempting to connect to a device.
358 DeviceConnecting {
359 /// The device identifier.
360 device_id: String,
361 },
362
363 /// Successfully connected to a device.
364 DeviceConnected {
365 /// The device identifier.
366 device_id: String,
367 /// The device name, if available.
368 name: Option<String>,
369 /// The device type, if detected.
370 device_type: Option<DeviceType>,
371 /// RSSI signal strength in dBm.
372 rssi: Option<i16>,
373 },
374
375 /// Disconnected from a device.
376 DeviceDisconnected {
377 /// The device identifier.
378 device_id: String,
379 },
380
381 /// Failed to connect to a device.
382 ConnectionError {
383 /// The device identifier.
384 device_id: String,
385 /// Description of the error.
386 error: String,
387 /// Additional error context with retry info.
388 context: Option<ErrorContext>,
389 },
390
391 /// Received an updated reading from a device.
392 ReadingUpdated {
393 /// The device identifier.
394 device_id: String,
395 /// The current sensor reading.
396 reading: CurrentReading,
397 },
398
399 /// Failed to read from a device.
400 ReadingError {
401 /// The device identifier.
402 device_id: String,
403 /// Description of the error.
404 error: String,
405 /// Additional error context with retry info.
406 context: Option<ErrorContext>,
407 },
408
409 /// Historical data loaded for a device.
410 HistoryLoaded {
411 /// The device identifier.
412 device_id: String,
413 /// The historical records.
414 records: Vec<HistoryRecord>,
415 },
416
417 /// History sync started for a device.
418 HistorySyncStarted {
419 /// The device identifier.
420 device_id: String,
421 /// Total number of records to download (if known).
422 total_records: Option<u16>,
423 },
424
425 /// History sync progress update.
426 HistorySyncProgress {
427 /// The device identifier.
428 device_id: String,
429 /// Number of records downloaded so far.
430 downloaded: usize,
431 /// Total number of records to download.
432 total: usize,
433 },
434
435 /// History sync completed for a device.
436 HistorySynced {
437 /// The device identifier.
438 device_id: String,
439 /// Number of records synced.
440 count: usize,
441 },
442
443 /// History sync failed for a device.
444 HistorySyncError {
445 /// The device identifier.
446 device_id: String,
447 /// Description of the error.
448 error: String,
449 /// Additional error context with retry info.
450 context: Option<ErrorContext>,
451 },
452
453 /// Measurement interval changed for a device.
454 IntervalChanged {
455 /// The device identifier.
456 device_id: String,
457 /// The new interval in seconds.
458 interval_secs: u16,
459 },
460
461 /// Failed to set measurement interval.
462 IntervalError {
463 /// The device identifier.
464 device_id: String,
465 /// Description of the error.
466 error: String,
467 /// Additional error context with retry info.
468 context: Option<ErrorContext>,
469 },
470
471 /// Device settings loaded from the device.
472 SettingsLoaded {
473 /// The device identifier.
474 device_id: String,
475 /// The device settings.
476 settings: DeviceSettings,
477 },
478
479 /// Bluetooth range changed for a device.
480 BluetoothRangeChanged {
481 /// The device identifier.
482 device_id: String,
483 /// Whether extended range is now enabled.
484 extended: bool,
485 },
486
487 /// Failed to set Bluetooth range.
488 BluetoothRangeError {
489 /// The device identifier.
490 device_id: String,
491 /// Description of the error.
492 error: String,
493 /// Additional error context with retry info.
494 context: Option<ErrorContext>,
495 },
496
497 /// Smart Home setting changed for a device.
498 SmartHomeChanged {
499 /// The device identifier.
500 device_id: String,
501 /// Whether Smart Home mode is now enabled.
502 enabled: bool,
503 },
504
505 /// Failed to set Smart Home mode.
506 SmartHomeError {
507 /// The device identifier.
508 device_id: String,
509 /// Description of the error.
510 error: String,
511 /// Additional error context with retry info.
512 context: Option<ErrorContext>,
513 },
514
515 /// Service status refreshed successfully.
516 ServiceStatusRefreshed {
517 /// Whether the service is reachable.
518 reachable: bool,
519 /// Whether the collector is running.
520 collector_running: bool,
521 /// Service uptime in seconds.
522 uptime_seconds: Option<u64>,
523 /// Monitored devices with their collection stats.
524 devices: Vec<ServiceDeviceStats>,
525 },
526
527 /// Service status refresh failed.
528 ServiceStatusError {
529 /// Description of the error.
530 error: String,
531 },
532
533 /// Service collector started successfully.
534 ServiceCollectorStarted,
535
536 /// Service collector stopped successfully.
537 ServiceCollectorStopped,
538
539 /// Service collector action failed.
540 ServiceCollectorError {
541 /// Description of the error.
542 error: String,
543 },
544
545 /// Device alias changed successfully.
546 AliasChanged {
547 /// The device identifier.
548 device_id: String,
549 /// The new alias (or None if cleared).
550 alias: Option<String>,
551 },
552
553 /// Failed to set device alias.
554 AliasError {
555 /// The device identifier.
556 device_id: String,
557 /// Description of the error.
558 error: String,
559 },
560
561 /// Device was forgotten (removed from known devices).
562 DeviceForgotten {
563 /// The device identifier.
564 device_id: String,
565 },
566
567 /// Failed to forget device.
568 ForgetDeviceError {
569 /// The device identifier.
570 device_id: String,
571 /// Description of the error.
572 error: String,
573 },
574
575 /// An operation was cancelled by user request.
576 OperationCancelled {
577 /// Description of what was cancelled.
578 operation: String,
579 },
580
581 /// Background polling started for a device.
582 BackgroundPollingStarted {
583 /// The device identifier.
584 device_id: String,
585 /// Polling interval in seconds.
586 interval_secs: u64,
587 },
588
589 /// Background polling stopped for a device.
590 BackgroundPollingStopped {
591 /// The device identifier.
592 device_id: String,
593 },
594
595 /// Signal strength update (can be sent periodically or on connect).
596 SignalStrengthUpdate {
597 /// The device identifier.
598 device_id: String,
599 /// RSSI in dBm.
600 rssi: i16,
601 /// Quality assessment.
602 quality: SignalQuality,
603 },
604
605 /// System service status retrieved.
606 SystemServiceStatus {
607 /// Whether the service is installed.
608 installed: bool,
609 /// Whether the service is running.
610 running: bool,
611 },
612
613 /// System service was installed successfully.
614 SystemServiceInstalled,
615
616 /// System service was uninstalled successfully.
617 SystemServiceUninstalled,
618
619 /// System service was started successfully.
620 SystemServiceStarted,
621
622 /// System service was stopped successfully.
623 SystemServiceStopped,
624
625 /// System service operation failed.
626 SystemServiceError {
627 /// The operation that failed.
628 operation: String,
629 /// Description of the error.
630 error: String,
631 },
632
633 /// Service configuration fetched.
634 ServiceConfigFetched {
635 /// List of monitored devices in service config.
636 devices: Vec<ServiceMonitoredDevice>,
637 },
638
639 /// Failed to fetch service configuration.
640 ServiceConfigError {
641 /// Error message.
642 error: String,
643 },
644
645 /// Device added to service monitoring.
646 ServiceDeviceAdded {
647 /// The device that was added.
648 device: ServiceMonitoredDevice,
649 },
650
651 /// Device updated in service monitoring.
652 ServiceDeviceUpdated {
653 /// The device that was updated.
654 device: ServiceMonitoredDevice,
655 },
656
657 /// Device removed from service monitoring.
658 ServiceDeviceRemoved {
659 /// The device address that was removed.
660 address: String,
661 },
662
663 /// Failed to modify service device.
664 ServiceDeviceError {
665 /// The operation that failed.
666 operation: String,
667 /// Error message.
668 error: String,
669 },
670}
671
672/// A device being monitored by the service.
673#[derive(Debug, Clone)]
674pub struct ServiceMonitoredDevice {
675 /// Device address/ID.
676 pub address: String,
677 /// Device alias.
678 pub alias: Option<String>,
679 /// Poll interval in seconds.
680 pub poll_interval: u64,
681}
682
683/// Signal quality assessment based on RSSI.
684#[derive(Debug, Clone, Copy, PartialEq, Eq)]
685pub enum SignalQuality {
686 /// Excellent signal (> -50 dBm).
687 Excellent,
688 /// Good signal (-50 to -70 dBm).
689 Good,
690 /// Fair signal (-70 to -80 dBm).
691 Fair,
692 /// Weak signal (< -80 dBm).
693 Weak,
694}
695
696impl SignalQuality {
697 /// Determine signal quality from RSSI value.
698 pub fn from_rssi(rssi: i16) -> Self {
699 match rssi {
700 r if r > -50 => SignalQuality::Excellent,
701 r if r > -70 => SignalQuality::Good,
702 r if r > -80 => SignalQuality::Fair,
703 _ => SignalQuality::Weak,
704 }
705 }
706
707 /// Get a user-friendly description of the signal quality.
708 pub fn description(&self) -> &'static str {
709 match self {
710 SignalQuality::Excellent => "Excellent",
711 SignalQuality::Good => "Good",
712 SignalQuality::Fair => "Fair",
713 SignalQuality::Weak => "Weak - move closer",
714 }
715 }
716}
717
718/// Statistics for a device being monitored by the service.
719#[derive(Debug, Clone)]
720pub struct ServiceDeviceStats {
721 /// Device identifier.
722 pub device_id: String,
723 /// Device alias/name.
724 pub alias: Option<String>,
725 /// Poll interval in seconds.
726 pub poll_interval: u64,
727 /// Whether the device is currently being polled.
728 pub polling: bool,
729 /// Number of successful polls.
730 pub success_count: u64,
731 /// Number of failed polls.
732 pub failure_count: u64,
733 /// Last poll time.
734 pub last_poll_at: Option<time::OffsetDateTime>,
735 /// Last error message.
736 pub last_error: Option<String>,
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742
743 #[test]
744 fn test_command_debug() {
745 let cmd = Command::Scan {
746 duration: Duration::from_secs(5),
747 };
748 let debug = format!("{:?}", cmd);
749 assert!(debug.contains("Scan"));
750 assert!(debug.contains("5"));
751 }
752
753 #[test]
754 fn test_command_clone() {
755 let cmd = Command::Connect {
756 device_id: "test-device".to_string(),
757 };
758 let cloned = cmd.clone();
759 match cloned {
760 Command::Connect { device_id } => assert_eq!(device_id, "test-device"),
761 other => panic!("Expected Connect variant, got {:?}", other),
762 }
763 }
764
765 #[test]
766 fn test_sensor_event_debug() {
767 let event = SensorEvent::ScanStarted;
768 let debug = format!("{:?}", event);
769 assert!(debug.contains("ScanStarted"));
770 }
771
772 #[test]
773 fn test_cached_device_default_values() {
774 let device = CachedDevice {
775 id: "test".to_string(),
776 name: None,
777 device_type: None,
778 reading: None,
779 last_sync: None,
780 };
781 assert_eq!(device.id, "test");
782 assert!(device.name.is_none());
783 }
784
785 #[test]
786 fn test_signal_quality_from_rssi() {
787 assert_eq!(SignalQuality::from_rssi(-40), SignalQuality::Excellent);
788 assert_eq!(SignalQuality::from_rssi(-50), SignalQuality::Good);
789 assert_eq!(SignalQuality::from_rssi(-60), SignalQuality::Good);
790 assert_eq!(SignalQuality::from_rssi(-70), SignalQuality::Fair);
791 assert_eq!(SignalQuality::from_rssi(-75), SignalQuality::Fair);
792 assert_eq!(SignalQuality::from_rssi(-80), SignalQuality::Weak);
793 assert_eq!(SignalQuality::from_rssi(-90), SignalQuality::Weak);
794 }
795
796 #[test]
797 fn test_signal_quality_description() {
798 assert_eq!(SignalQuality::Excellent.description(), "Excellent");
799 assert_eq!(SignalQuality::Good.description(), "Good");
800 assert_eq!(SignalQuality::Fair.description(), "Fair");
801 assert_eq!(SignalQuality::Weak.description(), "Weak - move closer");
802 }
803
804 #[test]
805 fn test_error_context_permanent() {
806 let ctx = ErrorContext::permanent("Device not found");
807 assert!(!ctx.retryable);
808 assert!(ctx.suggestion.is_none());
809 assert_eq!(ctx.message, "Device not found");
810 }
811
812 #[test]
813 fn test_error_context_transient() {
814 let ctx = ErrorContext::transient("Connection timeout", "Move closer and retry");
815 assert!(ctx.retryable);
816 assert_eq!(ctx.suggestion, Some("Move closer and retry".to_string()));
817 }
818}