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(command_tx: mpsc::Sender<Command>, event_rx: mpsc::Receiver<SensorEvent>) -> Self {
571        Self {
572            should_quit: false,
573            active_tab: Tab::default(),
574            selected_device: 0,
575            devices: Vec::new(),
576            scanning: false,
577            status_messages: Vec::new(),
578            status_message_timeout: 5, // 5 seconds
579            show_help: false,
580            command_tx,
581            event_rx,
582            thresholds: aranet_core::Thresholds::default(),
583            alerts: Vec::new(),
584            alert_history: VecDeque::new(),
585            show_alert_history: false,
586            log_file: None,
587            logging_enabled: false,
588            last_auto_refresh: None,
589            auto_refresh_interval: Duration::from_secs(60),
590            history_scroll: 0,
591            history_filter: HistoryFilter::default(),
592            spinner_frame: 0,
593            selected_setting: 0,
594            interval_options: vec![60, 120, 300, 600], // 1, 2, 5, 10 minutes
595            co2_alert_threshold: 1500,
596            radon_alert_threshold: 300,
597            bell_enabled: true,
598            device_filter: DeviceFilter::default(),
599            pending_confirmation: None,
600            show_sidebar: true,
601            show_fullscreen_chart: false,
602            editing_alias: false,
603            alias_input: String::new(),
604            sticky_alerts: false,
605            last_error: None,
606            show_error_details: false,
607            show_comparison: false,
608            comparison_device_index: None,
609            sidebar_width: 28,
610            theme: Theme::default(),
611            chart_metrics: Self::METRIC_PRIMARY, // Primary metric only by default
612            smart_home_enabled: false,
613            ble_range: BleRange::default(),
614            syncing: false,
615            export_format: ExportFormat::default(),
616            do_not_disturb: false,
617            service_client: aranet_core::service_client::ServiceClient::new(
618                "http://localhost:8080",
619            )
620            .ok(),
621            service_url: "http://localhost:8080".to_string(),
622            service_status: None,
623            service_refreshing: false,
624            service_selected_item: 0,
625        }
626    }
627
628    /// Toggle Bluetooth range.
629    pub fn toggle_ble_range(&mut self) {
630        self.ble_range = self.ble_range.toggle();
631        self.push_status_message(format!("BLE range: {}", self.ble_range.name()));
632    }
633
634    /// Bitmask constant for primary metric (CO2/Radon/Radiation).
635    pub const METRIC_PRIMARY: u8 = 0b001;
636    /// Bitmask constant for temperature metric.
637    pub const METRIC_TEMP: u8 = 0b010;
638    /// Bitmask constant for humidity metric.
639    pub const METRIC_HUMIDITY: u8 = 0b100;
640
641    /// Toggle a metric on the chart.
642    pub fn toggle_chart_metric(&mut self, metric: u8) {
643        self.chart_metrics ^= metric;
644        // Ensure at least one metric is shown
645        if self.chart_metrics == 0 {
646            self.chart_metrics = Self::METRIC_PRIMARY;
647        }
648    }
649
650    /// Check if a metric is enabled on chart.
651    pub fn chart_shows(&self, metric: u8) -> bool {
652        self.chart_metrics & metric != 0
653    }
654
655    /// Toggle between light and dark theme.
656    pub fn toggle_theme(&mut self) {
657        self.theme = match self.theme {
658            Theme::Dark => Theme::Light,
659            Theme::Light => Theme::Dark,
660        };
661    }
662
663    /// Get the current AppTheme based on the theme setting.
664    #[must_use]
665    pub fn app_theme(&self) -> super::ui::theme::AppTheme {
666        match self.theme {
667            Theme::Dark => super::ui::theme::AppTheme::dark(),
668            Theme::Light => super::ui::theme::AppTheme::light(),
669        }
670    }
671
672    /// Toggle Smart Home mode.
673    pub fn toggle_smart_home(&mut self) {
674        self.smart_home_enabled = !self.smart_home_enabled;
675        let status = if self.smart_home_enabled {
676            "enabled"
677        } else {
678            "disabled"
679        };
680        self.push_status_message(format!("Smart Home mode {}", status));
681    }
682
683    /// Toggle full-screen chart view.
684    pub fn toggle_fullscreen_chart(&mut self) {
685        self.show_fullscreen_chart = !self.show_fullscreen_chart;
686    }
687
688    /// Returns whether the application should quit.
689    pub fn should_quit(&self) -> bool {
690        self.should_quit
691    }
692
693    /// Add a status message to the queue.
694    pub fn push_status_message(&mut self, message: String) {
695        self.status_messages.push((message, Instant::now()));
696        // Keep at most 5 messages
697        while self.status_messages.len() > 5 {
698            self.status_messages.remove(0);
699        }
700    }
701
702    /// Remove expired status messages.
703    pub fn clean_expired_messages(&mut self) {
704        let timeout = std::time::Duration::from_secs(self.status_message_timeout);
705        self.status_messages
706            .retain(|(_, created)| created.elapsed() < timeout);
707    }
708
709    /// Get the current status message to display.
710    pub fn current_status_message(&self) -> Option<&str> {
711        self.status_messages.last().map(|(msg, _)| msg.as_str())
712    }
713
714    /// Handle an incoming sensor event and update state accordingly.
715    ///
716    /// Returns a list of commands to send to the worker (for auto-connect, auto-sync, etc.).
717    pub fn handle_sensor_event(&mut self, event: SensorEvent) -> Vec<Command> {
718        let mut commands = Vec::new();
719
720        match event {
721            SensorEvent::CachedDataLoaded { devices } => {
722                // Collect device IDs before handling (for auto-connect)
723                let device_ids: Vec<String> = devices.iter().map(|d| d.id.clone()).collect();
724                self.handle_cached_data(devices);
725
726                // Auto-connect to all cached devices
727                for device_id in device_ids {
728                    commands.push(Command::Connect { device_id });
729                }
730            }
731            SensorEvent::ScanStarted => {
732                self.scanning = true;
733                self.push_status_message("Scanning for devices...".to_string());
734            }
735            SensorEvent::ScanComplete { devices } => {
736                self.scanning = false;
737                self.push_status_message(format!("Found {} device(s)", devices.len()));
738                // Add discovered devices to our list
739                for discovered in devices {
740                    let id_str = discovered.id.to_string();
741                    if !self.devices.iter().any(|d| d.id == id_str) {
742                        let mut device = DeviceState::new(id_str);
743                        device.name = discovered.name;
744                        device.device_type = discovered.device_type;
745                        self.devices.push(device);
746                    }
747                }
748            }
749            SensorEvent::ScanError { error } => {
750                self.scanning = false;
751                let error_msg = format!("Scan: {}", error);
752                self.set_error(error_msg);
753                self.push_status_message(format!(
754                    "Scan error: {} (press E for details)",
755                    error.chars().take(40).collect::<String>()
756                ));
757            }
758            SensorEvent::DeviceConnecting { device_id } => {
759                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
760                    device.status = ConnectionStatus::Connecting;
761                    device.last_updated = Some(Instant::now());
762                }
763                self.push_status_message("Connecting...".to_string());
764            }
765            SensorEvent::DeviceConnected {
766                device_id,
767                name,
768                device_type,
769                rssi,
770            } => {
771                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
772                    device.status = ConnectionStatus::Connected;
773                    device.name = name.or(device.name.take());
774                    device.device_type = device_type.or(device.device_type);
775                    device.rssi = rssi;
776                    device.last_updated = Some(Instant::now());
777                    device.error = None;
778                    device.connected_at = Some(Instant::now());
779                }
780                self.push_status_message("Connected".to_string());
781
782                // Auto-sync history after successful connection
783                commands.push(Command::SyncHistory {
784                    device_id: device_id.clone(),
785                });
786            }
787            SensorEvent::DeviceDisconnected { device_id } => {
788                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
789                    device.status = ConnectionStatus::Disconnected;
790                    device.last_updated = Some(Instant::now());
791                    device.connected_at = None;
792                }
793            }
794            SensorEvent::ConnectionError {
795                device_id, error, ..
796            } => {
797                let device_name = self
798                    .devices
799                    .iter()
800                    .find(|d| d.id == device_id)
801                    .map(|d| d.display_name().to_string())
802                    .unwrap_or_else(|| device_id.clone());
803                let error_msg = format!("{}: {}", device_name, error);
804                self.set_error(error_msg);
805                self.push_status_message(format!(
806                    "Connection error: {} (press E for details)",
807                    error.chars().take(40).collect::<String>()
808                ));
809                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
810                    device.status = ConnectionStatus::Error(error.clone());
811                    device.error = Some(error);
812                    device.last_updated = Some(Instant::now());
813                }
814            }
815            SensorEvent::ReadingUpdated { device_id, reading } => {
816                // Check thresholds for alerts
817                self.check_thresholds(&device_id, &reading);
818
819                // Log reading to file if enabled
820                self.log_reading(&device_id, &reading);
821
822                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
823                    // Update session statistics
824                    device.session_stats.update(&reading);
825                    // Store previous reading for trend calculation
826                    device.previous_reading = device.reading.take();
827                    device.reading = Some(reading);
828                    device.last_updated = Some(Instant::now());
829                    device.error = None;
830                }
831            }
832            SensorEvent::ReadingError {
833                device_id, error, ..
834            } => {
835                let device_name = self
836                    .devices
837                    .iter()
838                    .find(|d| d.id == device_id)
839                    .map(|d| d.display_name().to_string())
840                    .unwrap_or_else(|| device_id.clone());
841                let error_msg = format!("{}: {}", device_name, error);
842                self.set_error(error_msg);
843                self.push_status_message(format!(
844                    "Reading error: {} (press E for details)",
845                    error.chars().take(40).collect::<String>()
846                ));
847                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
848                    device.error = Some(error);
849                    device.last_updated = Some(Instant::now());
850                }
851            }
852            SensorEvent::HistoryLoaded { device_id, records } => {
853                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
854                    device.history = records;
855                    device.last_updated = Some(Instant::now());
856                }
857            }
858            SensorEvent::HistorySyncStarted { device_id, .. } => {
859                self.syncing = true;
860                self.push_status_message(format!("Syncing history for {}...", device_id));
861            }
862            SensorEvent::HistorySynced { device_id, count } => {
863                self.syncing = false;
864                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
865                    device.last_sync = Some(time::OffsetDateTime::now_utc());
866                }
867                self.push_status_message(format!("Synced {} records for {}", count, device_id));
868            }
869            SensorEvent::HistorySyncError {
870                device_id, error, ..
871            } => {
872                self.syncing = false;
873                let device_name = self
874                    .devices
875                    .iter()
876                    .find(|d| d.id == device_id)
877                    .map(|d| d.display_name().to_string())
878                    .unwrap_or_else(|| device_id.clone());
879                let error_msg = format!("{}: {}", device_name, error);
880                self.set_error(error_msg);
881                self.push_status_message(format!(
882                    "History sync failed: {} (press E for details)",
883                    error.chars().take(40).collect::<String>()
884                ));
885                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
886                    device.error = Some(error);
887                }
888            }
889            SensorEvent::IntervalChanged {
890                device_id,
891                interval_secs,
892            } => {
893                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id)
894                    && let Some(reading) = &mut device.reading
895                {
896                    reading.interval = interval_secs;
897                }
898                self.push_status_message(format!("Interval set to {}m", interval_secs / 60));
899            }
900            SensorEvent::IntervalError {
901                device_id,
902                error,
903                context,
904            } => {
905                let device_name = self
906                    .devices
907                    .iter()
908                    .find(|d| d.id == device_id)
909                    .map(|d| d.display_name().to_string())
910                    .unwrap_or_else(|| device_id.clone());
911                // Include suggestion from context if available
912                let error_msg = if let Some(ref ctx) = context {
913                    if let Some(ref suggestion) = ctx.suggestion {
914                        format!("{}: {}. {}", device_name, error, suggestion)
915                    } else {
916                        format!("{}: {}", device_name, error)
917                    }
918                } else {
919                    format!("{}: {}", device_name, error)
920                };
921                self.set_error(error_msg);
922                self.push_status_message(format!(
923                    "Set interval failed: {} (press E for details)",
924                    error.chars().take(40).collect::<String>()
925                ));
926                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
927                    device.error = Some(error);
928                }
929            }
930            SensorEvent::SettingsLoaded {
931                device_id,
932                settings,
933            } => {
934                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
935                    device.settings = Some(settings);
936                    device.last_updated = Some(Instant::now());
937                }
938            }
939            SensorEvent::BluetoothRangeChanged {
940                device_id: _,
941                extended,
942            } => {
943                let range = if extended { "Extended" } else { "Standard" };
944                self.push_status_message(format!("Bluetooth range set to {}", range));
945            }
946            SensorEvent::BluetoothRangeError {
947                device_id,
948                error,
949                context,
950            } => {
951                let device_name = self
952                    .devices
953                    .iter()
954                    .find(|d| d.id == device_id)
955                    .and_then(|d| d.name.clone())
956                    .unwrap_or_else(|| device_id.clone());
957                // Include suggestion from context if available
958                let error_msg = if let Some(ref ctx) = context {
959                    if let Some(ref suggestion) = ctx.suggestion {
960                        format!("{}: {}. {}", device_name, error, suggestion)
961                    } else {
962                        format!("{}: {}", device_name, error)
963                    }
964                } else {
965                    format!("{}: {}", device_name, error)
966                };
967                self.set_error(error_msg);
968                self.push_status_message(format!(
969                    "Set BT range failed: {} (press E for details)",
970                    error.chars().take(40).collect::<String>()
971                ));
972            }
973            SensorEvent::SmartHomeChanged {
974                device_id: _,
975                enabled,
976            } => {
977                let mode = if enabled { "enabled" } else { "disabled" };
978                self.push_status_message(format!("Smart Home {}", mode));
979            }
980            SensorEvent::SmartHomeError {
981                device_id,
982                error,
983                context,
984            } => {
985                let device_name = self
986                    .devices
987                    .iter()
988                    .find(|d| d.id == device_id)
989                    .and_then(|d| d.name.clone())
990                    .unwrap_or_else(|| device_id.clone());
991                // Include suggestion from context if available
992                let error_msg = if let Some(ref ctx) = context {
993                    if let Some(ref suggestion) = ctx.suggestion {
994                        format!("{}: {}. {}", device_name, error, suggestion)
995                    } else {
996                        format!("{}: {}", device_name, error)
997                    }
998                } else {
999                    format!("{}: {}", device_name, error)
1000                };
1001                self.set_error(error_msg);
1002                self.push_status_message(format!(
1003                    "Set Smart Home failed: {} (press E for details)",
1004                    error.chars().take(40).collect::<String>()
1005                ));
1006            }
1007            SensorEvent::ServiceStatusRefreshed {
1008                reachable,
1009                collector_running,
1010                uptime_seconds,
1011                devices,
1012            } => {
1013                self.service_refreshing = false;
1014                self.service_status = Some(ServiceState {
1015                    reachable,
1016                    collector_running,
1017                    started_at: None, // We could compute from uptime if needed
1018                    uptime_seconds,
1019                    devices: devices
1020                        .into_iter()
1021                        .map(|d| aranet_core::service_client::DeviceCollectionStats {
1022                            device_id: d.device_id,
1023                            alias: d.alias,
1024                            poll_interval: d.poll_interval,
1025                            polling: d.polling,
1026                            success_count: d.success_count,
1027                            failure_count: d.failure_count,
1028                            last_poll_at: d.last_poll_at,
1029                            last_error_at: None, // Not tracked in messages, derived from last_error
1030                            last_error: d.last_error,
1031                        })
1032                        .collect(),
1033                    fetched_at: Instant::now(),
1034                });
1035                if reachable {
1036                    let status = if collector_running {
1037                        "running"
1038                    } else {
1039                        "stopped"
1040                    };
1041                    self.push_status_message(format!("Service collector: {}", status));
1042                } else {
1043                    self.push_status_message("Service not reachable".to_string());
1044                }
1045            }
1046            SensorEvent::ServiceStatusError { error } => {
1047                self.service_refreshing = false;
1048                self.push_status_message(format!("Service error: {}", error));
1049            }
1050            SensorEvent::ServiceCollectorStarted => {
1051                self.push_status_message("Collector started".to_string());
1052            }
1053            SensorEvent::ServiceCollectorStopped => {
1054                self.push_status_message("Collector stopped".to_string());
1055            }
1056            SensorEvent::ServiceCollectorError { error } => {
1057                self.push_status_message(format!("Collector error: {}", error));
1058            }
1059            SensorEvent::AliasChanged { device_id, alias } => {
1060                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
1061                    device.name = alias;
1062                }
1063                self.push_status_message("Device renamed".to_string());
1064            }
1065            SensorEvent::AliasError {
1066                device_id: _,
1067                error,
1068            } => {
1069                self.push_status_message(format!("Rename failed: {}", error));
1070            }
1071            SensorEvent::DeviceForgotten { device_id } => {
1072                if let Some(pos) = self.devices.iter().position(|d| d.id == device_id) {
1073                    self.devices.remove(pos);
1074                    if self.selected_device >= self.devices.len() && !self.devices.is_empty() {
1075                        self.selected_device = self.devices.len() - 1;
1076                    }
1077                }
1078                self.push_status_message("Device forgotten".to_string());
1079            }
1080            SensorEvent::ForgetDeviceError {
1081                device_id: _,
1082                error,
1083            } => {
1084                self.push_status_message(format!("Forget failed: {}", error));
1085            }
1086            SensorEvent::HistorySyncProgress {
1087                device_id: _,
1088                downloaded,
1089                total,
1090            } => {
1091                self.push_status_message(format!(
1092                    "Syncing history: {}/{} records",
1093                    downloaded, total
1094                ));
1095            }
1096            SensorEvent::OperationCancelled { operation } => {
1097                self.push_status_message(format!("{} cancelled", operation));
1098            }
1099            SensorEvent::BackgroundPollingStarted {
1100                device_id: _,
1101                interval_secs,
1102            } => {
1103                self.push_status_message(format!(
1104                    "Background polling started ({}s interval)",
1105                    interval_secs
1106                ));
1107            }
1108            SensorEvent::BackgroundPollingStopped { device_id: _ } => {
1109                self.push_status_message("Background polling stopped".to_string());
1110            }
1111            SensorEvent::SignalStrengthUpdate {
1112                device_id,
1113                rssi,
1114                quality: _,
1115            } => {
1116                if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
1117                    device.rssi = Some(rssi);
1118                }
1119            }
1120            // System service events - not displayed in TUI
1121            SensorEvent::SystemServiceStatus { .. }
1122            | SensorEvent::SystemServiceInstalled
1123            | SensorEvent::SystemServiceUninstalled
1124            | SensorEvent::SystemServiceStarted
1125            | SensorEvent::SystemServiceStopped
1126            | SensorEvent::SystemServiceError { .. }
1127            | SensorEvent::ServiceConfigFetched { .. }
1128            | SensorEvent::ServiceConfigError { .. }
1129            | SensorEvent::ServiceDeviceAdded { .. }
1130            | SensorEvent::ServiceDeviceUpdated { .. }
1131            | SensorEvent::ServiceDeviceRemoved { .. }
1132            | SensorEvent::ServiceDeviceError { .. } => {
1133                // TUI doesn't display system service status
1134            }
1135        }
1136
1137        commands
1138    }
1139
1140    /// Get a reference to the currently selected device, if any.
1141    pub fn selected_device(&self) -> Option<&DeviceState> {
1142        self.devices.get(self.selected_device)
1143    }
1144
1145    /// Select the next device in the list.
1146    pub fn select_next_device(&mut self) {
1147        if !self.devices.is_empty() {
1148            self.selected_device = (self.selected_device + 1) % self.devices.len();
1149            self.reset_history_scroll();
1150        }
1151    }
1152
1153    /// Select the previous device in the list.
1154    pub fn select_previous_device(&mut self) {
1155        if !self.devices.is_empty() {
1156            self.selected_device = self
1157                .selected_device
1158                .checked_sub(1)
1159                .unwrap_or(self.devices.len() - 1);
1160            self.reset_history_scroll();
1161        }
1162    }
1163
1164    /// Scroll history list up by one page.
1165    pub fn scroll_history_up(&mut self) {
1166        self.history_scroll = self.history_scroll.saturating_sub(5);
1167    }
1168
1169    /// Scroll history list down by one page.
1170    pub fn scroll_history_down(&mut self) {
1171        if let Some(device) = self.selected_device() {
1172            let max_scroll = device.history.len().saturating_sub(10);
1173            self.history_scroll = (self.history_scroll + 5).min(max_scroll);
1174        }
1175    }
1176
1177    /// Reset history scroll when device changes.
1178    pub fn reset_history_scroll(&mut self) {
1179        self.history_scroll = 0;
1180    }
1181
1182    /// Advance the spinner animation frame.
1183    pub fn tick_spinner(&mut self) {
1184        self.spinner_frame = (self.spinner_frame + 1) % 10;
1185    }
1186
1187    /// Get the current spinner character.
1188    pub fn spinner_char(&self) -> &'static str {
1189        const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1190        SPINNER[self.spinner_frame]
1191    }
1192
1193    /// Set history filter.
1194    pub fn set_history_filter(&mut self, filter: HistoryFilter) {
1195        self.history_filter = filter;
1196        self.history_scroll = 0; // Reset scroll when filter changes
1197    }
1198
1199    /// Get devices matching current filter.
1200    pub fn filtered_devices(&self) -> Vec<&DeviceState> {
1201        self.devices
1202            .iter()
1203            .filter(|d| match self.device_filter {
1204                DeviceFilter::All => true,
1205                DeviceFilter::Aranet4Only => {
1206                    matches!(d.device_type, Some(DeviceType::Aranet4))
1207                }
1208                DeviceFilter::RadonOnly => {
1209                    matches!(d.device_type, Some(DeviceType::AranetRadon))
1210                }
1211                DeviceFilter::RadiationOnly => {
1212                    matches!(d.device_type, Some(DeviceType::AranetRadiation))
1213                }
1214                DeviceFilter::ConnectedOnly => {
1215                    matches!(d.status, ConnectionStatus::Connected)
1216                }
1217            })
1218            .collect()
1219    }
1220
1221    /// Cycle device filter to next option.
1222    pub fn cycle_device_filter(&mut self) {
1223        self.device_filter = self.device_filter.next();
1224        self.push_status_message(format!("Filter: {}", self.device_filter.label()));
1225    }
1226
1227    /// Select the next setting in the Settings tab.
1228    pub fn select_next_setting(&mut self) {
1229        self.selected_setting = (self.selected_setting + 1) % 3; // 3 settings now
1230    }
1231
1232    /// Select the previous setting in the Settings tab.
1233    pub fn select_previous_setting(&mut self) {
1234        self.selected_setting = self.selected_setting.checked_sub(1).unwrap_or(2);
1235    }
1236
1237    /// Increase CO2 threshold by 100 ppm.
1238    pub fn increase_co2_threshold(&mut self) {
1239        self.co2_alert_threshold = (self.co2_alert_threshold + 100).min(3000);
1240    }
1241
1242    /// Decrease CO2 threshold by 100 ppm.
1243    pub fn decrease_co2_threshold(&mut self) {
1244        self.co2_alert_threshold = self.co2_alert_threshold.saturating_sub(100).max(500);
1245    }
1246
1247    /// Increase radon threshold by 50 Bq/m³.
1248    pub fn increase_radon_threshold(&mut self) {
1249        self.radon_alert_threshold = (self.radon_alert_threshold + 50).min(1000);
1250    }
1251
1252    /// Decrease radon threshold by 50 Bq/m³.
1253    pub fn decrease_radon_threshold(&mut self) {
1254        self.radon_alert_threshold = self.radon_alert_threshold.saturating_sub(50).max(100);
1255    }
1256
1257    /// Cycle to next interval option.
1258    pub fn cycle_interval(&mut self) -> Option<(String, u16)> {
1259        let device = self.selected_device()?;
1260        let reading = device.reading.as_ref()?;
1261        let current_idx = self
1262            .interval_options
1263            .iter()
1264            .position(|&i| i == reading.interval)
1265            .unwrap_or(0);
1266        let next_idx = (current_idx + 1) % self.interval_options.len();
1267        let new_interval = self.interval_options[next_idx];
1268        Some((device.id.clone(), new_interval))
1269    }
1270
1271    /// Handle cached device data loaded from the store on startup.
1272    fn handle_cached_data(&mut self, cached_devices: Vec<CachedDevice>) {
1273        let count = cached_devices.len();
1274        if count > 0 {
1275            self.push_status_message(format!("Loaded {} cached device(s)", count));
1276        }
1277
1278        for cached in cached_devices {
1279            // Check if device already exists (e.g., from live scan)
1280            if let Some(device) = self.devices.iter_mut().find(|d| d.id == cached.id) {
1281                // Update with cached data if we don't have live data
1282                if device.reading.is_none() {
1283                    device.reading = cached.reading;
1284                }
1285                if device.name.is_none() {
1286                    device.name = cached.name;
1287                }
1288                if device.device_type.is_none() {
1289                    device.device_type = cached.device_type;
1290                }
1291                // Always set last_sync from cache if we don't have it
1292                if device.last_sync.is_none() {
1293                    device.last_sync = cached.last_sync;
1294                }
1295            } else {
1296                // Add new device from cache
1297                let mut device = DeviceState::new(cached.id);
1298                device.name = cached.name;
1299                device.device_type = cached.device_type;
1300                device.reading = cached.reading;
1301                device.last_sync = cached.last_sync;
1302                // Mark as disconnected since it's from cache
1303                device.status = ConnectionStatus::Disconnected;
1304                self.devices.push(device);
1305            }
1306        }
1307    }
1308
1309    /// Check if a reading exceeds thresholds and create an alert if needed.
1310    pub fn check_thresholds(&mut self, device_id: &str, reading: &CurrentReading) {
1311        // Check CO2 against custom threshold
1312        if reading.co2 > 0 && reading.co2 >= self.co2_alert_threshold {
1313            let level = self.thresholds.evaluate_co2(reading.co2);
1314
1315            // Determine severity based on how far above threshold
1316            let severity = if reading.co2 >= self.co2_alert_threshold * 2 {
1317                AlertSeverity::Critical
1318            } else if reading.co2 >= (self.co2_alert_threshold * 3) / 2 {
1319                AlertSeverity::Warning
1320            } else {
1321                AlertSeverity::Info
1322            };
1323
1324            // Check if we already have a CO2 alert for this device
1325            if !self
1326                .alerts
1327                .iter()
1328                .any(|a| a.device_id == device_id && a.message.contains("CO2"))
1329            {
1330                let device_name = self
1331                    .devices
1332                    .iter()
1333                    .find(|d| d.id == device_id)
1334                    .and_then(|d| d.name.clone());
1335
1336                let message = format!("CO2 at {} ppm - {}", reading.co2, level.action());
1337
1338                self.alerts.push(Alert {
1339                    device_id: device_id.to_string(),
1340                    device_name: device_name.clone(),
1341                    message: message.clone(),
1342                    level,
1343                    triggered_at: Instant::now(),
1344                    severity,
1345                });
1346
1347                // Add to alert history (newest at back), O(1)
1348                self.alert_history.push_back(AlertRecord {
1349                    device_name: device_name.unwrap_or_else(|| device_id.to_string()),
1350                    message,
1351                    timestamp: time::OffsetDateTime::now_utc(),
1352                    severity,
1353                });
1354
1355                // Keep history limited to last MAX_ALERT_HISTORY entries
1356                while self.alert_history.len() > MAX_ALERT_HISTORY {
1357                    self.alert_history.pop_front(); // Remove oldest (front), O(1)
1358                }
1359
1360                // Ring terminal bell if enabled and not in Do Not Disturb mode
1361                if self.bell_enabled && !self.do_not_disturb {
1362                    print!("\x07"); // ASCII BEL character
1363                    use std::io::Write;
1364                    std::io::stdout().flush().ok();
1365                }
1366            }
1367        } else if reading.co2 > 0 && !self.sticky_alerts {
1368            // Clear CO2 alert if level improved below threshold (unless sticky)
1369            self.alerts
1370                .retain(|a| !(a.device_id == device_id && a.message.contains("CO2")));
1371        }
1372
1373        // Check battery level
1374        if reading.battery > 0 && reading.battery < 20 {
1375            // Check if we already have a battery alert for this device
1376            let has_battery_alert = self
1377                .alerts
1378                .iter()
1379                .any(|a| a.device_id == device_id && a.message.contains("Battery"));
1380
1381            if !has_battery_alert {
1382                let device_name = self
1383                    .devices
1384                    .iter()
1385                    .find(|d| d.id == device_id)
1386                    .and_then(|d| d.name.clone());
1387
1388                // Determine severity: < 10% is Critical, 10-20% is Warning
1389                let (message, severity) = if reading.battery < 10 {
1390                    (
1391                        format!("Battery critically low: {}%", reading.battery),
1392                        AlertSeverity::Critical,
1393                    )
1394                } else {
1395                    (
1396                        format!("Battery low: {}%", reading.battery),
1397                        AlertSeverity::Warning,
1398                    )
1399                };
1400
1401                self.alerts.push(Alert {
1402                    device_id: device_id.to_string(),
1403                    device_name: device_name.clone(),
1404                    message: message.clone(),
1405                    level: aranet_core::Co2Level::Good, // Not applicable, just a placeholder
1406                    triggered_at: Instant::now(),
1407                    severity,
1408                });
1409
1410                // Add to alert history (newest at back), O(1)
1411                self.alert_history.push_back(AlertRecord {
1412                    device_name: device_name.unwrap_or_else(|| device_id.to_string()),
1413                    message,
1414                    timestamp: time::OffsetDateTime::now_utc(),
1415                    severity,
1416                });
1417
1418                // Keep history limited to last MAX_ALERT_HISTORY entries
1419                while self.alert_history.len() > MAX_ALERT_HISTORY {
1420                    self.alert_history.pop_front(); // Remove oldest (front), O(1)
1421                }
1422
1423                // Ring terminal bell if enabled and not in Do Not Disturb mode
1424                if self.bell_enabled && !self.do_not_disturb {
1425                    print!("\x07"); // ASCII BEL character
1426                    use std::io::Write;
1427                    std::io::stdout().flush().ok();
1428                }
1429            }
1430        } else if reading.battery >= 20 && !self.sticky_alerts {
1431            // Clear battery alert if battery improved (unless sticky)
1432            self.alerts
1433                .retain(|a| !(a.device_id == device_id && a.message.contains("Battery")));
1434        }
1435
1436        // Check radon against custom threshold
1437        if let Some(radon) = reading.radon {
1438            if radon >= self.radon_alert_threshold as u32 {
1439                // Check if we already have a radon alert for this device
1440                let has_radon_alert = self
1441                    .alerts
1442                    .iter()
1443                    .any(|a| a.device_id == device_id && a.message.contains("Radon"));
1444
1445                if !has_radon_alert {
1446                    let device_name = self
1447                        .devices
1448                        .iter()
1449                        .find(|d| d.id == device_id)
1450                        .and_then(|d| d.name.clone());
1451
1452                    // Determine severity: 2x threshold is Critical, at threshold is Warning
1453                    let severity = if radon >= (self.radon_alert_threshold as u32) * 2 {
1454                        AlertSeverity::Critical
1455                    } else {
1456                        AlertSeverity::Warning
1457                    };
1458
1459                    let message = format!("Radon high: {} Bq/m³", radon);
1460
1461                    self.alerts.push(Alert {
1462                        device_id: device_id.to_string(),
1463                        device_name: device_name.clone(),
1464                        message: message.clone(),
1465                        level: aranet_core::Co2Level::Good, // Not applicable, just a placeholder
1466                        triggered_at: Instant::now(),
1467                        severity,
1468                    });
1469
1470                    // Add to alert history (newest at back), O(1)
1471                    self.alert_history.push_back(AlertRecord {
1472                        device_name: device_name.unwrap_or_else(|| device_id.to_string()),
1473                        message,
1474                        timestamp: time::OffsetDateTime::now_utc(),
1475                        severity,
1476                    });
1477
1478                    // Keep history limited to last MAX_ALERT_HISTORY entries
1479                    while self.alert_history.len() > MAX_ALERT_HISTORY {
1480                        self.alert_history.pop_front(); // Remove oldest (front), O(1)
1481                    }
1482
1483                    // Ring terminal bell if enabled and not in Do Not Disturb mode
1484                    if self.bell_enabled && !self.do_not_disturb {
1485                        print!("\x07"); // ASCII BEL character
1486                        use std::io::Write;
1487                        std::io::stdout().flush().ok();
1488                    }
1489                }
1490            } else if !self.sticky_alerts {
1491                // Clear radon alert if level improved (unless sticky)
1492                self.alerts
1493                    .retain(|a| !(a.device_id == device_id && a.message.contains("Radon")));
1494            }
1495        }
1496    }
1497
1498    /// Dismiss an alert for a device.
1499    pub fn dismiss_alert(&mut self, device_id: &str) {
1500        self.alerts.retain(|a| a.device_id != device_id);
1501    }
1502
1503    /// Toggle alert history view.
1504    pub fn toggle_alert_history(&mut self) {
1505        self.show_alert_history = !self.show_alert_history;
1506    }
1507
1508    /// Toggle sticky alerts mode.
1509    pub fn toggle_sticky_alerts(&mut self) {
1510        self.sticky_alerts = !self.sticky_alerts;
1511        self.push_status_message(format!(
1512            "Sticky alerts {}",
1513            if self.sticky_alerts {
1514                "enabled"
1515            } else {
1516                "disabled"
1517            }
1518        ));
1519    }
1520
1521    /// Toggle data logging on/off.
1522    pub fn toggle_logging(&mut self) {
1523        if self.logging_enabled {
1524            self.logging_enabled = false;
1525            self.push_status_message("Logging disabled".to_string());
1526        } else {
1527            // Create log file path
1528            let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
1529            let log_dir = dirs::data_local_dir()
1530                .unwrap_or_else(|| std::path::PathBuf::from("."))
1531                .join("aranet")
1532                .join("logs");
1533
1534            // Create directory if needed
1535            if let Err(e) = std::fs::create_dir_all(&log_dir) {
1536                self.push_status_message(format!("Failed to create log dir: {}", e));
1537                return;
1538            }
1539
1540            let log_path = log_dir.join(format!("readings_{}.csv", timestamp));
1541            self.log_file = Some(log_path.clone());
1542            self.logging_enabled = true;
1543            self.push_status_message(format!("Logging to {}", log_path.display()));
1544        }
1545    }
1546
1547    /// Log a reading to file.
1548    pub fn log_reading(&self, device_id: &str, reading: &CurrentReading) {
1549        if !self.logging_enabled {
1550            return;
1551        }
1552
1553        let Some(log_path) = &self.log_file else {
1554            return;
1555        };
1556
1557        use std::io::Write;
1558
1559        let file_exists = log_path.exists();
1560        let file = match std::fs::OpenOptions::new()
1561            .create(true)
1562            .append(true)
1563            .open(log_path)
1564        {
1565            Ok(f) => f,
1566            Err(_) => return,
1567        };
1568
1569        let mut writer = std::io::BufWriter::new(file);
1570
1571        // Write header if new file
1572        if !file_exists {
1573            let _ = writeln!(
1574                writer,
1575                "timestamp,device_id,co2,temperature,humidity,pressure,battery,status,radon,radiation_rate"
1576            );
1577        }
1578
1579        let timestamp = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S");
1580        let radon = reading.radon.map(|r| r.to_string()).unwrap_or_default();
1581        let radiation = reading
1582            .radiation_rate
1583            .map(|r| format!("{:.3}", r))
1584            .unwrap_or_default();
1585
1586        let _ = writeln!(
1587            writer,
1588            "{},{},{},{:.1},{},{:.1},{},{:?},{},{}",
1589            timestamp,
1590            device_id,
1591            reading.co2,
1592            reading.temperature,
1593            reading.humidity,
1594            reading.pressure,
1595            reading.battery,
1596            reading.status,
1597            radon,
1598            radiation
1599        );
1600    }
1601
1602    /// Export visible history to file (CSV or JSON based on export_format).
1603    pub fn export_history(&self) -> Option<String> {
1604        use std::io::Write;
1605
1606        let device = self.selected_device()?;
1607        if device.history.is_empty() {
1608            return None;
1609        }
1610
1611        // Filter history based on current filter
1612        let filtered: Vec<_> = device
1613            .history
1614            .iter()
1615            .filter(|r| self.filter_matches_record(r))
1616            .collect();
1617
1618        if filtered.is_empty() {
1619            return None;
1620        }
1621
1622        // Create export directory
1623        let export_dir = dirs::data_local_dir()
1624            .unwrap_or_else(|| std::path::PathBuf::from("."))
1625            .join("aranet")
1626            .join("exports");
1627        std::fs::create_dir_all(&export_dir).ok()?;
1628
1629        // Generate filename with timestamp
1630        let now =
1631            time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
1632        let filename = format!(
1633            "history_{}_{}.{}",
1634            device
1635                .name
1636                .as_deref()
1637                .unwrap_or(&device.id)
1638                .replace(' ', "_"),
1639            now.format(
1640                &time::format_description::parse("[year][month][day]_[hour][minute][second]")
1641                    .unwrap()
1642            )
1643            .unwrap_or_default(),
1644            self.export_format.extension()
1645        );
1646        let path = export_dir.join(&filename);
1647
1648        let mut file = std::fs::File::create(&path).ok()?;
1649
1650        match self.export_format {
1651            ExportFormat::Csv => {
1652                // Write CSV header
1653                writeln!(
1654                    file,
1655                    "timestamp,co2,temperature,humidity,pressure,radon,radiation_rate"
1656                )
1657                .ok()?;
1658
1659                // Write CSV records
1660                for record in filtered {
1661                    writeln!(
1662                        file,
1663                        "{},{},{:.1},{},{:.1},{},{}",
1664                        record
1665                            .timestamp
1666                            .format(&time::format_description::well_known::Rfc3339)
1667                            .unwrap_or_default(),
1668                        record.co2,
1669                        record.temperature,
1670                        record.humidity,
1671                        record.pressure,
1672                        record.radon.map(|v| v.to_string()).unwrap_or_default(),
1673                        record
1674                            .radiation_rate
1675                            .map(|v| format!("{:.3}", v))
1676                            .unwrap_or_default(),
1677                    )
1678                    .ok()?;
1679                }
1680            }
1681            ExportFormat::Json => {
1682                // Build JSON records
1683                let json_records: Vec<serde_json::Value> = filtered
1684                    .iter()
1685                    .map(|record| {
1686                        let mut obj = serde_json::json!({
1687                            "timestamp": record.timestamp
1688                                .format(&time::format_description::well_known::Rfc3339)
1689                                .unwrap_or_default(),
1690                            "co2": record.co2,
1691                            "temperature": record.temperature,
1692                            "humidity": record.humidity,
1693                            "pressure": record.pressure,
1694                        });
1695                        if let Some(radon) = record.radon {
1696                            obj["radon"] = serde_json::json!(radon);
1697                        }
1698                        if let Some(rate) = record.radiation_rate {
1699                            obj["radiation_rate"] = serde_json::json!(rate);
1700                        }
1701                        if let Some(total) = record.radiation_total {
1702                            obj["radiation_total"] = serde_json::json!(total);
1703                        }
1704                        obj
1705                    })
1706                    .collect();
1707
1708                let json_output = serde_json::json!({
1709                    "device": device.name.as_deref().unwrap_or(&device.id),
1710                    "device_id": device.id,
1711                    "device_type": device.device_type.map(|dt| format!("{:?}", dt)),
1712                    "export_time": now.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(),
1713                    "record_count": json_records.len(),
1714                    "records": json_records,
1715                });
1716
1717                serde_json::to_writer_pretty(&file, &json_output).ok()?;
1718            }
1719        }
1720
1721        Some(path.to_string_lossy().to_string())
1722    }
1723
1724    /// Toggle export format between CSV and JSON.
1725    pub fn toggle_export_format(&mut self) {
1726        self.export_format = self.export_format.toggle();
1727        self.push_status_message(format!(
1728            "Export format: {}",
1729            self.export_format.extension().to_uppercase()
1730        ));
1731    }
1732
1733    /// Toggle Do Not Disturb mode.
1734    pub fn toggle_do_not_disturb(&mut self) {
1735        self.do_not_disturb = !self.do_not_disturb;
1736        let status = if self.do_not_disturb {
1737            "enabled - alerts silenced"
1738        } else {
1739            "disabled"
1740        };
1741        self.push_status_message(format!("Do Not Disturb {}", status));
1742    }
1743
1744    /// Check if a record matches the current history filter.
1745    fn filter_matches_record(&self, record: &HistoryRecord) -> bool {
1746        use time::OffsetDateTime;
1747
1748        match &self.history_filter {
1749            HistoryFilter::All => true,
1750            HistoryFilter::Today => {
1751                let now = OffsetDateTime::now_utc();
1752                record.timestamp.date() == now.date()
1753            }
1754            HistoryFilter::Last24Hours => {
1755                let cutoff = OffsetDateTime::now_utc() - time::Duration::hours(24);
1756                record.timestamp >= cutoff
1757            }
1758            HistoryFilter::Last7Days => {
1759                let cutoff = OffsetDateTime::now_utc() - time::Duration::days(7);
1760                record.timestamp >= cutoff
1761            }
1762            HistoryFilter::Last30Days => {
1763                let cutoff = OffsetDateTime::now_utc() - time::Duration::days(30);
1764                record.timestamp >= cutoff
1765            }
1766            HistoryFilter::Custom { start, end } => {
1767                let record_date = record.timestamp.date();
1768                let after_start = start.is_none_or(|s| record_date >= s);
1769                let before_end = end.is_none_or(|e| record_date <= e);
1770                after_start && before_end
1771            }
1772        }
1773    }
1774
1775    /// Set a custom date range filter.
1776    /// Reserved for future UI support of custom date range selection.
1777    #[allow(dead_code)]
1778    pub fn set_custom_date_filter(&mut self, start: Option<time::Date>, end: Option<time::Date>) {
1779        self.history_filter = HistoryFilter::Custom { start, end };
1780        self.history_scroll = 0;
1781        self.push_status_message("Custom date range set".to_string());
1782    }
1783
1784    /// Check if auto-refresh is due and return list of connected device IDs to refresh.
1785    pub fn check_auto_refresh(&mut self) -> Vec<String> {
1786        let now = Instant::now();
1787
1788        // Determine refresh interval based on first connected device's reading interval
1789        // or use default of 60 seconds
1790        let interval = self
1791            .devices
1792            .iter()
1793            .find(|d| d.status == ConnectionStatus::Connected)
1794            .and_then(|d| d.reading.as_ref())
1795            .map(|r| Duration::from_secs(r.interval as u64))
1796            .unwrap_or(Duration::from_secs(60));
1797
1798        self.auto_refresh_interval = interval;
1799
1800        // Check if enough time has passed since last refresh
1801        let should_refresh = match self.last_auto_refresh {
1802            Some(last) => now.duration_since(last) >= interval,
1803            None => true, // First refresh
1804        };
1805
1806        if should_refresh {
1807            self.last_auto_refresh = Some(now);
1808            // Return IDs of all connected devices
1809            self.devices
1810                .iter()
1811                .filter(|d| d.status == ConnectionStatus::Connected)
1812                .map(|d| d.id.clone())
1813                .collect()
1814        } else {
1815            Vec::new()
1816        }
1817    }
1818
1819    /// Request confirmation for an action.
1820    pub fn request_confirmation(&mut self, action: PendingAction) {
1821        self.pending_confirmation = Some(action);
1822    }
1823
1824    /// Confirm the pending action.
1825    pub fn confirm_action(&mut self) -> Option<Command> {
1826        if let Some(action) = self.pending_confirmation.take() {
1827            match action {
1828                PendingAction::Disconnect { device_id, .. } => {
1829                    return Some(Command::Disconnect { device_id });
1830                }
1831            }
1832        }
1833        None
1834    }
1835
1836    /// Cancel the pending action.
1837    pub fn cancel_confirmation(&mut self) {
1838        self.pending_confirmation = None;
1839        self.push_status_message("Cancelled".to_string());
1840    }
1841
1842    /// Toggle sidebar visibility.
1843    pub fn toggle_sidebar(&mut self) {
1844        self.show_sidebar = !self.show_sidebar;
1845    }
1846
1847    /// Toggle between normal and wide sidebar.
1848    pub fn toggle_sidebar_width(&mut self) {
1849        self.sidebar_width = if self.sidebar_width == 28 { 40 } else { 28 };
1850    }
1851
1852    /// Start editing alias for selected device.
1853    pub fn start_alias_edit(&mut self) {
1854        if let Some(device) = self.selected_device() {
1855            self.alias_input = device
1856                .alias
1857                .clone()
1858                .or_else(|| device.name.clone())
1859                .unwrap_or_default();
1860            self.editing_alias = true;
1861        }
1862    }
1863
1864    /// Cancel alias editing.
1865    pub fn cancel_alias_edit(&mut self) {
1866        self.editing_alias = false;
1867        self.alias_input.clear();
1868    }
1869
1870    /// Save the alias.
1871    pub fn save_alias(&mut self) {
1872        let display_name = if let Some(device) = self.devices.get_mut(self.selected_device) {
1873            if self.alias_input.trim().is_empty() {
1874                device.alias = None;
1875            } else {
1876                device.alias = Some(self.alias_input.trim().to_string());
1877            }
1878            Some(device.display_name().to_string())
1879        } else {
1880            None
1881        };
1882        if let Some(name) = display_name {
1883            self.push_status_message(format!("Alias set: {}", name));
1884        }
1885        self.editing_alias = false;
1886        self.alias_input.clear();
1887    }
1888
1889    /// Handle character input for alias editing.
1890    pub fn alias_input_char(&mut self, c: char) {
1891        if self.alias_input.len() < 20 {
1892            self.alias_input.push(c);
1893        }
1894    }
1895
1896    /// Handle backspace for alias editing.
1897    pub fn alias_input_backspace(&mut self) {
1898        self.alias_input.pop();
1899    }
1900
1901    /// Store an error for later display.
1902    pub fn set_error(&mut self, error: String) {
1903        self.last_error = Some(error);
1904    }
1905
1906    /// Toggle error details popup.
1907    pub fn toggle_error_details(&mut self) {
1908        if self.last_error.is_some() {
1909            self.show_error_details = !self.show_error_details;
1910        } else {
1911            self.push_status_message("No error to display".to_string());
1912        }
1913    }
1914
1915    /// Get average CO2 across all connected devices with readings.
1916    pub fn average_co2(&self) -> Option<u16> {
1917        let values: Vec<u16> = self
1918            .devices
1919            .iter()
1920            .filter(|d| matches!(d.status, ConnectionStatus::Connected))
1921            .filter_map(|d| d.reading.as_ref())
1922            .filter_map(|r| if r.co2 > 0 { Some(r.co2) } else { None })
1923            .collect();
1924
1925        if values.is_empty() {
1926            None
1927        } else {
1928            Some((values.iter().map(|&v| v as u32).sum::<u32>() / values.len() as u32) as u16)
1929        }
1930    }
1931
1932    /// Get count of connected devices.
1933    pub fn connected_count(&self) -> usize {
1934        self.devices
1935            .iter()
1936            .filter(|d| matches!(d.status, ConnectionStatus::Connected))
1937            .count()
1938    }
1939
1940    /// Check if any device is currently connecting.
1941    pub fn is_any_connecting(&self) -> bool {
1942        self.devices
1943            .iter()
1944            .any(|d| matches!(d.status, ConnectionStatus::Connecting))
1945    }
1946
1947    /// Check if a history sync is in progress.
1948    pub fn is_syncing(&self) -> bool {
1949        self.syncing
1950    }
1951
1952    /// Toggle comparison view.
1953    pub fn toggle_comparison(&mut self) {
1954        if self.devices.len() < 2 {
1955            self.push_status_message("Need at least 2 devices for comparison".to_string());
1956            return;
1957        }
1958
1959        self.show_comparison = !self.show_comparison;
1960
1961        if self.show_comparison {
1962            // Pick the next device as comparison target
1963            let next = (self.selected_device + 1) % self.devices.len();
1964            self.comparison_device_index = Some(next);
1965            self.push_status_message(
1966                "Comparison view: use </> to change second device".to_string(),
1967            );
1968        } else {
1969            self.comparison_device_index = None;
1970        }
1971    }
1972
1973    /// Cycle the comparison device.
1974    pub fn cycle_comparison_device(&mut self, forward: bool) {
1975        if !self.show_comparison || self.devices.len() < 2 {
1976            return;
1977        }
1978
1979        let current = self.comparison_device_index.unwrap_or(0);
1980        let mut next = if forward {
1981            (current + 1) % self.devices.len()
1982        } else {
1983            current.checked_sub(1).unwrap_or(self.devices.len() - 1)
1984        };
1985
1986        // Skip the selected device
1987        if next == self.selected_device {
1988            next = if forward {
1989                (next + 1) % self.devices.len()
1990            } else {
1991                next.checked_sub(1).unwrap_or(self.devices.len() - 1)
1992            };
1993        }
1994
1995        self.comparison_device_index = Some(next);
1996    }
1997
1998    /// Get the comparison device.
1999    pub fn comparison_device(&self) -> Option<&DeviceState> {
2000        self.comparison_device_index
2001            .and_then(|i| self.devices.get(i))
2002    }
2003}