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