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(_) => 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}