Skip to main content

aranet_cli/tui/
app.rs

1//! Application state for the TUI.
2//!
3//! This module contains the core state management for the terminal user interface,
4//! including device tracking, connection status, and UI navigation.
5
6use std::collections::VecDeque;
7use std::time::{Duration, Instant};
8
9use tokio::sync::mpsc;
10
11use aranet_core::settings::DeviceSettings;
12use aranet_types::{CurrentReading, DeviceType, HistoryRecord};
13
14use super::messages::{CachedDevice, Command, SensorEvent};
15
16/// Maximum number of alert history entries to retain.
17const MAX_ALERT_HISTORY: usize = 1000;
18
19/// Bluetooth range mode.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum BleRange {
22    #[default]
23    Standard,
24    Extended,
25}
26
27impl BleRange {
28    /// Get display name.
29    pub fn name(self) -> &'static str {
30        match self {
31            Self::Standard => "Standard",
32            Self::Extended => "Extended",
33        }
34    }
35
36    /// Toggle between modes.
37    pub fn toggle(self) -> Self {
38        match self {
39            Self::Standard => Self::Extended,
40            Self::Extended => Self::Standard,
41        }
42    }
43}
44
45/// UI theme.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum Theme {
48    #[default]
49    Dark,
50    Light,
51}
52
53impl Theme {
54    /// Get background color for this theme.
55    pub fn bg(self) -> ratatui::style::Color {
56        match self {
57            Self::Dark => ratatui::style::Color::Reset,
58            Self::Light => ratatui::style::Color::White,
59        }
60    }
61}
62
63/// Connection status for a device.
64#[derive(Debug, Clone, Default, PartialEq, Eq)]
65pub enum ConnectionStatus {
66    /// Device is not connected.
67    #[default]
68    Disconnected,
69    /// Device is currently connecting.
70    Connecting,
71    /// Device is connected and ready.
72    Connected,
73    /// Connection error occurred.
74    Error(String),
75}
76
77/// State for a single Aranet device.
78#[derive(Debug, Clone)]
79pub struct DeviceState {
80    /// Unique device identifier.
81    pub id: String,
82    /// Device name if known.
83    pub name: Option<String>,
84    /// User-defined alias for the device.
85    pub alias: Option<String>,
86    /// Device type if detected.
87    pub device_type: Option<DeviceType>,
88    /// Most recent sensor reading.
89    pub reading: Option<CurrentReading>,
90    /// Historical readings for sparkline display.
91    pub history: Vec<HistoryRecord>,
92    /// Current connection status.
93    pub status: ConnectionStatus,
94    /// When the device state was last updated.
95    pub last_updated: Option<Instant>,
96    /// Error message if an error occurred.
97    pub error: Option<String>,
98    /// Previous reading for trend calculation.
99    pub previous_reading: Option<CurrentReading>,
100    /// Session statistics for this device.
101    pub session_stats: SessionStats,
102    /// When history was last synced from the device.
103    pub last_sync: Option<time::OffsetDateTime>,
104    /// RSSI signal strength (dBm) if available.
105    pub rssi: Option<i16>,
106    /// When the device was connected (for uptime calculation).
107    pub connected_at: Option<std::time::Instant>,
108    /// Device settings read from the device.
109    pub settings: Option<DeviceSettings>,
110}
111
112impl DeviceState {
113    /// Create a new device state with the given ID.
114    pub fn new(id: String) -> Self {
115        Self {
116            id,
117            name: None,
118            alias: None,
119            device_type: None,
120            reading: None,
121            history: Vec::new(),
122            status: ConnectionStatus::Disconnected,
123            last_updated: None,
124            error: None,
125            previous_reading: None,
126            session_stats: SessionStats::default(),
127            last_sync: None,
128            rssi: None,
129            connected_at: None,
130            settings: None,
131        }
132    }
133
134    /// Get the display name (alias > name > id).
135    pub fn display_name(&self) -> &str {
136        self.alias
137            .as_deref()
138            .or(self.name.as_deref())
139            .unwrap_or(&self.id)
140    }
141
142    /// Get uptime as formatted string if connected.
143    pub fn uptime(&self) -> Option<String> {
144        let connected_at = self.connected_at?;
145        let elapsed = connected_at.elapsed();
146        let secs = elapsed.as_secs();
147
148        if secs < 60 {
149            Some(format!("{}s", secs))
150        } else if secs < 3600 {
151            Some(format!("{}m {}s", secs / 60, secs % 60))
152        } else {
153            let hours = secs / 3600;
154            let mins = (secs % 3600) / 60;
155            Some(format!("{}h {}m", hours, mins))
156        }
157    }
158}
159
160/// UI tab selection.
161#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
162pub enum Tab {
163    /// Main dashboard showing current readings.
164    #[default]
165    Dashboard,
166    /// Historical data view.
167    History,
168    /// Application settings.
169    Settings,
170    /// Service management.
171    Service,
172}
173
174/// Time range filter for history.
175#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
176pub enum HistoryFilter {
177    /// Show all history.
178    #[default]
179    All,
180    /// Show today's records only.
181    Today,
182    /// Show last 24 hours.
183    Last24Hours,
184    /// Show last 7 days.
185    Last7Days,
186    /// Show last 30 days.
187    Last30Days,
188    /// Custom date range.
189    /// Reserved for future UI support of custom date range selection.
190    #[allow(dead_code)]
191    Custom {
192        start: Option<time::Date>,
193        end: Option<time::Date>,
194    },
195}
196
197impl HistoryFilter {
198    /// Get display label for the filter.
199    pub fn label(&self) -> &'static str {
200        match self {
201            HistoryFilter::All => "All",
202            HistoryFilter::Today => "Today",
203            HistoryFilter::Last24Hours => "24h",
204            HistoryFilter::Last7Days => "7d",
205            HistoryFilter::Last30Days => "30d",
206            HistoryFilter::Custom { .. } => "Custom",
207        }
208    }
209}
210
211/// Export format for history data.
212#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
213pub enum ExportFormat {
214    /// CSV format.
215    #[default]
216    Csv,
217    /// JSON format.
218    Json,
219}
220
221impl ExportFormat {
222    /// Get file extension for this format.
223    pub fn extension(&self) -> &'static str {
224        match self {
225            ExportFormat::Csv => "csv",
226            ExportFormat::Json => "json",
227        }
228    }
229
230    /// Toggle to the other format.
231    pub fn toggle(&self) -> Self {
232        match self {
233            ExportFormat::Csv => ExportFormat::Json,
234            ExportFormat::Json => ExportFormat::Csv,
235        }
236    }
237}
238
239/// Filter for device list display.
240#[derive(Debug, Clone, Copy, PartialEq, Default)]
241pub enum DeviceFilter {
242    /// Show all devices.
243    #[default]
244    All,
245    /// Show only Aranet4 devices.
246    Aranet4Only,
247    /// Show only Aranet Radon devices.
248    RadonOnly,
249    /// Show only Aranet Radiation devices.
250    RadiationOnly,
251    /// Show only connected devices.
252    ConnectedOnly,
253}
254
255impl DeviceFilter {
256    /// Get display label for the filter.
257    pub fn label(&self) -> &'static str {
258        match self {
259            Self::All => "All",
260            Self::Aranet4Only => "Aranet4",
261            Self::RadonOnly => "Radon",
262            Self::RadiationOnly => "Radiation",
263            Self::ConnectedOnly => "Connected",
264        }
265    }
266
267    /// Cycle to next filter.
268    pub fn next(&self) -> Self {
269        match self {
270            Self::All => Self::Aranet4Only,
271            Self::Aranet4Only => Self::RadonOnly,
272            Self::RadonOnly => Self::RadiationOnly,
273            Self::RadiationOnly => Self::ConnectedOnly,
274            Self::ConnectedOnly => Self::All,
275        }
276    }
277}
278
279/// Alert severity level.
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub enum AlertSeverity {
282    /// Informational alert (blue).
283    Info,
284    /// Warning alert (yellow).
285    Warning,
286    /// Critical alert (red).
287    Critical,
288}
289
290impl AlertSeverity {
291    /// Get the color for this severity.
292    pub fn color(self) -> ratatui::style::Color {
293        match self {
294            Self::Info => ratatui::style::Color::Blue,
295            Self::Warning => ratatui::style::Color::Yellow,
296            Self::Critical => ratatui::style::Color::Red,
297        }
298    }
299
300    /// Get the icon for this severity.
301    pub fn icon(self) -> &'static str {
302        match self {
303            Self::Info => "(i)",
304            Self::Warning => "(!)",
305            Self::Critical => "(X)",
306        }
307    }
308}
309
310/// An active alert for a device.
311#[derive(Debug, Clone)]
312#[allow(dead_code)]
313pub struct Alert {
314    /// Device ID that triggered the alert.
315    pub device_id: String,
316    /// Device name for display.
317    pub device_name: Option<String>,
318    /// Alert message.
319    pub message: String,
320    /// CO2 level that triggered the alert.
321    pub level: aranet_core::Co2Level,
322    /// When the alert was triggered.
323    pub triggered_at: Instant,
324    /// Severity level of the alert.
325    pub severity: AlertSeverity,
326}
327
328/// Record of a past alert for history viewing.
329#[derive(Debug, Clone)]
330pub struct AlertRecord {
331    /// Device name or ID.
332    pub device_name: String,
333    /// Alert message.
334    pub message: String,
335    /// When the alert was triggered.
336    pub timestamp: time::OffsetDateTime,
337    /// Severity level of the alert.
338    pub severity: AlertSeverity,
339}
340
341/// Session statistics for a device.
342#[derive(Debug, Clone, Default)]
343pub struct SessionStats {
344    /// Minimum CO2 reading in session.
345    pub co2_min: Option<u16>,
346    /// Maximum CO2 reading in session.
347    pub co2_max: Option<u16>,
348    /// Sum of CO2 readings for average calculation.
349    pub co2_sum: u64,
350    /// Count of CO2 readings.
351    pub co2_count: u32,
352    /// Minimum temperature in session.
353    pub temp_min: Option<f32>,
354    /// Maximum temperature in session.
355    pub temp_max: Option<f32>,
356}
357
358impl SessionStats {
359    /// Update statistics with a new reading.
360    pub fn update(&mut self, reading: &CurrentReading) {
361        // Only track non-zero CO2 (Aranet4)
362        if reading.co2 > 0 {
363            self.co2_min = Some(self.co2_min.map_or(reading.co2, |m| m.min(reading.co2)));
364            self.co2_max = Some(self.co2_max.map_or(reading.co2, |m| m.max(reading.co2)));
365            self.co2_sum += reading.co2 as u64;
366            self.co2_count += 1;
367        }
368
369        // Temperature
370        self.temp_min = Some(
371            self.temp_min
372                .map_or(reading.temperature, |m| m.min(reading.temperature)),
373        );
374        self.temp_max = Some(
375            self.temp_max
376                .map_or(reading.temperature, |m| m.max(reading.temperature)),
377        );
378    }
379
380    /// Get average CO2.
381    pub fn co2_avg(&self) -> Option<u16> {
382        if self.co2_count > 0 {
383            Some((self.co2_sum / self.co2_count as u64) as u16)
384        } else {
385            None
386        }
387    }
388}
389
390/// Calculate radon averages from history records.
391pub fn calculate_radon_averages(history: &[HistoryRecord]) -> (Option<u32>, Option<u32>) {
392    use time::OffsetDateTime;
393
394    let now = OffsetDateTime::now_utc();
395    let day_ago = now - time::Duration::days(1);
396    let week_ago = now - time::Duration::days(7);
397
398    let mut day_sum: u64 = 0;
399    let mut day_count: u32 = 0;
400    let mut week_sum: u64 = 0;
401    let mut week_count: u32 = 0;
402
403    for record in history {
404        if let Some(radon) = record.radon
405            && record.timestamp >= week_ago
406        {
407            week_sum += radon as u64;
408            week_count += 1;
409
410            if record.timestamp >= day_ago {
411                day_sum += radon as u64;
412                day_count += 1;
413            }
414        }
415    }
416
417    let day_avg = if day_count > 0 {
418        Some((day_sum / day_count as u64) as u32)
419    } else {
420        None
421    };
422
423    let week_avg = if week_count > 0 {
424        Some((week_sum / week_count as u64) as u32)
425    } else {
426        None
427    };
428
429    (day_avg, week_avg)
430}
431
432/// Actions that require user confirmation.
433#[derive(Debug, Clone)]
434pub enum PendingAction {
435    /// Disconnect from device.
436    Disconnect {
437        device_id: String,
438        device_name: String,
439    },
440}
441
442/// Main application state for the TUI.
443pub struct App {
444    /// Whether the application should exit.
445    pub should_quit: bool,
446    /// Currently active UI tab.
447    pub active_tab: Tab,
448    /// Index of the currently selected device.
449    pub selected_device: usize,
450    /// List of all known devices.
451    pub devices: Vec<DeviceState>,
452    /// Whether a device scan is in progress.
453    pub scanning: bool,
454    /// Queue of status messages with their creation time.
455    pub status_messages: Vec<(String, Instant)>,
456    /// How long to show each status message (in seconds).
457    pub status_message_timeout: u64,
458    /// Whether to show the help overlay.
459    pub show_help: bool,
460    /// Channel for sending commands to the background worker.
461    #[allow(dead_code)]
462    pub command_tx: mpsc::Sender<Command>,
463    /// Channel for receiving events from the background worker.
464    pub event_rx: mpsc::Receiver<SensorEvent>,
465    /// Threshold evaluator for CO2 levels.
466    pub thresholds: aranet_core::Thresholds,
467    /// Active alerts for devices.
468    pub alerts: Vec<Alert>,
469    /// History of all alerts (for viewing, newest last).
470    pub alert_history: VecDeque<AlertRecord>,
471    /// Whether to show alert history overlay.
472    pub show_alert_history: bool,
473    /// Path to log file for data logging.
474    pub log_file: Option<std::path::PathBuf>,
475    /// Whether logging is enabled.
476    pub logging_enabled: bool,
477    /// When the last auto-refresh was triggered.
478    pub last_auto_refresh: Option<Instant>,
479    /// Auto-refresh interval (uses device interval or 60s default).
480    pub auto_refresh_interval: Duration,
481    /// Scroll offset for history list in History tab.
482    pub history_scroll: usize,
483    /// Time range filter for history display.
484    pub history_filter: HistoryFilter,
485    /// Spinner animation frame counter.
486    pub spinner_frame: usize,
487    /// Currently selected setting in the Settings tab.
488    pub selected_setting: usize,
489    /// Available interval options in seconds.
490    pub interval_options: Vec<u16>,
491    /// Custom CO2 alert threshold (ppm). Default is 1500 (Poor level).
492    pub co2_alert_threshold: u16,
493    /// Custom radon alert threshold (Bq/m³). Default is 300.
494    pub radon_alert_threshold: u16,
495    /// Whether to ring terminal bell on alerts.
496    pub bell_enabled: bool,
497    /// Device list filter.
498    pub device_filter: DeviceFilter,
499    /// Pending confirmation action.
500    pub pending_confirmation: Option<PendingAction>,
501    /// Whether to show the device sidebar (can be hidden on narrow terminals).
502    pub show_sidebar: bool,
503    /// Whether to show full-screen chart view.
504    pub show_fullscreen_chart: bool,
505    /// Whether currently editing device alias.
506    pub editing_alias: bool,
507    /// Current alias input buffer.
508    pub alias_input: String,
509    /// Whether alerts are sticky (don't auto-clear when condition improves).
510    pub sticky_alerts: bool,
511    /// Last error message (full details).
512    pub last_error: Option<String>,
513    /// Whether to show error details popup.
514    pub show_error_details: bool,
515    /// Whether comparison view is active.
516    pub show_comparison: bool,
517    /// Index of second device for comparison (first is selected_device).
518    pub comparison_device_index: Option<usize>,
519    /// Sidebar width (default 28, wide 40).
520    pub sidebar_width: u16,
521    /// Current UI theme.
522    pub theme: Theme,
523    /// Which metrics to show on sparkline (bitmask: 1=primary, 2=temp, 4=humidity).
524    pub chart_metrics: u8,
525    /// Whether Smart Home integration mode is enabled.
526    pub smart_home_enabled: bool,
527    /// Bluetooth range setting.
528    pub ble_range: BleRange,
529    /// Whether a history sync is in progress.
530    pub syncing: bool,
531    /// Export format for history (CSV or JSON).
532    pub export_format: ExportFormat,
533    /// Do Not Disturb mode - temporarily suppresses all alert notifications.
534    pub do_not_disturb: bool,
535    /// Service client for aranet-service communication.
536    /// Currently unused as communication goes through the worker.
537    #[allow(dead_code)]
538    pub service_client: Option<aranet_core::service_client::ServiceClient>,
539    /// Service URL (default: http://localhost:8080).
540    pub service_url: String,
541    /// Last known service status.
542    pub service_status: Option<ServiceState>,
543    /// Whether the service is being refreshed.
544    pub service_refreshing: bool,
545    /// Selected item in service tab (0=start/stop, 1+=devices).
546    pub service_selected_item: usize,
547}
548
549/// State of the aranet-service.
550#[derive(Debug, Clone)]
551pub struct ServiceState {
552    /// Whether the service is reachable.
553    pub reachable: bool,
554    /// Whether the collector is running.
555    pub collector_running: bool,
556    /// When the collector was started (for display purposes).
557    #[allow(dead_code)]
558    pub started_at: Option<time::OffsetDateTime>,
559    /// Uptime in seconds.
560    pub uptime_seconds: Option<u64>,
561    /// Per-device collection statistics.
562    pub devices: Vec<aranet_core::service_client::DeviceCollectionStats>,
563    /// Last status fetch time (for staleness detection).
564    #[allow(dead_code)]
565    pub fetched_at: Instant,
566}
567
568impl App {
569    /// Create a new application with the given command and event channels.
570    pub fn new(
571        command_tx: mpsc::Sender<Command>,
572        event_rx: mpsc::Receiver<SensorEvent>,
573        service_url: String,
574        service_api_key: Option<String>,
575    ) -> Self {
576        Self {
577            should_quit: false,
578            active_tab: Tab::default(),
579            selected_device: 0,
580            devices: Vec::new(),
581            scanning: false,
582            status_messages: Vec::new(),
583            status_message_timeout: 5, // 5 seconds
584            show_help: false,
585            command_tx,
586            event_rx,
587            thresholds: aranet_core::Thresholds::default(),
588            alerts: Vec::new(),
589            alert_history: VecDeque::new(),
590            show_alert_history: false,
591            log_file: None,
592            logging_enabled: false,
593            last_auto_refresh: None,
594            auto_refresh_interval: Duration::from_secs(60),
595            history_scroll: 0,
596            history_filter: HistoryFilter::default(),
597            spinner_frame: 0,
598            selected_setting: 0,
599            interval_options: vec![60, 120, 300, 600], // 1, 2, 5, 10 minutes
600            co2_alert_threshold: 1500,
601            radon_alert_threshold: 300,
602            bell_enabled: true,
603            device_filter: DeviceFilter::default(),
604            pending_confirmation: None,
605            show_sidebar: true,
606            show_fullscreen_chart: false,
607            editing_alias: false,
608            alias_input: String::new(),
609            sticky_alerts: false,
610            last_error: None,
611            show_error_details: false,
612            show_comparison: false,
613            comparison_device_index: None,
614            sidebar_width: 28,
615            theme: Theme::default(),
616            chart_metrics: Self::METRIC_PRIMARY, // Primary metric only by default
617            smart_home_enabled: false,
618            ble_range: BleRange::default(),
619            syncing: false,
620            export_format: ExportFormat::default(),
621            do_not_disturb: false,
622            service_client: aranet_core::service_client::ServiceClient::new_with_api_key(
623                &service_url,
624                service_api_key,
625            )
626            .ok(),
627            service_url,
628            service_status: None,
629            service_refreshing: false,
630            service_selected_item: 0,
631        }
632    }
633
634    /// Toggle Bluetooth range.
635    pub fn toggle_ble_range(&mut self) {
636        self.ble_range = self.ble_range.toggle();
637        self.push_status_message(format!("BLE range: {}", self.ble_range.name()));
638    }
639
640    /// Bitmask constant for primary metric (CO2/Radon/Radiation).
641    pub const METRIC_PRIMARY: u8 = 0b001;
642    /// Bitmask constant for temperature metric.
643    pub const METRIC_TEMP: u8 = 0b010;
644    /// Bitmask constant for humidity metric.
645    pub const METRIC_HUMIDITY: u8 = 0b100;
646
647    /// Toggle a metric on the chart.
648    pub fn toggle_chart_metric(&mut self, metric: u8) {
649        self.chart_metrics ^= metric;
650        // Ensure at least one metric is shown
651        if self.chart_metrics == 0 {
652            self.chart_metrics = Self::METRIC_PRIMARY;
653        }
654    }
655
656    /// Check if a metric is enabled on chart.
657    pub fn chart_shows(&self, metric: u8) -> bool {
658        self.chart_metrics & metric != 0
659    }
660
661    /// Toggle between light and dark theme.
662    pub fn toggle_theme(&mut self) {
663        self.theme = match self.theme {
664            Theme::Dark => Theme::Light,
665            Theme::Light => Theme::Dark,
666        };
667    }
668
669    /// Get the current AppTheme based on the theme setting.
670    #[must_use]
671    pub fn app_theme(&self) -> super::ui::theme::AppTheme {
672        match self.theme {
673            Theme::Dark => super::ui::theme::AppTheme::dark(),
674            Theme::Light => super::ui::theme::AppTheme::light(),
675        }
676    }
677
678    /// Toggle Smart Home mode.
679    pub fn toggle_smart_home(&mut self) {
680        self.smart_home_enabled = !self.smart_home_enabled;
681        let status = if self.smart_home_enabled {
682            "enabled"
683        } else {
684            "disabled"
685        };
686        self.push_status_message(format!("Smart Home mode {}", status));
687    }
688
689    /// Toggle full-screen chart view.
690    pub fn toggle_fullscreen_chart(&mut self) {
691        self.show_fullscreen_chart = !self.show_fullscreen_chart;
692    }
693
694    /// Returns whether the application should quit.
695    pub fn should_quit(&self) -> bool {
696        self.should_quit
697    }
698
699    /// Add a status message to the queue.
700    pub fn push_status_message(&mut self, message: String) {
701        self.status_messages.push((message, Instant::now()));
702        // Keep at most 5 messages
703        while self.status_messages.len() > 5 {
704            self.status_messages.remove(0);
705        }
706    }
707
708    /// Remove expired status messages.
709    pub fn clean_expired_messages(&mut self) {
710        let timeout = std::time::Duration::from_secs(self.status_message_timeout);
711        self.status_messages
712            .retain(|(_, created)| created.elapsed() < timeout);
713    }
714
715    /// Get the current status message to display.
716    pub fn current_status_message(&self) -> Option<&str> {
717        self.status_messages.last().map(|(msg, _)| msg.as_str())
718    }
719
720    /// Handle an incoming sensor event and update state accordingly.
721    ///
722    /// Returns a list of commands to send to the worker (for auto-connect, auto-sync, etc.).
723    pub fn handle_sensor_event(&mut self, event: SensorEvent) -> Vec<Command> {
724        match event {
725            // Device discovery and connection lifecycle
726            SensorEvent::CachedDataLoaded { .. }
727            | SensorEvent::ScanStarted
728            | SensorEvent::ScanComplete { .. }
729            | SensorEvent::DeviceConnecting { .. }
730            | SensorEvent::DeviceConnected { .. }
731            | SensorEvent::DeviceDisconnected { .. }
732            | SensorEvent::AliasChanged { .. }
733            | SensorEvent::DeviceForgotten { .. }
734            | SensorEvent::SignalStrengthUpdate { .. }
735            | SensorEvent::BackgroundPollingStarted { .. }
736            | SensorEvent::BackgroundPollingStopped { .. } => self.handle_device_event(event),
737
738            // Reading updates and history
739            SensorEvent::ReadingUpdated { .. }
740            | SensorEvent::HistoryLoaded { .. }
741            | SensorEvent::HistorySyncStarted { .. }
742            | SensorEvent::HistorySynced { .. }
743            | SensorEvent::HistorySyncProgress { .. } => self.handle_reading_event(event),
744
745            // Device settings changes
746            SensorEvent::IntervalChanged { .. }
747            | SensorEvent::SettingsLoaded { .. }
748            | SensorEvent::BluetoothRangeChanged { .. }
749            | SensorEvent::SmartHomeChanged { .. } => {
750                self.handle_settings_event(event);
751                Vec::new()
752            }
753
754            // Error events
755            SensorEvent::ScanError { .. }
756            | SensorEvent::ConnectionError { .. }
757            | SensorEvent::ReadingError { .. }
758            | SensorEvent::HistorySyncError { .. }
759            | SensorEvent::IntervalError { .. }
760            | SensorEvent::BluetoothRangeError { .. }
761            | SensorEvent::SmartHomeError { .. }
762            | SensorEvent::AliasError { .. }
763            | SensorEvent::ForgetDeviceError { .. } => {
764                self.handle_error_event(event);
765                Vec::new()
766            }
767
768            // Service status and management
769            SensorEvent::ServiceStatusRefreshed { .. }
770            | SensorEvent::ServiceStatusError { .. }
771            | SensorEvent::ServiceCollectorStarted
772            | SensorEvent::ServiceCollectorStopped
773            | SensorEvent::ServiceCollectorError { .. } => {
774                self.handle_service_event(event);
775                Vec::new()
776            }
777
778            // Misc events
779            SensorEvent::OperationCancelled { operation } => {
780                self.push_status_message(format!("{} cancelled", operation));
781                Vec::new()
782            }
783
784            // System service events - not displayed in TUI
785            SensorEvent::SystemServiceStatus { .. }
786            | SensorEvent::SystemServiceInstalled
787            | SensorEvent::SystemServiceUninstalled
788            | SensorEvent::SystemServiceStarted
789            | SensorEvent::SystemServiceStopped
790            | SensorEvent::SystemServiceError { .. }
791            | SensorEvent::ServiceConfigFetched { .. }
792            | SensorEvent::ServiceConfigError { .. }
793            | SensorEvent::ServiceDeviceAdded { .. }
794            | SensorEvent::ServiceDeviceUpdated { .. }
795            | SensorEvent::ServiceDeviceRemoved { .. }
796            | SensorEvent::ServiceDeviceError { .. } => Vec::new(),
797        }
798    }
799
800    /// Handle device discovery, connection, disconnection, and identity events.
801    ///
802    /// Returns commands for auto-connect or auto-sync side effects.
803    fn handle_device_event(&mut self, event: SensorEvent) -> Vec<Command> {
804        let mut commands = Vec::new();
805
806        match event {
807            SensorEvent::CachedDataLoaded { devices } => {
808                let device_ids: Vec<String> = devices.iter().map(|d| d.id.clone()).collect();
809                self.handle_cached_data(devices);
810
811                // Auto-connect to all cached devices
812                for device_id in device_ids {
813                    commands.push(Command::Connect { device_id });
814                }
815            }
816            SensorEvent::ScanStarted => {
817                self.scanning = true;
818                self.push_status_message("Scanning for devices...".to_string());
819            }
820            SensorEvent::ScanComplete { devices } => {
821                self.scanning = false;
822                self.push_status_message(format!("Found {} device(s)", devices.len()));
823                for discovered in devices {
824                    let id_str = discovered.id.to_string();
825                    if !self.devices.iter().any(|d| d.id == id_str) {
826                        let mut device = DeviceState::new(id_str);
827                        device.name = discovered.name;
828                        device.device_type = discovered.device_type;
829                        self.devices.push(device);
830                    }
831                }
832            }
833            SensorEvent::DeviceConnecting { device_id } => {
834                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
835                    device.status = ConnectionStatus::Connecting;
836                    device.last_updated = Some(Instant::now());
837                }
838                self.push_status_message("Connecting...".to_string());
839            }
840            SensorEvent::DeviceConnected {
841                device_id,
842                name,
843                device_type,
844                rssi,
845            } => {
846                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
847                    device.status = ConnectionStatus::Connected;
848                    device.name = name.or(device.name.take());
849                    device.device_type = device_type.or(device.device_type);
850                    device.rssi = rssi;
851                    device.last_updated = Some(Instant::now());
852                    device.error = None;
853                    device.connected_at = Some(Instant::now());
854                }
855                self.push_status_message("Connected".to_string());
856
857                // Auto-sync history after successful connection
858                commands.push(Command::SyncHistory {
859                    device_id: device_id.clone(),
860                });
861            }
862            SensorEvent::DeviceDisconnected { device_id } => {
863                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
864                    device.status = ConnectionStatus::Disconnected;
865                    device.last_updated = Some(Instant::now());
866                    device.connected_at = None;
867                }
868            }
869            SensorEvent::AliasChanged { device_id, alias } => {
870                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
871                    device.name = alias;
872                }
873                self.push_status_message("Device renamed".to_string());
874            }
875            SensorEvent::DeviceForgotten { device_id } => {
876                if let Some(pos) = self.devices.iter().position(|d| d.id == device_id) {
877                    self.devices.remove(pos);
878                    if self.selected_device >= self.devices.len() && !self.devices.is_empty() {
879                        self.selected_device = self.devices.len() - 1;
880                    }
881                }
882                self.push_status_message("Device forgotten".to_string());
883            }
884            SensorEvent::SignalStrengthUpdate {
885                device_id,
886                rssi,
887                quality: _,
888            } => {
889                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
890                    device.rssi = Some(rssi);
891                }
892            }
893            SensorEvent::BackgroundPollingStarted {
894                device_id: _,
895                interval_secs,
896            } => {
897                self.push_status_message(format!(
898                    "Background polling started ({}s interval)",
899                    interval_secs
900                ));
901            }
902            SensorEvent::BackgroundPollingStopped { device_id: _ } => {
903                self.push_status_message("Background polling stopped".to_string());
904            }
905            _ => {}
906        }
907
908        self.ensure_selected_device_visible();
909        commands
910    }
911
912    /// Handle reading updates and history sync events.
913    ///
914    /// Returns commands for side effects (currently none, but kept for consistency).
915    fn handle_reading_event(&mut self, event: SensorEvent) -> Vec<Command> {
916        match event {
917            SensorEvent::ReadingUpdated { device_id, reading } => {
918                self.check_thresholds(&device_id, &reading);
919                self.log_reading(&device_id, &reading);
920
921                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
922                    device.session_stats.update(&reading);
923                    device.previous_reading = device.reading.take();
924                    device.reading = Some(reading);
925                    device.last_updated = Some(Instant::now());
926                    device.error = None;
927                }
928            }
929            SensorEvent::HistoryLoaded { device_id, records } => {
930                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
931                    device.history = records;
932                    device.last_updated = Some(Instant::now());
933                }
934            }
935            SensorEvent::HistorySyncStarted { device_id, .. } => {
936                self.syncing = true;
937                self.push_status_message(format!("Syncing history for {}...", device_id));
938            }
939            SensorEvent::HistorySynced { device_id, count } => {
940                self.syncing = false;
941                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
942                    device.last_sync = Some(time::OffsetDateTime::now_utc());
943                }
944                self.push_status_message(format!("Synced {} records for {}", count, device_id));
945            }
946            SensorEvent::HistorySyncProgress {
947                device_id: _,
948                downloaded,
949                total,
950            } => {
951                self.push_status_message(format!(
952                    "Syncing history: {}/{} records",
953                    downloaded, total
954                ));
955            }
956            _ => {}
957        }
958
959        Vec::new()
960    }
961
962    /// Handle device settings change confirmations (interval, BT range, smart home).
963    fn handle_settings_event(&mut self, event: SensorEvent) {
964        match event {
965            SensorEvent::IntervalChanged {
966                device_id,
967                interval_secs,
968            } => {
969                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id)
970                    && let Some(reading) = &mut device.reading
971                {
972                    reading.interval = interval_secs;
973                }
974                self.push_status_message(format!("Interval set to {}m", interval_secs / 60));
975            }
976            SensorEvent::SettingsLoaded {
977                device_id,
978                settings,
979            } => {
980                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
981                    device.settings = Some(settings);
982                    device.last_updated = Some(Instant::now());
983                }
984            }
985            SensorEvent::BluetoothRangeChanged {
986                device_id: _,
987                extended,
988            } => {
989                let range = if extended { "Extended" } else { "Standard" };
990                self.push_status_message(format!("Bluetooth range set to {}", range));
991            }
992            SensorEvent::SmartHomeChanged {
993                device_id: _,
994                enabled,
995            } => {
996                let mode = if enabled { "enabled" } else { "disabled" };
997                self.push_status_message(format!("Smart Home {}", mode));
998            }
999            _ => {}
1000        }
1001    }
1002
1003    /// Handle all error events by setting the error state and displaying a status message.
1004    fn handle_error_event(&mut self, event: SensorEvent) {
1005        match event {
1006            SensorEvent::ScanError { error } => {
1007                self.scanning = false;
1008                let error_msg = format!("Scan: {}", error);
1009                self.set_error(error_msg);
1010                self.push_status_message(format!(
1011                    "Scan error: {} (press E for details)",
1012                    error.chars().take(40).collect::<String>()
1013                ));
1014            }
1015            SensorEvent::ConnectionError {
1016                device_id, error, ..
1017            } => {
1018                let device_name = self.device_display_name(&device_id);
1019                let error_msg = format!("{}: {}", device_name, error);
1020                self.set_error(error_msg);
1021                self.push_status_message(format!(
1022                    "Connection error: {} (press E for details)",
1023                    error.chars().take(40).collect::<String>()
1024                ));
1025                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
1026                    device.status = ConnectionStatus::Error(error.clone());
1027                    device.error = Some(error);
1028                    device.last_updated = Some(Instant::now());
1029                }
1030            }
1031            SensorEvent::ReadingError {
1032                device_id, error, ..
1033            } => {
1034                let device_name = self.device_display_name(&device_id);
1035                let error_msg = format!("{}: {}", device_name, error);
1036                self.set_error(error_msg);
1037                self.push_status_message(format!(
1038                    "Reading error: {} (press E for details)",
1039                    error.chars().take(40).collect::<String>()
1040                ));
1041                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
1042                    device.error = Some(error);
1043                    device.last_updated = Some(Instant::now());
1044                }
1045            }
1046            SensorEvent::HistorySyncError {
1047                device_id, error, ..
1048            } => {
1049                self.syncing = false;
1050                let device_name = self.device_display_name(&device_id);
1051                let error_msg = format!("{}: {}", device_name, error);
1052                self.set_error(error_msg);
1053                self.push_status_message(format!(
1054                    "History sync failed: {} (press E for details)",
1055                    error.chars().take(40).collect::<String>()
1056                ));
1057                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
1058                    device.error = Some(error);
1059                }
1060            }
1061            SensorEvent::IntervalError {
1062                device_id,
1063                error,
1064                context,
1065            } => {
1066                let device_name = self.device_display_name(&device_id);
1067                let error_msg = Self::format_error_with_context(&device_name, &error, &context);
1068                self.set_error(error_msg);
1069                self.push_status_message(format!(
1070                    "Set interval failed: {} (press E for details)",
1071                    error.chars().take(40).collect::<String>()
1072                ));
1073                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
1074                    device.error = Some(error);
1075                }
1076            }
1077            SensorEvent::BluetoothRangeError {
1078                device_id,
1079                error,
1080                context,
1081            } => {
1082                let device_name = self.device_name_or_id(&device_id);
1083                let error_msg = Self::format_error_with_context(&device_name, &error, &context);
1084                self.set_error(error_msg);
1085                self.push_status_message(format!(
1086                    "Set BT range failed: {} (press E for details)",
1087                    error.chars().take(40).collect::<String>()
1088                ));
1089            }
1090            SensorEvent::SmartHomeError {
1091                device_id,
1092                error,
1093                context,
1094            } => {
1095                let device_name = self.device_name_or_id(&device_id);
1096                let error_msg = Self::format_error_with_context(&device_name, &error, &context);
1097                self.set_error(error_msg);
1098                self.push_status_message(format!(
1099                    "Set Smart Home failed: {} (press E for details)",
1100                    error.chars().take(40).collect::<String>()
1101                ));
1102            }
1103            SensorEvent::AliasError {
1104                device_id: _,
1105                error,
1106            } => {
1107                self.push_status_message(format!("Rename failed: {}", error));
1108            }
1109            SensorEvent::ForgetDeviceError {
1110                device_id: _,
1111                error,
1112            } => {
1113                self.push_status_message(format!("Forget failed: {}", error));
1114            }
1115            _ => {}
1116        }
1117    }
1118
1119    /// Handle service status and collector management events.
1120    fn handle_service_event(&mut self, event: SensorEvent) {
1121        match event {
1122            SensorEvent::ServiceStatusRefreshed {
1123                reachable,
1124                collector_running,
1125                uptime_seconds,
1126                devices,
1127            } => {
1128                self.service_refreshing = false;
1129                self.service_status = Some(ServiceState {
1130                    reachable,
1131                    collector_running,
1132                    started_at: None,
1133                    uptime_seconds,
1134                    devices: devices
1135                        .into_iter()
1136                        .map(|d| aranet_core::service_client::DeviceCollectionStats {
1137                            device_id: d.device_id,
1138                            alias: d.alias,
1139                            poll_interval: d.poll_interval,
1140                            polling: d.polling,
1141                            success_count: d.success_count,
1142                            failure_count: d.failure_count,
1143                            last_poll_at: d.last_poll_at,
1144                            last_error_at: None,
1145                            last_error: d.last_error,
1146                        })
1147                        .collect(),
1148                    fetched_at: Instant::now(),
1149                });
1150                if reachable {
1151                    let status = if collector_running {
1152                        "running"
1153                    } else {
1154                        "stopped"
1155                    };
1156                    self.push_status_message(format!("Service collector: {}", status));
1157                } else {
1158                    self.push_status_message("Service not reachable".to_string());
1159                }
1160            }
1161            SensorEvent::ServiceStatusError { error } => {
1162                self.service_refreshing = false;
1163                self.push_status_message(format!("Service error: {}", error));
1164            }
1165            SensorEvent::ServiceCollectorStarted => {
1166                self.push_status_message("Collector started".to_string());
1167            }
1168            SensorEvent::ServiceCollectorStopped => {
1169                self.push_status_message("Collector stopped".to_string());
1170            }
1171            SensorEvent::ServiceCollectorError { error } => {
1172                self.push_status_message(format!("Collector error: {}", error));
1173            }
1174            _ => {}
1175        }
1176    }
1177
1178    /// Get the display name for a device, falling back to its ID.
1179    fn device_display_name(&self, device_id: &str) -> String {
1180        self.devices
1181            .iter()
1182            .find(|d| d.id == device_id)
1183            .map(|d| d.display_name().to_string())
1184            .unwrap_or_else(|| device_id.to_string())
1185    }
1186
1187    /// Get the device name (not alias), falling back to its ID.
1188    fn device_name_or_id(&self, device_id: &str) -> String {
1189        self.devices
1190            .iter()
1191            .find(|d| d.id == device_id)
1192            .and_then(|d| d.name.clone())
1193            .unwrap_or_else(|| device_id.to_string())
1194    }
1195
1196    /// Format an error message, including the suggestion from context if available.
1197    fn format_error_with_context(
1198        device_name: &str,
1199        error: &str,
1200        context: &Option<aranet_core::messages::ErrorContext>,
1201    ) -> String {
1202        if let Some(ctx) = context
1203            && let Some(suggestion) = &ctx.suggestion
1204        {
1205            return format!("{}: {}. {}", device_name, error, suggestion);
1206        }
1207        format!("{}: {}", device_name, error)
1208    }
1209
1210    /// Get a reference to the currently selected device, if any.
1211    pub fn selected_device(&self) -> Option<&DeviceState> {
1212        self.devices.get(self.selected_device)
1213    }
1214
1215    /// Get device indices matching the current filter.
1216    #[must_use]
1217    pub fn filtered_device_indices(&self) -> Vec<usize> {
1218        self.devices
1219            .iter()
1220            .enumerate()
1221            .filter(|(_, d)| match self.device_filter {
1222                DeviceFilter::All => true,
1223                DeviceFilter::Aranet4Only => {
1224                    matches!(d.device_type, Some(DeviceType::Aranet4))
1225                }
1226                DeviceFilter::RadonOnly => {
1227                    matches!(d.device_type, Some(DeviceType::AranetRadon))
1228                }
1229                DeviceFilter::RadiationOnly => {
1230                    matches!(d.device_type, Some(DeviceType::AranetRadiation))
1231                }
1232                DeviceFilter::ConnectedOnly => {
1233                    matches!(d.status, ConnectionStatus::Connected)
1234                }
1235            })
1236            .map(|(index, _)| index)
1237            .collect()
1238    }
1239
1240    /// Update selection to match the currently visible filtered list.
1241    pub fn ensure_selected_device_visible(&mut self) {
1242        if self.devices.is_empty() {
1243            self.selected_device = 0;
1244            return;
1245        }
1246
1247        let filtered = self.filtered_device_indices();
1248        if filtered.is_empty() {
1249            return;
1250        }
1251
1252        if !filtered.contains(&self.selected_device) {
1253            self.selected_device = filtered[0];
1254            self.reset_history_scroll();
1255        }
1256    }
1257
1258    /// Select a device by its current filtered row.
1259    pub fn select_filtered_row(&mut self, row: usize) {
1260        if let Some(index) = self.filtered_device_indices().get(row).copied() {
1261            self.selected_device = index;
1262            self.reset_history_scroll();
1263        }
1264    }
1265
1266    /// Select the next device in the list.
1267    pub fn select_next_device(&mut self) {
1268        let filtered = self.filtered_device_indices();
1269        if !filtered.is_empty() {
1270            let current = filtered
1271                .iter()
1272                .position(|&idx| idx == self.selected_device)
1273                .unwrap_or(0);
1274            self.selected_device = filtered[(current + 1) % filtered.len()];
1275            self.reset_history_scroll();
1276        }
1277    }
1278
1279    /// Select the previous device in the list.
1280    pub fn select_previous_device(&mut self) {
1281        let filtered = self.filtered_device_indices();
1282        if !filtered.is_empty() {
1283            let current = filtered
1284                .iter()
1285                .position(|&idx| idx == self.selected_device)
1286                .unwrap_or(0);
1287            self.selected_device = filtered
1288                .get(current.checked_sub(1).unwrap_or(filtered.len() - 1))
1289                .copied()
1290                .unwrap_or(self.selected_device);
1291            self.reset_history_scroll();
1292        }
1293    }
1294
1295    /// Scroll history list up by one page.
1296    pub fn scroll_history_up(&mut self) {
1297        self.history_scroll = self.history_scroll.saturating_sub(5);
1298    }
1299
1300    /// Scroll history list down by one page.
1301    pub fn scroll_history_down(&mut self) {
1302        if let Some(device) = self.selected_device() {
1303            let max_scroll = device.history.len().saturating_sub(10);
1304            self.history_scroll = (self.history_scroll + 5).min(max_scroll);
1305        }
1306    }
1307
1308    /// Reset history scroll when device changes.
1309    pub fn reset_history_scroll(&mut self) {
1310        self.history_scroll = 0;
1311    }
1312
1313    /// Advance the spinner animation frame.
1314    pub fn tick_spinner(&mut self) {
1315        self.spinner_frame = (self.spinner_frame + 1) % 10;
1316    }
1317
1318    /// Get the current spinner character.
1319    pub fn spinner_char(&self) -> &'static str {
1320        const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1321        SPINNER[self.spinner_frame]
1322    }
1323
1324    /// Set history filter.
1325    pub fn set_history_filter(&mut self, filter: HistoryFilter) {
1326        self.history_filter = filter;
1327        self.history_scroll = 0; // Reset scroll when filter changes
1328    }
1329
1330    /// Cycle device filter to next option.
1331    pub fn cycle_device_filter(&mut self) {
1332        self.device_filter = self.device_filter.next();
1333        self.ensure_selected_device_visible();
1334        self.push_status_message(format!("Filter: {}", self.device_filter.label()));
1335    }
1336
1337    /// Select the next setting in the Settings tab.
1338    pub fn select_next_setting(&mut self) {
1339        self.selected_setting = (self.selected_setting + 1) % 3; // 3 settings now
1340    }
1341
1342    /// Select the previous setting in the Settings tab.
1343    pub fn select_previous_setting(&mut self) {
1344        self.selected_setting = self.selected_setting.checked_sub(1).unwrap_or(2);
1345    }
1346
1347    /// Increase CO2 threshold by 100 ppm.
1348    pub fn increase_co2_threshold(&mut self) {
1349        self.co2_alert_threshold = (self.co2_alert_threshold + 100).min(3000);
1350    }
1351
1352    /// Decrease CO2 threshold by 100 ppm.
1353    pub fn decrease_co2_threshold(&mut self) {
1354        self.co2_alert_threshold = self.co2_alert_threshold.saturating_sub(100).max(500);
1355    }
1356
1357    /// Increase radon threshold by 50 Bq/m³.
1358    pub fn increase_radon_threshold(&mut self) {
1359        self.radon_alert_threshold = (self.radon_alert_threshold + 50).min(1000);
1360    }
1361
1362    /// Decrease radon threshold by 50 Bq/m³.
1363    pub fn decrease_radon_threshold(&mut self) {
1364        self.radon_alert_threshold = self.radon_alert_threshold.saturating_sub(50).max(100);
1365    }
1366
1367    /// Cycle to next interval option.
1368    pub fn cycle_interval(&mut self) -> Option<(String, u16)> {
1369        let device = self.selected_device()?;
1370        let reading = device.reading.as_ref()?;
1371        let current_idx = self
1372            .interval_options
1373            .iter()
1374            .position(|&i| i == reading.interval)
1375            .unwrap_or(0);
1376        let next_idx = (current_idx + 1) % self.interval_options.len();
1377        let new_interval = self.interval_options[next_idx];
1378        Some((device.id.clone(), new_interval))
1379    }
1380
1381    /// Handle cached device data loaded from the store on startup.
1382    fn handle_cached_data(&mut self, cached_devices: Vec<CachedDevice>) {
1383        let count = cached_devices.len();
1384        if count > 0 {
1385            self.push_status_message(format!("Loaded {} cached device(s)", count));
1386        }
1387
1388        for cached in cached_devices {
1389            // Check if device already exists (e.g., from live scan)
1390            if let Some(device) = self.devices.iter_mut().find(|d| d.id == cached.id) {
1391                // Update with cached data if we don't have live data
1392                if device.reading.is_none() {
1393                    device.reading = cached.reading;
1394                }
1395                if device.name.is_none() {
1396                    device.name = cached.name;
1397                }
1398                if device.device_type.is_none() {
1399                    device.device_type = cached.device_type;
1400                }
1401                // Always set last_sync from cache if we don't have it
1402                if device.last_sync.is_none() {
1403                    device.last_sync = cached.last_sync;
1404                }
1405            } else {
1406                // Add new device from cache
1407                let mut device = DeviceState::new(cached.id);
1408                device.name = cached.name;
1409                device.device_type = cached.device_type;
1410                device.reading = cached.reading;
1411                device.last_sync = cached.last_sync;
1412                // Mark as disconnected since it's from cache
1413                device.status = ConnectionStatus::Disconnected;
1414                self.devices.push(device);
1415            }
1416        }
1417    }
1418
1419    /// Add a new alert if one doesn't already exist for this device and category.
1420    fn add_alert(
1421        &mut self,
1422        device_id: &str,
1423        category: &str,
1424        message: String,
1425        level: aranet_core::Co2Level,
1426        severity: AlertSeverity,
1427    ) {
1428        if self
1429            .alerts
1430            .iter()
1431            .any(|a| a.device_id == device_id && a.message.contains(category))
1432        {
1433            return;
1434        }
1435
1436        let device_name = self
1437            .devices
1438            .iter()
1439            .find(|d| d.id == device_id)
1440            .and_then(|d| d.name.clone());
1441
1442        self.alerts.push(Alert {
1443            device_id: device_id.to_string(),
1444            device_name: device_name.clone(),
1445            message: message.clone(),
1446            level,
1447            triggered_at: Instant::now(),
1448            severity,
1449        });
1450
1451        self.alert_history.push_back(AlertRecord {
1452            device_name: device_name.unwrap_or_else(|| device_id.to_string()),
1453            message,
1454            timestamp: time::OffsetDateTime::now_utc(),
1455            severity,
1456        });
1457
1458        while self.alert_history.len() > MAX_ALERT_HISTORY {
1459            self.alert_history.pop_front();
1460        }
1461
1462        if self.bell_enabled && !self.do_not_disturb {
1463            print!("\x07");
1464            use std::io::Write;
1465            std::io::stdout().flush().ok();
1466        }
1467    }
1468
1469    /// Clear alerts matching a device and category (unless sticky alerts are on).
1470    fn clear_alert(&mut self, device_id: &str, category: &str) {
1471        if !self.sticky_alerts {
1472            self.alerts
1473                .retain(|a| !(a.device_id == device_id && a.message.contains(category)));
1474        }
1475    }
1476
1477    /// Check if a reading exceeds thresholds and create an alert if needed.
1478    pub fn check_thresholds(&mut self, device_id: &str, reading: &CurrentReading) {
1479        // Check CO2 against custom threshold
1480        if reading.co2 > 0 && reading.co2 >= self.co2_alert_threshold {
1481            let level = self.thresholds.evaluate_co2(reading.co2);
1482            let severity = if reading.co2 >= self.co2_alert_threshold * 2 {
1483                AlertSeverity::Critical
1484            } else if reading.co2 >= (self.co2_alert_threshold * 3) / 2 {
1485                AlertSeverity::Warning
1486            } else {
1487                AlertSeverity::Info
1488            };
1489            let message = format!("CO2 at {} ppm - {}", reading.co2, level.action());
1490            self.add_alert(device_id, "CO2", message, level, severity);
1491        } else if reading.co2 > 0 {
1492            self.clear_alert(device_id, "CO2");
1493        }
1494
1495        // Check battery level
1496        if reading.battery > 0 && reading.battery < 20 {
1497            let (message, severity) = if reading.battery < 10 {
1498                (
1499                    format!("Battery critically low: {}%", reading.battery),
1500                    AlertSeverity::Critical,
1501                )
1502            } else {
1503                (
1504                    format!("Battery low: {}%", reading.battery),
1505                    AlertSeverity::Warning,
1506                )
1507            };
1508            self.add_alert(
1509                device_id,
1510                "Battery",
1511                message,
1512                aranet_core::Co2Level::Good,
1513                severity,
1514            );
1515        } else if reading.battery >= 20 {
1516            self.clear_alert(device_id, "Battery");
1517        }
1518
1519        // Check radon against custom threshold
1520        if let Some(radon) = reading.radon {
1521            if radon >= self.radon_alert_threshold as u32 {
1522                let severity = if radon >= (self.radon_alert_threshold as u32) * 2 {
1523                    AlertSeverity::Critical
1524                } else {
1525                    AlertSeverity::Warning
1526                };
1527                let message = format!("Radon high: {} Bq/m³", radon);
1528                self.add_alert(
1529                    device_id,
1530                    "Radon",
1531                    message,
1532                    aranet_core::Co2Level::Good,
1533                    severity,
1534                );
1535            } else {
1536                self.clear_alert(device_id, "Radon");
1537            }
1538        }
1539    }
1540
1541    /// Dismiss an alert for a device.
1542    pub fn dismiss_alert(&mut self, device_id: &str) {
1543        self.alerts.retain(|a| a.device_id != device_id);
1544    }
1545
1546    /// Toggle alert history view.
1547    pub fn toggle_alert_history(&mut self) {
1548        self.show_alert_history = !self.show_alert_history;
1549    }
1550
1551    /// Toggle sticky alerts mode.
1552    pub fn toggle_sticky_alerts(&mut self) {
1553        self.sticky_alerts = !self.sticky_alerts;
1554        self.push_status_message(format!(
1555            "Sticky alerts {}",
1556            if self.sticky_alerts {
1557                "enabled"
1558            } else {
1559                "disabled"
1560            }
1561        ));
1562    }
1563
1564    /// Toggle data logging on/off.
1565    pub fn toggle_logging(&mut self) {
1566        if self.logging_enabled {
1567            self.logging_enabled = false;
1568            self.push_status_message("Logging disabled".to_string());
1569        } else {
1570            // Create log file path
1571            let timestamp = {
1572                let now = time::OffsetDateTime::now_local()
1573                    .unwrap_or_else(|_| time::OffsetDateTime::now_utc());
1574                now.format(
1575                    &time::format_description::parse("[year][month][day]_[hour][minute][second]")
1576                        .unwrap_or_default(),
1577                )
1578                .unwrap_or_default()
1579            };
1580            let log_dir = dirs::data_local_dir()
1581                .unwrap_or_else(|| std::path::PathBuf::from("."))
1582                .join("aranet")
1583                .join("logs");
1584
1585            // Create directory if needed
1586            if let Err(e) = std::fs::create_dir_all(&log_dir) {
1587                self.push_status_message(format!("Failed to create log dir: {}", e));
1588                return;
1589            }
1590
1591            let log_path = log_dir.join(format!("readings_{}.csv", timestamp));
1592            self.log_file = Some(log_path.clone());
1593            self.logging_enabled = true;
1594            self.push_status_message(format!("Logging to {}", log_path.display()));
1595        }
1596    }
1597
1598    /// Log a reading to file.
1599    pub fn log_reading(&self, device_id: &str, reading: &CurrentReading) {
1600        if !self.logging_enabled {
1601            return;
1602        }
1603
1604        let Some(log_path) = &self.log_file else {
1605            return;
1606        };
1607
1608        use std::io::Write;
1609
1610        let file_exists = log_path.exists();
1611        let file = match std::fs::OpenOptions::new()
1612            .create(true)
1613            .append(true)
1614            .open(log_path)
1615        {
1616            Ok(f) => f,
1617            Err(_) => return,
1618        };
1619
1620        let mut writer = std::io::BufWriter::new(file);
1621
1622        // Write header if new file
1623        if !file_exists {
1624            let _ = writeln!(
1625                writer,
1626                "timestamp,device_id,co2,temperature,humidity,pressure,battery,status,radon,radiation_rate"
1627            );
1628        }
1629
1630        let timestamp = {
1631            let now = time::OffsetDateTime::now_local()
1632                .unwrap_or_else(|_| time::OffsetDateTime::now_utc());
1633            now.format(
1634                &time::format_description::parse("[year]-[month]-[day]T[hour]:[minute]:[second]")
1635                    .unwrap_or_default(),
1636            )
1637            .unwrap_or_default()
1638        };
1639        let radon = reading.radon.map(|r| r.to_string()).unwrap_or_default();
1640        let radiation = reading
1641            .radiation_rate
1642            .map(|r| format!("{:.3}", r))
1643            .unwrap_or_default();
1644
1645        let _ = writeln!(
1646            writer,
1647            "{},{},{},{:.1},{},{:.1},{},{:?},{},{}",
1648            timestamp,
1649            device_id,
1650            reading.co2,
1651            reading.temperature,
1652            reading.humidity,
1653            reading.pressure,
1654            reading.battery,
1655            reading.status,
1656            radon,
1657            radiation
1658        );
1659    }
1660
1661    /// Export visible history to file (CSV or JSON based on export_format).
1662    pub fn export_history(&self) -> Option<String> {
1663        use std::io::Write;
1664
1665        let device = self.selected_device()?;
1666        if device.history.is_empty() {
1667            return None;
1668        }
1669
1670        // Filter history based on current filter
1671        let filtered: Vec<_> = device
1672            .history
1673            .iter()
1674            .filter(|r| self.filter_matches_record(r))
1675            .collect();
1676
1677        if filtered.is_empty() {
1678            return None;
1679        }
1680
1681        // Create export directory
1682        let export_dir = dirs::data_local_dir()
1683            .unwrap_or_else(|| std::path::PathBuf::from("."))
1684            .join("aranet")
1685            .join("exports");
1686        std::fs::create_dir_all(&export_dir).ok()?;
1687
1688        // Generate filename with timestamp
1689        let now =
1690            time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
1691        let filename = format!(
1692            "history_{}_{}.{}",
1693            device
1694                .name
1695                .as_deref()
1696                .unwrap_or(&device.id)
1697                .replace(' ', "_"),
1698            time::format_description::parse("[year][month][day]_[hour][minute][second]")
1699                .ok()
1700                .and_then(|fmt| now.format(&fmt).ok())
1701                .unwrap_or_else(|| "export".to_string()),
1702            self.export_format.extension()
1703        );
1704        let path = export_dir.join(&filename);
1705
1706        let mut file = std::fs::File::create(&path).ok()?;
1707
1708        match self.export_format {
1709            ExportFormat::Csv => {
1710                // Write CSV header
1711                writeln!(
1712                    file,
1713                    "timestamp,co2,temperature,humidity,pressure,radon,radiation_rate"
1714                )
1715                .ok()?;
1716
1717                // Write CSV records
1718                for record in filtered {
1719                    writeln!(
1720                        file,
1721                        "{},{},{:.1},{},{:.1},{},{}",
1722                        record
1723                            .timestamp
1724                            .format(&time::format_description::well_known::Rfc3339)
1725                            .unwrap_or_default(),
1726                        record.co2,
1727                        record.temperature,
1728                        record.humidity,
1729                        record.pressure,
1730                        record.radon.map(|v| v.to_string()).unwrap_or_default(),
1731                        record
1732                            .radiation_rate
1733                            .map(|v| format!("{:.3}", v))
1734                            .unwrap_or_default(),
1735                    )
1736                    .ok()?;
1737                }
1738            }
1739            ExportFormat::Json => {
1740                // Build JSON records
1741                let json_records: Vec<serde_json::Value> = filtered
1742                    .iter()
1743                    .map(|record| {
1744                        let mut obj = serde_json::json!({
1745                            "timestamp": record.timestamp
1746                                .format(&time::format_description::well_known::Rfc3339)
1747                                .unwrap_or_default(),
1748                            "co2": record.co2,
1749                            "temperature": record.temperature,
1750                            "humidity": record.humidity,
1751                            "pressure": record.pressure,
1752                        });
1753                        if let Some(radon) = record.radon {
1754                            obj["radon"] = serde_json::json!(radon);
1755                        }
1756                        if let Some(rate) = record.radiation_rate {
1757                            obj["radiation_rate"] = serde_json::json!(rate);
1758                        }
1759                        if let Some(total) = record.radiation_total {
1760                            obj["radiation_total"] = serde_json::json!(total);
1761                        }
1762                        obj
1763                    })
1764                    .collect();
1765
1766                let json_output = serde_json::json!({
1767                    "device": device.name.as_deref().unwrap_or(&device.id),
1768                    "device_id": device.id,
1769                    "device_type": device.device_type.map(|dt| format!("{:?}", dt)),
1770                    "export_time": now.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(),
1771                    "record_count": json_records.len(),
1772                    "records": json_records,
1773                });
1774
1775                serde_json::to_writer_pretty(&file, &json_output).ok()?;
1776            }
1777        }
1778
1779        Some(path.to_string_lossy().to_string())
1780    }
1781
1782    /// Toggle export format between CSV and JSON.
1783    pub fn toggle_export_format(&mut self) {
1784        self.export_format = self.export_format.toggle();
1785        self.push_status_message(format!(
1786            "Export format: {}",
1787            self.export_format.extension().to_uppercase()
1788        ));
1789    }
1790
1791    /// Toggle Do Not Disturb mode.
1792    pub fn toggle_do_not_disturb(&mut self) {
1793        self.do_not_disturb = !self.do_not_disturb;
1794        let status = if self.do_not_disturb {
1795            "enabled - alerts silenced"
1796        } else {
1797            "disabled"
1798        };
1799        self.push_status_message(format!("Do Not Disturb {}", status));
1800    }
1801
1802    /// Check if a record matches the current history filter.
1803    fn filter_matches_record(&self, record: &HistoryRecord) -> bool {
1804        use time::OffsetDateTime;
1805
1806        match &self.history_filter {
1807            HistoryFilter::All => true,
1808            HistoryFilter::Today => {
1809                let now = OffsetDateTime::now_utc();
1810                record.timestamp.date() == now.date()
1811            }
1812            HistoryFilter::Last24Hours => {
1813                let cutoff = OffsetDateTime::now_utc() - time::Duration::hours(24);
1814                record.timestamp >= cutoff
1815            }
1816            HistoryFilter::Last7Days => {
1817                let cutoff = OffsetDateTime::now_utc() - time::Duration::days(7);
1818                record.timestamp >= cutoff
1819            }
1820            HistoryFilter::Last30Days => {
1821                let cutoff = OffsetDateTime::now_utc() - time::Duration::days(30);
1822                record.timestamp >= cutoff
1823            }
1824            HistoryFilter::Custom { start, end } => {
1825                let record_date = record.timestamp.date();
1826                let after_start = start.is_none_or(|s| record_date >= s);
1827                let before_end = end.is_none_or(|e| record_date <= e);
1828                after_start && before_end
1829            }
1830        }
1831    }
1832
1833    /// Set a custom date range filter.
1834    /// Reserved for future UI support of custom date range selection.
1835    #[allow(dead_code)]
1836    pub fn set_custom_date_filter(&mut self, start: Option<time::Date>, end: Option<time::Date>) {
1837        if let (Some(s), Some(e)) = (start, end)
1838            && e < s
1839        {
1840            self.push_status_message("Warning: end date is before start date".to_string());
1841        }
1842        self.history_filter = HistoryFilter::Custom { start, end };
1843        self.history_scroll = 0;
1844        self.push_status_message("Custom date range set".to_string());
1845    }
1846
1847    /// Check if auto-refresh is due and return list of connected device IDs to refresh.
1848    pub fn check_auto_refresh(&mut self) -> Vec<String> {
1849        let now = Instant::now();
1850
1851        // Determine refresh interval based on first connected device's reading interval
1852        // or use default of 60 seconds
1853        let interval = self
1854            .devices
1855            .iter()
1856            .find(|d| d.status == ConnectionStatus::Connected)
1857            .and_then(|d| d.reading.as_ref())
1858            .map(|r| Duration::from_secs(r.interval as u64))
1859            .unwrap_or(Duration::from_secs(60));
1860
1861        self.auto_refresh_interval = interval;
1862
1863        // Check if enough time has passed since last refresh
1864        let should_refresh = match self.last_auto_refresh {
1865            Some(last) => now.duration_since(last) >= interval,
1866            None => true, // First refresh
1867        };
1868
1869        if should_refresh {
1870            self.last_auto_refresh = Some(now);
1871            // Return IDs of all connected devices
1872            self.devices
1873                .iter()
1874                .filter(|d| d.status == ConnectionStatus::Connected)
1875                .map(|d| d.id.clone())
1876                .collect()
1877        } else {
1878            Vec::new()
1879        }
1880    }
1881
1882    /// Request confirmation for an action.
1883    pub fn request_confirmation(&mut self, action: PendingAction) {
1884        self.pending_confirmation = Some(action);
1885    }
1886
1887    /// Confirm the pending action.
1888    pub fn confirm_action(&mut self) -> Option<Command> {
1889        if let Some(action) = self.pending_confirmation.take() {
1890            match action {
1891                PendingAction::Disconnect { device_id, .. } => {
1892                    return Some(Command::Disconnect { device_id });
1893                }
1894            }
1895        }
1896        None
1897    }
1898
1899    /// Cancel the pending action.
1900    pub fn cancel_confirmation(&mut self) {
1901        self.pending_confirmation = None;
1902        self.push_status_message("Cancelled".to_string());
1903    }
1904
1905    /// Toggle sidebar visibility.
1906    pub fn toggle_sidebar(&mut self) {
1907        self.show_sidebar = !self.show_sidebar;
1908    }
1909
1910    /// Toggle between normal and wide sidebar.
1911    pub fn toggle_sidebar_width(&mut self) {
1912        self.sidebar_width = if self.sidebar_width == 28 { 40 } else { 28 };
1913    }
1914
1915    /// Start editing alias for selected device.
1916    pub fn start_alias_edit(&mut self) {
1917        if let Some(device) = self.selected_device() {
1918            self.alias_input = device
1919                .alias
1920                .clone()
1921                .or_else(|| device.name.clone())
1922                .unwrap_or_default();
1923            self.editing_alias = true;
1924        }
1925    }
1926
1927    /// Cancel alias editing.
1928    pub fn cancel_alias_edit(&mut self) {
1929        self.editing_alias = false;
1930        self.alias_input.clear();
1931    }
1932
1933    /// Save the alias.
1934    pub fn save_alias(&mut self) {
1935        let display_name = if let Some(device) = self.devices.get_mut(self.selected_device) {
1936            if self.alias_input.trim().is_empty() {
1937                device.alias = None;
1938            } else {
1939                device.alias = Some(self.alias_input.trim().to_string());
1940            }
1941            Some(device.display_name().to_string())
1942        } else {
1943            None
1944        };
1945        if let Some(name) = display_name {
1946            self.push_status_message(format!("Alias set: {}", name));
1947        }
1948        self.editing_alias = false;
1949        self.alias_input.clear();
1950    }
1951
1952    /// Handle character input for alias editing.
1953    pub fn alias_input_char(&mut self, c: char) {
1954        if self.alias_input.len() < 20 {
1955            self.alias_input.push(c);
1956        }
1957    }
1958
1959    /// Handle backspace for alias editing.
1960    pub fn alias_input_backspace(&mut self) {
1961        self.alias_input.pop();
1962    }
1963
1964    /// Store an error for later display.
1965    pub fn set_error(&mut self, error: String) {
1966        self.last_error = Some(error);
1967    }
1968
1969    /// Toggle error details popup.
1970    pub fn toggle_error_details(&mut self) {
1971        if self.last_error.is_some() {
1972            self.show_error_details = !self.show_error_details;
1973        } else {
1974            self.push_status_message("No error to display".to_string());
1975        }
1976    }
1977
1978    /// Get average CO2 across all connected devices with readings.
1979    pub fn average_co2(&self) -> Option<u16> {
1980        let values: Vec<u16> = self
1981            .devices
1982            .iter()
1983            .filter(|d| matches!(d.status, ConnectionStatus::Connected))
1984            .filter_map(|d| d.reading.as_ref())
1985            .filter_map(|r| if r.co2 > 0 { Some(r.co2) } else { None })
1986            .collect();
1987
1988        if values.is_empty() {
1989            None
1990        } else {
1991            Some((values.iter().map(|&v| v as u32).sum::<u32>() / values.len() as u32) as u16)
1992        }
1993    }
1994
1995    /// Get count of connected devices.
1996    pub fn connected_count(&self) -> usize {
1997        self.devices
1998            .iter()
1999            .filter(|d| matches!(d.status, ConnectionStatus::Connected))
2000            .count()
2001    }
2002
2003    /// Check if any device is currently connecting.
2004    pub fn is_any_connecting(&self) -> bool {
2005        self.devices
2006            .iter()
2007            .any(|d| matches!(d.status, ConnectionStatus::Connecting))
2008    }
2009
2010    /// Check if a history sync is in progress.
2011    pub fn is_syncing(&self) -> bool {
2012        self.syncing
2013    }
2014
2015    /// Toggle comparison view.
2016    pub fn toggle_comparison(&mut self) {
2017        if self.devices.len() < 2 {
2018            self.push_status_message("Need at least 2 devices for comparison".to_string());
2019            return;
2020        }
2021
2022        self.show_comparison = !self.show_comparison;
2023
2024        if self.show_comparison {
2025            // Pick the next device as comparison target
2026            let next = (self.selected_device + 1) % self.devices.len();
2027            self.comparison_device_index = Some(next);
2028            self.push_status_message(
2029                "Comparison view: use </> to change second device".to_string(),
2030            );
2031        } else {
2032            self.comparison_device_index = None;
2033        }
2034    }
2035
2036    /// Cycle the comparison device.
2037    pub fn cycle_comparison_device(&mut self, forward: bool) {
2038        if !self.show_comparison || self.devices.len() < 2 {
2039            return;
2040        }
2041
2042        let current = self.comparison_device_index.unwrap_or(0);
2043        let mut next = if forward {
2044            (current + 1) % self.devices.len()
2045        } else {
2046            current.checked_sub(1).unwrap_or(self.devices.len() - 1)
2047        };
2048
2049        // Skip the selected device
2050        if next == self.selected_device {
2051            next = if forward {
2052                (next + 1) % self.devices.len()
2053            } else {
2054                next.checked_sub(1).unwrap_or(self.devices.len() - 1)
2055            };
2056        }
2057
2058        self.comparison_device_index = Some(next);
2059    }
2060
2061    /// Get the comparison device.
2062    pub fn comparison_device(&self) -> Option<&DeviceState> {
2063        self.comparison_device_index
2064            .and_then(|i| self.devices.get(i))
2065    }
2066}