Skip to main content

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}