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