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