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