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_borrowed::<1>(
1576                        "[year][month][day]_[hour][minute][second]",
1577                    )
1578                    .unwrap_or_default(),
1579                )
1580                .unwrap_or_default()
1581            };
1582            let log_dir = dirs::data_local_dir()
1583                .unwrap_or_else(|| std::path::PathBuf::from("."))
1584                .join("aranet")
1585                .join("logs");
1586
1587            // Create directory if needed
1588            if let Err(e) = std::fs::create_dir_all(&log_dir) {
1589                self.push_status_message(format!("Failed to create log dir: {}", e));
1590                return;
1591            }
1592
1593            let log_path = log_dir.join(format!("readings_{}.csv", timestamp));
1594            self.log_file = Some(log_path.clone());
1595            self.logging_enabled = true;
1596            self.push_status_message(format!("Logging to {}", log_path.display()));
1597        }
1598    }
1599
1600    /// Log a reading to file.
1601    pub fn log_reading(&self, device_id: &str, reading: &CurrentReading) {
1602        if !self.logging_enabled {
1603            return;
1604        }
1605
1606        let Some(log_path) = &self.log_file else {
1607            return;
1608        };
1609
1610        use std::io::Write;
1611
1612        let file_exists = log_path.exists();
1613        let file = match std::fs::OpenOptions::new()
1614            .create(true)
1615            .append(true)
1616            .open(log_path)
1617        {
1618            Ok(f) => f,
1619            Err(_) => return,
1620        };
1621
1622        let mut writer = std::io::BufWriter::new(file);
1623
1624        // Write header if new file
1625        if !file_exists {
1626            let _ = writeln!(
1627                writer,
1628                "timestamp,device_id,co2,temperature,humidity,pressure,battery,status,radon,radiation_rate"
1629            );
1630        }
1631
1632        let timestamp = {
1633            let now = time::OffsetDateTime::now_local()
1634                .unwrap_or_else(|_| time::OffsetDateTime::now_utc());
1635            now.format(
1636                &time::format_description::parse_borrowed::<1>(
1637                    "[year]-[month]-[day]T[hour]:[minute]:[second]",
1638                )
1639                .unwrap_or_default(),
1640            )
1641            .unwrap_or_default()
1642        };
1643        let radon = reading.radon.map(|r| r.to_string()).unwrap_or_default();
1644        let radiation = reading
1645            .radiation_rate
1646            .map(|r| format!("{:.3}", r))
1647            .unwrap_or_default();
1648
1649        let _ = writeln!(
1650            writer,
1651            "{},{},{},{:.1},{},{:.1},{},{:?},{},{}",
1652            timestamp,
1653            device_id,
1654            reading.co2,
1655            reading.temperature,
1656            reading.humidity,
1657            reading.pressure,
1658            reading.battery,
1659            reading.status,
1660            radon,
1661            radiation
1662        );
1663    }
1664
1665    /// Export visible history to file (CSV or JSON based on export_format).
1666    pub fn export_history(&self) -> Option<String> {
1667        use std::io::Write;
1668
1669        let device = self.selected_device()?;
1670        if device.history.is_empty() {
1671            return None;
1672        }
1673
1674        // Filter history based on current filter
1675        let filtered: Vec<_> = device
1676            .history
1677            .iter()
1678            .filter(|r| self.filter_matches_record(r))
1679            .collect();
1680
1681        if filtered.is_empty() {
1682            return None;
1683        }
1684
1685        // Create export directory
1686        let export_dir = dirs::data_local_dir()
1687            .unwrap_or_else(|| std::path::PathBuf::from("."))
1688            .join("aranet")
1689            .join("exports");
1690        std::fs::create_dir_all(&export_dir).ok()?;
1691
1692        // Generate filename with timestamp
1693        let now =
1694            time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
1695        let filename = format!(
1696            "history_{}_{}.{}",
1697            device
1698                .name
1699                .as_deref()
1700                .unwrap_or(&device.id)
1701                .replace(' ', "_"),
1702            time::format_description::parse_borrowed::<1>(
1703                "[year][month][day]_[hour][minute][second]"
1704            )
1705            .ok()
1706            .and_then(|fmt| now.format(&fmt).ok())
1707            .unwrap_or_else(|| "export".to_string()),
1708            self.export_format.extension()
1709        );
1710        let path = export_dir.join(&filename);
1711
1712        let mut file = std::fs::File::create(&path).ok()?;
1713
1714        match self.export_format {
1715            ExportFormat::Csv => {
1716                // Write CSV header
1717                writeln!(
1718                    file,
1719                    "timestamp,co2,temperature,humidity,pressure,radon,radiation_rate"
1720                )
1721                .ok()?;
1722
1723                // Write CSV records
1724                for record in filtered {
1725                    writeln!(
1726                        file,
1727                        "{},{},{:.1},{},{:.1},{},{}",
1728                        record
1729                            .timestamp
1730                            .format(&time::format_description::well_known::Rfc3339)
1731                            .unwrap_or_default(),
1732                        record.co2,
1733                        record.temperature,
1734                        record.humidity,
1735                        record.pressure,
1736                        record.radon.map(|v| v.to_string()).unwrap_or_default(),
1737                        record
1738                            .radiation_rate
1739                            .map(|v| format!("{:.3}", v))
1740                            .unwrap_or_default(),
1741                    )
1742                    .ok()?;
1743                }
1744            }
1745            ExportFormat::Json => {
1746                // Build JSON records
1747                let json_records: Vec<serde_json::Value> = filtered
1748                    .iter()
1749                    .map(|record| {
1750                        let mut obj = serde_json::json!({
1751                            "timestamp": record.timestamp
1752                                .format(&time::format_description::well_known::Rfc3339)
1753                                .unwrap_or_default(),
1754                            "co2": record.co2,
1755                            "temperature": record.temperature,
1756                            "humidity": record.humidity,
1757                            "pressure": record.pressure,
1758                        });
1759                        if let Some(radon) = record.radon {
1760                            obj["radon"] = serde_json::json!(radon);
1761                        }
1762                        if let Some(rate) = record.radiation_rate {
1763                            obj["radiation_rate"] = serde_json::json!(rate);
1764                        }
1765                        if let Some(total) = record.radiation_total {
1766                            obj["radiation_total"] = serde_json::json!(total);
1767                        }
1768                        obj
1769                    })
1770                    .collect();
1771
1772                let json_output = serde_json::json!({
1773                    "device": device.name.as_deref().unwrap_or(&device.id),
1774                    "device_id": device.id,
1775                    "device_type": device.device_type.map(|dt| format!("{:?}", dt)),
1776                    "export_time": now.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(),
1777                    "record_count": json_records.len(),
1778                    "records": json_records,
1779                });
1780
1781                serde_json::to_writer_pretty(&file, &json_output).ok()?;
1782            }
1783        }
1784
1785        Some(path.to_string_lossy().to_string())
1786    }
1787
1788    /// Toggle export format between CSV and JSON.
1789    pub fn toggle_export_format(&mut self) {
1790        self.export_format = self.export_format.toggle();
1791        self.push_status_message(format!(
1792            "Export format: {}",
1793            self.export_format.extension().to_uppercase()
1794        ));
1795    }
1796
1797    /// Toggle Do Not Disturb mode.
1798    pub fn toggle_do_not_disturb(&mut self) {
1799        self.do_not_disturb = !self.do_not_disturb;
1800        let status = if self.do_not_disturb {
1801            "enabled - alerts silenced"
1802        } else {
1803            "disabled"
1804        };
1805        self.push_status_message(format!("Do Not Disturb {}", status));
1806    }
1807
1808    /// Check if a record matches the current history filter.
1809    fn filter_matches_record(&self, record: &HistoryRecord) -> bool {
1810        use time::OffsetDateTime;
1811
1812        match &self.history_filter {
1813            HistoryFilter::All => true,
1814            HistoryFilter::Today => {
1815                let now = OffsetDateTime::now_utc();
1816                record.timestamp.date() == now.date()
1817            }
1818            HistoryFilter::Last24Hours => {
1819                let cutoff = OffsetDateTime::now_utc() - time::Duration::hours(24);
1820                record.timestamp >= cutoff
1821            }
1822            HistoryFilter::Last7Days => {
1823                let cutoff = OffsetDateTime::now_utc() - time::Duration::days(7);
1824                record.timestamp >= cutoff
1825            }
1826            HistoryFilter::Last30Days => {
1827                let cutoff = OffsetDateTime::now_utc() - time::Duration::days(30);
1828                record.timestamp >= cutoff
1829            }
1830            HistoryFilter::Custom { start, end } => {
1831                let record_date = record.timestamp.date();
1832                let after_start = start.is_none_or(|s| record_date >= s);
1833                let before_end = end.is_none_or(|e| record_date <= e);
1834                after_start && before_end
1835            }
1836        }
1837    }
1838
1839    /// Set a custom date range filter.
1840    /// Reserved for future UI support of custom date range selection.
1841    #[allow(dead_code)]
1842    pub fn set_custom_date_filter(&mut self, start: Option<time::Date>, end: Option<time::Date>) {
1843        if let (Some(s), Some(e)) = (start, end)
1844            && e < s
1845        {
1846            self.push_status_message("Warning: end date is before start date".to_string());
1847        }
1848        self.history_filter = HistoryFilter::Custom { start, end };
1849        self.history_scroll = 0;
1850        self.push_status_message("Custom date range set".to_string());
1851    }
1852
1853    /// Check if auto-refresh is due and return list of connected device IDs to refresh.
1854    pub fn check_auto_refresh(&mut self) -> Vec<String> {
1855        let now = Instant::now();
1856
1857        // Determine refresh interval based on first connected device's reading interval
1858        // or use default of 60 seconds
1859        let interval = self
1860            .devices
1861            .iter()
1862            .find(|d| d.status == ConnectionStatus::Connected)
1863            .and_then(|d| d.reading.as_ref())
1864            .map(|r| Duration::from_secs(r.interval as u64))
1865            .unwrap_or(Duration::from_secs(60));
1866
1867        self.auto_refresh_interval = interval;
1868
1869        // Check if enough time has passed since last refresh
1870        let should_refresh = match self.last_auto_refresh {
1871            Some(last) => now.duration_since(last) >= interval,
1872            None => true, // First refresh
1873        };
1874
1875        if should_refresh {
1876            self.last_auto_refresh = Some(now);
1877            // Return IDs of all connected devices
1878            self.devices
1879                .iter()
1880                .filter(|d| d.status == ConnectionStatus::Connected)
1881                .map(|d| d.id.clone())
1882                .collect()
1883        } else {
1884            Vec::new()
1885        }
1886    }
1887
1888    /// Request confirmation for an action.
1889    pub fn request_confirmation(&mut self, action: PendingAction) {
1890        self.pending_confirmation = Some(action);
1891    }
1892
1893    /// Confirm the pending action.
1894    pub fn confirm_action(&mut self) -> Option<Command> {
1895        if let Some(action) = self.pending_confirmation.take() {
1896            match action {
1897                PendingAction::Disconnect { device_id, .. } => {
1898                    return Some(Command::Disconnect { device_id });
1899                }
1900            }
1901        }
1902        None
1903    }
1904
1905    /// Cancel the pending action.
1906    pub fn cancel_confirmation(&mut self) {
1907        self.pending_confirmation = None;
1908        self.push_status_message("Cancelled".to_string());
1909    }
1910
1911    /// Toggle sidebar visibility.
1912    pub fn toggle_sidebar(&mut self) {
1913        self.show_sidebar = !self.show_sidebar;
1914    }
1915
1916    /// Toggle between normal and wide sidebar.
1917    pub fn toggle_sidebar_width(&mut self) {
1918        self.sidebar_width = if self.sidebar_width == 28 { 40 } else { 28 };
1919    }
1920
1921    /// Start editing alias for selected device.
1922    pub fn start_alias_edit(&mut self) {
1923        if let Some(device) = self.selected_device() {
1924            self.alias_input = device
1925                .alias
1926                .clone()
1927                .or_else(|| device.name.clone())
1928                .unwrap_or_default();
1929            self.editing_alias = true;
1930        }
1931    }
1932
1933    /// Cancel alias editing.
1934    pub fn cancel_alias_edit(&mut self) {
1935        self.editing_alias = false;
1936        self.alias_input.clear();
1937    }
1938
1939    /// Save the alias.
1940    pub fn save_alias(&mut self) {
1941        let display_name = if let Some(device) = self.devices.get_mut(self.selected_device) {
1942            if self.alias_input.trim().is_empty() {
1943                device.alias = None;
1944            } else {
1945                device.alias = Some(self.alias_input.trim().to_string());
1946            }
1947            Some(device.display_name().to_string())
1948        } else {
1949            None
1950        };
1951        if let Some(name) = display_name {
1952            self.push_status_message(format!("Alias set: {}", name));
1953        }
1954        self.editing_alias = false;
1955        self.alias_input.clear();
1956    }
1957
1958    /// Handle character input for alias editing.
1959    pub fn alias_input_char(&mut self, c: char) {
1960        if self.alias_input.len() < 20 {
1961            self.alias_input.push(c);
1962        }
1963    }
1964
1965    /// Handle backspace for alias editing.
1966    pub fn alias_input_backspace(&mut self) {
1967        self.alias_input.pop();
1968    }
1969
1970    /// Store an error for later display.
1971    pub fn set_error(&mut self, error: String) {
1972        self.last_error = Some(error);
1973    }
1974
1975    /// Toggle error details popup.
1976    pub fn toggle_error_details(&mut self) {
1977        if self.last_error.is_some() {
1978            self.show_error_details = !self.show_error_details;
1979        } else {
1980            self.push_status_message("No error to display".to_string());
1981        }
1982    }
1983
1984    /// Get average CO2 across all connected devices with readings.
1985    pub fn average_co2(&self) -> Option<u16> {
1986        let values: Vec<u16> = self
1987            .devices
1988            .iter()
1989            .filter(|d| matches!(d.status, ConnectionStatus::Connected))
1990            .filter_map(|d| d.reading.as_ref())
1991            .filter_map(|r| if r.co2 > 0 { Some(r.co2) } else { None })
1992            .collect();
1993
1994        if values.is_empty() {
1995            None
1996        } else {
1997            Some((values.iter().map(|&v| v as u32).sum::<u32>() / values.len() as u32) as u16)
1998        }
1999    }
2000
2001    /// Get count of connected devices.
2002    pub fn connected_count(&self) -> usize {
2003        self.devices
2004            .iter()
2005            .filter(|d| matches!(d.status, ConnectionStatus::Connected))
2006            .count()
2007    }
2008
2009    /// Check if any device is currently connecting.
2010    pub fn is_any_connecting(&self) -> bool {
2011        self.devices
2012            .iter()
2013            .any(|d| matches!(d.status, ConnectionStatus::Connecting))
2014    }
2015
2016    /// Check if a history sync is in progress.
2017    pub fn is_syncing(&self) -> bool {
2018        self.syncing
2019    }
2020
2021    /// Toggle comparison view.
2022    pub fn toggle_comparison(&mut self) {
2023        if self.devices.len() < 2 {
2024            self.push_status_message("Need at least 2 devices for comparison".to_string());
2025            return;
2026        }
2027
2028        self.show_comparison = !self.show_comparison;
2029
2030        if self.show_comparison {
2031            // Pick the next device as comparison target
2032            let next = (self.selected_device + 1) % self.devices.len();
2033            self.comparison_device_index = Some(next);
2034            self.push_status_message(
2035                "Comparison view: use </> to change second device".to_string(),
2036            );
2037        } else {
2038            self.comparison_device_index = None;
2039        }
2040    }
2041
2042    /// Cycle the comparison device.
2043    pub fn cycle_comparison_device(&mut self, forward: bool) {
2044        if !self.show_comparison || self.devices.len() < 2 {
2045            return;
2046        }
2047
2048        let current = self.comparison_device_index.unwrap_or(0);
2049        let mut next = if forward {
2050            (current + 1) % self.devices.len()
2051        } else {
2052            current.checked_sub(1).unwrap_or(self.devices.len() - 1)
2053        };
2054
2055        // Skip the selected device
2056        if next == self.selected_device {
2057            next = if forward {
2058                (next + 1) % self.devices.len()
2059            } else {
2060                next.checked_sub(1).unwrap_or(self.devices.len() - 1)
2061            };
2062        }
2063
2064        self.comparison_device_index = Some(next);
2065    }
2066
2067    /// Get the comparison device.
2068    pub fn comparison_device(&self) -> Option<&DeviceState> {
2069        self.comparison_device_index
2070            .and_then(|i| self.devices.get(i))
2071    }
2072}