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