1use std::time::{Duration, Instant};
7
8use tokio::sync::mpsc;
9
10use aranet_core::settings::DeviceSettings;
11use aranet_types::{CurrentReading, DeviceType, HistoryRecord};
12
13use super::messages::{CachedDevice, Command, SensorEvent};
14
15const MAX_ALERT_HISTORY: usize = 1000;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum BleRange {
21 #[default]
22 Standard,
23 Extended,
24}
25
26impl BleRange {
27 pub fn name(self) -> &'static str {
29 match self {
30 Self::Standard => "Standard",
31 Self::Extended => "Extended",
32 }
33 }
34
35 pub fn toggle(self) -> Self {
37 match self {
38 Self::Standard => Self::Extended,
39 Self::Extended => Self::Standard,
40 }
41 }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum Theme {
47 #[default]
48 Dark,
49 Light,
50}
51
52impl Theme {
53 pub fn bg(self) -> ratatui::style::Color {
55 match self {
56 Self::Dark => ratatui::style::Color::Reset,
57 Self::Light => ratatui::style::Color::White,
58 }
59 }
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub enum ConnectionStatus {
65 #[default]
67 Disconnected,
68 Connecting,
70 Connected,
72 Error(String),
74}
75
76#[derive(Debug, Clone)]
78pub struct DeviceState {
79 pub id: String,
81 pub name: Option<String>,
83 pub alias: Option<String>,
85 pub device_type: Option<DeviceType>,
87 pub reading: Option<CurrentReading>,
89 pub history: Vec<HistoryRecord>,
91 pub status: ConnectionStatus,
93 pub last_updated: Option<Instant>,
95 pub error: Option<String>,
97 pub previous_reading: Option<CurrentReading>,
99 pub session_stats: SessionStats,
101 pub last_sync: Option<time::OffsetDateTime>,
103 pub rssi: Option<i16>,
105 pub connected_at: Option<std::time::Instant>,
107 pub settings: Option<DeviceSettings>,
109}
110
111impl DeviceState {
112 pub fn new(id: String) -> Self {
114 Self {
115 id,
116 name: None,
117 alias: None,
118 device_type: None,
119 reading: None,
120 history: Vec::new(),
121 status: ConnectionStatus::Disconnected,
122 last_updated: None,
123 error: None,
124 previous_reading: None,
125 session_stats: SessionStats::default(),
126 last_sync: None,
127 rssi: None,
128 connected_at: None,
129 settings: None,
130 }
131 }
132
133 pub fn display_name(&self) -> &str {
135 self.alias
136 .as_deref()
137 .or(self.name.as_deref())
138 .unwrap_or(&self.id)
139 }
140
141 pub fn uptime(&self) -> Option<String> {
143 let connected_at = self.connected_at?;
144 let elapsed = connected_at.elapsed();
145 let secs = elapsed.as_secs();
146
147 if secs < 60 {
148 Some(format!("{}s", secs))
149 } else if secs < 3600 {
150 Some(format!("{}m {}s", secs / 60, secs % 60))
151 } else {
152 let hours = secs / 3600;
153 let mins = (secs % 3600) / 60;
154 Some(format!("{}h {}m", hours, mins))
155 }
156 }
157}
158
159#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
161pub enum Tab {
162 #[default]
164 Dashboard,
165 History,
167 Settings,
169}
170
171#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
173pub enum HistoryFilter {
174 #[default]
176 All,
177 Today,
179 Last24Hours,
181 Last7Days,
183 Last30Days,
185}
186
187impl HistoryFilter {
188 pub fn label(&self) -> &'static str {
190 match self {
191 HistoryFilter::All => "All",
192 HistoryFilter::Today => "Today",
193 HistoryFilter::Last24Hours => "24h",
194 HistoryFilter::Last7Days => "7d",
195 HistoryFilter::Last30Days => "30d",
196 }
197 }
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Default)]
202pub enum DeviceFilter {
203 #[default]
205 All,
206 Aranet4Only,
208 RadonOnly,
210 RadiationOnly,
212 ConnectedOnly,
214}
215
216impl DeviceFilter {
217 pub fn label(&self) -> &'static str {
219 match self {
220 Self::All => "All",
221 Self::Aranet4Only => "Aranet4",
222 Self::RadonOnly => "Radon",
223 Self::RadiationOnly => "Radiation",
224 Self::ConnectedOnly => "Connected",
225 }
226 }
227
228 pub fn next(&self) -> Self {
230 match self {
231 Self::All => Self::Aranet4Only,
232 Self::Aranet4Only => Self::RadonOnly,
233 Self::RadonOnly => Self::RadiationOnly,
234 Self::RadiationOnly => Self::ConnectedOnly,
235 Self::ConnectedOnly => Self::All,
236 }
237 }
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub enum AlertSeverity {
243 Info,
245 Warning,
247 Critical,
249}
250
251impl AlertSeverity {
252 pub fn color(self) -> ratatui::style::Color {
254 match self {
255 Self::Info => ratatui::style::Color::Blue,
256 Self::Warning => ratatui::style::Color::Yellow,
257 Self::Critical => ratatui::style::Color::Red,
258 }
259 }
260
261 pub fn icon(self) -> &'static str {
263 match self {
264 Self::Info => "(i)",
265 Self::Warning => "(!)",
266 Self::Critical => "(X)",
267 }
268 }
269}
270
271#[derive(Debug, Clone)]
273#[allow(dead_code)]
274pub struct Alert {
275 pub device_id: String,
277 pub device_name: Option<String>,
279 pub message: String,
281 pub level: aranet_core::Co2Level,
283 pub triggered_at: Instant,
285 pub severity: AlertSeverity,
287}
288
289#[derive(Debug, Clone)]
291pub struct AlertRecord {
292 pub device_name: String,
294 pub message: String,
296 pub timestamp: time::OffsetDateTime,
298 pub severity: AlertSeverity,
300}
301
302#[derive(Debug, Clone, Default)]
304pub struct SessionStats {
305 pub co2_min: Option<u16>,
307 pub co2_max: Option<u16>,
309 pub co2_sum: u64,
311 pub co2_count: u32,
313 pub temp_min: Option<f32>,
315 pub temp_max: Option<f32>,
317}
318
319impl SessionStats {
320 pub fn update(&mut self, reading: &CurrentReading) {
322 if reading.co2 > 0 {
324 self.co2_min = Some(self.co2_min.map_or(reading.co2, |m| m.min(reading.co2)));
325 self.co2_max = Some(self.co2_max.map_or(reading.co2, |m| m.max(reading.co2)));
326 self.co2_sum += reading.co2 as u64;
327 self.co2_count += 1;
328 }
329
330 self.temp_min = Some(
332 self.temp_min
333 .map_or(reading.temperature, |m| m.min(reading.temperature)),
334 );
335 self.temp_max = Some(
336 self.temp_max
337 .map_or(reading.temperature, |m| m.max(reading.temperature)),
338 );
339 }
340
341 pub fn co2_avg(&self) -> Option<u16> {
343 if self.co2_count > 0 {
344 Some((self.co2_sum / self.co2_count as u64) as u16)
345 } else {
346 None
347 }
348 }
349}
350
351pub fn calculate_radon_averages(history: &[HistoryRecord]) -> (Option<u32>, Option<u32>) {
353 use time::OffsetDateTime;
354
355 let now = OffsetDateTime::now_utc();
356 let day_ago = now - time::Duration::days(1);
357 let week_ago = now - time::Duration::days(7);
358
359 let mut day_sum: u64 = 0;
360 let mut day_count: u32 = 0;
361 let mut week_sum: u64 = 0;
362 let mut week_count: u32 = 0;
363
364 for record in history {
365 if let Some(radon) = record.radon
366 && record.timestamp >= week_ago
367 {
368 week_sum += radon as u64;
369 week_count += 1;
370
371 if record.timestamp >= day_ago {
372 day_sum += radon as u64;
373 day_count += 1;
374 }
375 }
376 }
377
378 let day_avg = if day_count > 0 {
379 Some((day_sum / day_count as u64) as u32)
380 } else {
381 None
382 };
383
384 let week_avg = if week_count > 0 {
385 Some((week_sum / week_count as u64) as u32)
386 } else {
387 None
388 };
389
390 (day_avg, week_avg)
391}
392
393#[derive(Debug, Clone)]
395pub enum PendingAction {
396 Disconnect {
398 device_id: String,
399 device_name: String,
400 },
401}
402
403pub struct App {
405 pub should_quit: bool,
407 pub active_tab: Tab,
409 pub selected_device: usize,
411 pub devices: Vec<DeviceState>,
413 pub scanning: bool,
415 pub status_messages: Vec<(String, Instant)>,
417 pub status_message_timeout: u64,
419 pub show_help: bool,
421 #[allow(dead_code)]
423 pub command_tx: mpsc::Sender<Command>,
424 pub event_rx: mpsc::Receiver<SensorEvent>,
426 pub thresholds: aranet_core::Thresholds,
428 pub alerts: Vec<Alert>,
430 pub alert_history: Vec<AlertRecord>,
432 pub show_alert_history: bool,
434 pub log_file: Option<std::path::PathBuf>,
436 pub logging_enabled: bool,
438 pub last_auto_refresh: Option<Instant>,
440 pub auto_refresh_interval: Duration,
442 pub history_scroll: usize,
444 pub history_filter: HistoryFilter,
446 pub spinner_frame: usize,
448 pub selected_setting: usize,
450 pub interval_options: Vec<u16>,
452 pub co2_alert_threshold: u16,
454 pub radon_alert_threshold: u16,
456 pub bell_enabled: bool,
458 pub device_filter: DeviceFilter,
460 pub pending_confirmation: Option<PendingAction>,
462 pub show_sidebar: bool,
464 pub show_fullscreen_chart: bool,
466 pub editing_alias: bool,
468 pub alias_input: String,
470 pub sticky_alerts: bool,
472 pub last_error: Option<String>,
474 pub show_error_details: bool,
476 pub show_comparison: bool,
478 pub comparison_device_index: Option<usize>,
480 pub sidebar_width: u16,
482 pub theme: Theme,
484 pub chart_metrics: u8,
486 pub smart_home_enabled: bool,
488 pub ble_range: BleRange,
490 pub syncing: bool,
492}
493
494impl App {
495 pub fn new(command_tx: mpsc::Sender<Command>, event_rx: mpsc::Receiver<SensorEvent>) -> Self {
497 Self {
498 should_quit: false,
499 active_tab: Tab::default(),
500 selected_device: 0,
501 devices: Vec::new(),
502 scanning: false,
503 status_messages: Vec::new(),
504 status_message_timeout: 5, show_help: false,
506 command_tx,
507 event_rx,
508 thresholds: aranet_core::Thresholds::default(),
509 alerts: Vec::new(),
510 alert_history: Vec::new(),
511 show_alert_history: false,
512 log_file: None,
513 logging_enabled: false,
514 last_auto_refresh: None,
515 auto_refresh_interval: Duration::from_secs(60),
516 history_scroll: 0,
517 history_filter: HistoryFilter::default(),
518 spinner_frame: 0,
519 selected_setting: 0,
520 interval_options: vec![60, 120, 300, 600], co2_alert_threshold: 1500,
522 radon_alert_threshold: 300,
523 bell_enabled: true,
524 device_filter: DeviceFilter::default(),
525 pending_confirmation: None,
526 show_sidebar: true,
527 show_fullscreen_chart: false,
528 editing_alias: false,
529 alias_input: String::new(),
530 sticky_alerts: false,
531 last_error: None,
532 show_error_details: false,
533 show_comparison: false,
534 comparison_device_index: None,
535 sidebar_width: 28,
536 theme: Theme::default(),
537 chart_metrics: Self::METRIC_PRIMARY, smart_home_enabled: false,
539 ble_range: BleRange::default(),
540 syncing: false,
541 }
542 }
543
544 pub fn toggle_ble_range(&mut self) {
546 self.ble_range = self.ble_range.toggle();
547 self.push_status_message(format!("BLE range: {}", self.ble_range.name()));
548 }
549
550 pub const METRIC_PRIMARY: u8 = 0b001;
552 pub const METRIC_TEMP: u8 = 0b010;
554 pub const METRIC_HUMIDITY: u8 = 0b100;
556
557 pub fn toggle_chart_metric(&mut self, metric: u8) {
559 self.chart_metrics ^= metric;
560 if self.chart_metrics == 0 {
562 self.chart_metrics = Self::METRIC_PRIMARY;
563 }
564 }
565
566 pub fn chart_shows(&self, metric: u8) -> bool {
568 self.chart_metrics & metric != 0
569 }
570
571 pub fn toggle_theme(&mut self) {
573 self.theme = match self.theme {
574 Theme::Dark => Theme::Light,
575 Theme::Light => Theme::Dark,
576 };
577 }
578
579 #[must_use]
581 pub fn app_theme(&self) -> super::ui::theme::AppTheme {
582 match self.theme {
583 Theme::Dark => super::ui::theme::AppTheme::dark(),
584 Theme::Light => super::ui::theme::AppTheme::light(),
585 }
586 }
587
588 pub fn toggle_smart_home(&mut self) {
590 self.smart_home_enabled = !self.smart_home_enabled;
591 let status = if self.smart_home_enabled {
592 "enabled"
593 } else {
594 "disabled"
595 };
596 self.push_status_message(format!("Smart Home mode {}", status));
597 }
598
599 pub fn toggle_fullscreen_chart(&mut self) {
601 self.show_fullscreen_chart = !self.show_fullscreen_chart;
602 }
603
604 pub fn should_quit(&self) -> bool {
606 self.should_quit
607 }
608
609 pub fn push_status_message(&mut self, message: String) {
611 self.status_messages.push((message, Instant::now()));
612 while self.status_messages.len() > 5 {
614 self.status_messages.remove(0);
615 }
616 }
617
618 pub fn clean_expired_messages(&mut self) {
620 let timeout = std::time::Duration::from_secs(self.status_message_timeout);
621 self.status_messages
622 .retain(|(_, created)| created.elapsed() < timeout);
623 }
624
625 pub fn current_status_message(&self) -> Option<&str> {
627 self.status_messages.last().map(|(msg, _)| msg.as_str())
628 }
629
630 pub fn handle_sensor_event(&mut self, event: SensorEvent) -> Vec<Command> {
634 let mut commands = Vec::new();
635
636 match event {
637 SensorEvent::CachedDataLoaded { devices } => {
638 let device_ids: Vec<String> = devices.iter().map(|d| d.id.clone()).collect();
640 self.handle_cached_data(devices);
641
642 for device_id in device_ids {
644 commands.push(Command::Connect { device_id });
645 }
646 }
647 SensorEvent::ScanStarted => {
648 self.scanning = true;
649 self.push_status_message("Scanning for devices...".to_string());
650 }
651 SensorEvent::ScanComplete { devices } => {
652 self.scanning = false;
653 self.push_status_message(format!("Found {} device(s)", devices.len()));
654 for discovered in devices {
656 let id_str = discovered.id.to_string();
657 if !self.devices.iter().any(|d| d.id == id_str) {
658 let mut device = DeviceState::new(id_str);
659 device.name = discovered.name;
660 device.device_type = discovered.device_type;
661 self.devices.push(device);
662 }
663 }
664 }
665 SensorEvent::ScanError { error } => {
666 self.scanning = false;
667 let error_msg = format!("Scan: {}", error);
668 self.set_error(error_msg);
669 self.push_status_message(format!(
670 "Scan error: {} (press E for details)",
671 error.chars().take(40).collect::<String>()
672 ));
673 }
674 SensorEvent::DeviceConnecting { device_id } => {
675 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
676 device.status = ConnectionStatus::Connecting;
677 device.last_updated = Some(Instant::now());
678 }
679 self.push_status_message("Connecting...".to_string());
680 }
681 SensorEvent::DeviceConnected {
682 device_id,
683 name,
684 device_type,
685 rssi,
686 } => {
687 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
688 device.status = ConnectionStatus::Connected;
689 device.name = name.or(device.name.take());
690 device.device_type = device_type.or(device.device_type);
691 device.rssi = rssi;
692 device.last_updated = Some(Instant::now());
693 device.error = None;
694 device.connected_at = Some(Instant::now());
695 }
696 self.push_status_message("Connected".to_string());
697
698 commands.push(Command::SyncHistory {
700 device_id: device_id.clone(),
701 });
702 }
703 SensorEvent::DeviceDisconnected { device_id } => {
704 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
705 device.status = ConnectionStatus::Disconnected;
706 device.last_updated = Some(Instant::now());
707 device.connected_at = None;
708 }
709 }
710 SensorEvent::ConnectionError { device_id, error } => {
711 let device_name = self
712 .devices
713 .iter()
714 .find(|d| d.id == device_id)
715 .map(|d| d.display_name().to_string())
716 .unwrap_or_else(|| device_id.clone());
717 let error_msg = format!("{}: {}", device_name, error);
718 self.set_error(error_msg);
719 self.push_status_message(format!(
720 "Connection error: {} (press E for details)",
721 error.chars().take(40).collect::<String>()
722 ));
723 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
724 device.status = ConnectionStatus::Error(error.clone());
725 device.error = Some(error);
726 device.last_updated = Some(Instant::now());
727 }
728 }
729 SensorEvent::ReadingUpdated { device_id, reading } => {
730 self.check_thresholds(&device_id, &reading);
732
733 self.log_reading(&device_id, &reading);
735
736 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
737 device.session_stats.update(&reading);
739 device.previous_reading = device.reading.take();
741 device.reading = Some(reading);
742 device.last_updated = Some(Instant::now());
743 device.error = None;
744 }
745 }
746 SensorEvent::ReadingError { device_id, error } => {
747 let device_name = self
748 .devices
749 .iter()
750 .find(|d| d.id == device_id)
751 .map(|d| d.display_name().to_string())
752 .unwrap_or_else(|| device_id.clone());
753 let error_msg = format!("{}: {}", device_name, error);
754 self.set_error(error_msg);
755 self.push_status_message(format!(
756 "Reading error: {} (press E for details)",
757 error.chars().take(40).collect::<String>()
758 ));
759 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
760 device.error = Some(error);
761 device.last_updated = Some(Instant::now());
762 }
763 }
764 SensorEvent::HistoryLoaded { device_id, records } => {
765 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
766 device.history = records;
767 device.last_updated = Some(Instant::now());
768 }
769 }
770 SensorEvent::HistorySyncStarted { device_id } => {
771 self.syncing = true;
772 self.push_status_message(format!("Syncing history for {}...", device_id));
773 }
774 SensorEvent::HistorySynced { device_id, count } => {
775 self.syncing = false;
776 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
777 device.last_sync = Some(time::OffsetDateTime::now_utc());
778 }
779 self.push_status_message(format!("Synced {} records for {}", count, device_id));
780 }
781 SensorEvent::HistorySyncError { device_id, error } => {
782 self.syncing = false;
783 let device_name = self
784 .devices
785 .iter()
786 .find(|d| d.id == device_id)
787 .map(|d| d.display_name().to_string())
788 .unwrap_or_else(|| device_id.clone());
789 let error_msg = format!("{}: {}", device_name, error);
790 self.set_error(error_msg);
791 self.push_status_message(format!(
792 "History sync failed: {} (press E for details)",
793 error.chars().take(40).collect::<String>()
794 ));
795 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
796 device.error = Some(error);
797 }
798 }
799 SensorEvent::IntervalChanged {
800 device_id,
801 interval_secs,
802 } => {
803 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id)
804 && let Some(reading) = &mut device.reading
805 {
806 reading.interval = interval_secs;
807 }
808 self.push_status_message(format!("Interval set to {}m", interval_secs / 60));
809 }
810 SensorEvent::IntervalError { device_id, error } => {
811 let device_name = self
812 .devices
813 .iter()
814 .find(|d| d.id == device_id)
815 .map(|d| d.display_name().to_string())
816 .unwrap_or_else(|| device_id.clone());
817 let error_msg = format!("{}: {}", device_name, error);
818 self.set_error(error_msg);
819 self.push_status_message(format!(
820 "Set interval failed: {} (press E for details)",
821 error.chars().take(40).collect::<String>()
822 ));
823 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
824 device.error = Some(error);
825 }
826 }
827 SensorEvent::SettingsLoaded {
828 device_id,
829 settings,
830 } => {
831 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
832 device.settings = Some(settings);
833 device.last_updated = Some(Instant::now());
834 }
835 }
836 SensorEvent::BluetoothRangeChanged {
837 device_id: _,
838 extended,
839 } => {
840 let range = if extended { "Extended" } else { "Standard" };
841 self.push_status_message(format!("Bluetooth range set to {}", range));
842 }
843 SensorEvent::BluetoothRangeError { device_id, error } => {
844 let device_name = self
845 .devices
846 .iter()
847 .find(|d| d.id == device_id)
848 .and_then(|d| d.name.clone())
849 .unwrap_or_else(|| device_id.clone());
850 let error_msg = format!("{}: {}", device_name, error);
851 self.set_error(error_msg);
852 self.push_status_message(format!(
853 "Set BT range failed: {} (press E for details)",
854 error.chars().take(40).collect::<String>()
855 ));
856 }
857 SensorEvent::SmartHomeChanged {
858 device_id: _,
859 enabled,
860 } => {
861 let mode = if enabled { "enabled" } else { "disabled" };
862 self.push_status_message(format!("Smart Home {}", mode));
863 }
864 SensorEvent::SmartHomeError { device_id, error } => {
865 let device_name = self
866 .devices
867 .iter()
868 .find(|d| d.id == device_id)
869 .and_then(|d| d.name.clone())
870 .unwrap_or_else(|| device_id.clone());
871 let error_msg = format!("{}: {}", device_name, error);
872 self.set_error(error_msg);
873 self.push_status_message(format!(
874 "Set Smart Home failed: {} (press E for details)",
875 error.chars().take(40).collect::<String>()
876 ));
877 }
878 }
879
880 commands
881 }
882
883 pub fn selected_device(&self) -> Option<&DeviceState> {
885 self.devices.get(self.selected_device)
886 }
887
888 pub fn select_next_device(&mut self) {
890 if !self.devices.is_empty() {
891 self.selected_device = (self.selected_device + 1) % self.devices.len();
892 self.reset_history_scroll();
893 }
894 }
895
896 pub fn select_previous_device(&mut self) {
898 if !self.devices.is_empty() {
899 self.selected_device = self
900 .selected_device
901 .checked_sub(1)
902 .unwrap_or(self.devices.len() - 1);
903 self.reset_history_scroll();
904 }
905 }
906
907 pub fn scroll_history_up(&mut self) {
909 self.history_scroll = self.history_scroll.saturating_sub(5);
910 }
911
912 pub fn scroll_history_down(&mut self) {
914 if let Some(device) = self.selected_device() {
915 let max_scroll = device.history.len().saturating_sub(10);
916 self.history_scroll = (self.history_scroll + 5).min(max_scroll);
917 }
918 }
919
920 pub fn reset_history_scroll(&mut self) {
922 self.history_scroll = 0;
923 }
924
925 pub fn tick_spinner(&mut self) {
927 self.spinner_frame = (self.spinner_frame + 1) % 10;
928 }
929
930 pub fn spinner_char(&self) -> &'static str {
932 const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
933 SPINNER[self.spinner_frame]
934 }
935
936 pub fn set_history_filter(&mut self, filter: HistoryFilter) {
938 self.history_filter = filter;
939 self.history_scroll = 0; }
941
942 pub fn filtered_devices(&self) -> Vec<&DeviceState> {
944 self.devices
945 .iter()
946 .filter(|d| match self.device_filter {
947 DeviceFilter::All => true,
948 DeviceFilter::Aranet4Only => {
949 matches!(d.device_type, Some(DeviceType::Aranet4))
950 }
951 DeviceFilter::RadonOnly => {
952 matches!(d.device_type, Some(DeviceType::AranetRadon))
953 }
954 DeviceFilter::RadiationOnly => {
955 matches!(d.device_type, Some(DeviceType::AranetRadiation))
956 }
957 DeviceFilter::ConnectedOnly => {
958 matches!(d.status, ConnectionStatus::Connected)
959 }
960 })
961 .collect()
962 }
963
964 pub fn cycle_device_filter(&mut self) {
966 self.device_filter = self.device_filter.next();
967 self.push_status_message(format!("Filter: {}", self.device_filter.label()));
968 }
969
970 pub fn select_next_setting(&mut self) {
972 self.selected_setting = (self.selected_setting + 1) % 3; }
974
975 pub fn select_previous_setting(&mut self) {
977 self.selected_setting = self.selected_setting.checked_sub(1).unwrap_or(2);
978 }
979
980 pub fn increase_co2_threshold(&mut self) {
982 self.co2_alert_threshold = (self.co2_alert_threshold + 100).min(3000);
983 }
984
985 pub fn decrease_co2_threshold(&mut self) {
987 self.co2_alert_threshold = self.co2_alert_threshold.saturating_sub(100).max(500);
988 }
989
990 pub fn increase_radon_threshold(&mut self) {
992 self.radon_alert_threshold = (self.radon_alert_threshold + 50).min(1000);
993 }
994
995 pub fn decrease_radon_threshold(&mut self) {
997 self.radon_alert_threshold = self.radon_alert_threshold.saturating_sub(50).max(100);
998 }
999
1000 pub fn cycle_interval(&mut self) -> Option<(String, u16)> {
1002 let device = self.selected_device()?;
1003 let reading = device.reading.as_ref()?;
1004 let current_idx = self
1005 .interval_options
1006 .iter()
1007 .position(|&i| i == reading.interval)
1008 .unwrap_or(0);
1009 let next_idx = (current_idx + 1) % self.interval_options.len();
1010 let new_interval = self.interval_options[next_idx];
1011 Some((device.id.clone(), new_interval))
1012 }
1013
1014 fn handle_cached_data(&mut self, cached_devices: Vec<CachedDevice>) {
1016 let count = cached_devices.len();
1017 if count > 0 {
1018 self.push_status_message(format!("Loaded {} cached device(s)", count));
1019 }
1020
1021 for cached in cached_devices {
1022 if let Some(device) = self.devices.iter_mut().find(|d| d.id == cached.id) {
1024 if device.reading.is_none() {
1026 device.reading = cached.reading;
1027 }
1028 if device.name.is_none() {
1029 device.name = cached.name;
1030 }
1031 if device.device_type.is_none() {
1032 device.device_type = cached.device_type;
1033 }
1034 if device.last_sync.is_none() {
1036 device.last_sync = cached.last_sync;
1037 }
1038 } else {
1039 let mut device = DeviceState::new(cached.id);
1041 device.name = cached.name;
1042 device.device_type = cached.device_type;
1043 device.reading = cached.reading;
1044 device.last_sync = cached.last_sync;
1045 device.status = ConnectionStatus::Disconnected;
1047 self.devices.push(device);
1048 }
1049 }
1050 }
1051
1052 pub fn check_thresholds(&mut self, device_id: &str, reading: &CurrentReading) {
1054 if reading.co2 > 0 && reading.co2 >= self.co2_alert_threshold {
1056 let level = self.thresholds.evaluate_co2(reading.co2);
1057
1058 let severity = if reading.co2 >= self.co2_alert_threshold * 2 {
1060 AlertSeverity::Critical
1061 } else if reading.co2 >= (self.co2_alert_threshold * 3) / 2 {
1062 AlertSeverity::Warning
1063 } else {
1064 AlertSeverity::Info
1065 };
1066
1067 if !self
1069 .alerts
1070 .iter()
1071 .any(|a| a.device_id == device_id && a.message.contains("CO2"))
1072 {
1073 let device_name = self
1074 .devices
1075 .iter()
1076 .find(|d| d.id == device_id)
1077 .and_then(|d| d.name.clone());
1078
1079 let message = format!("CO2 at {} ppm - {}", reading.co2, level.action());
1080
1081 self.alerts.push(Alert {
1082 device_id: device_id.to_string(),
1083 device_name: device_name.clone(),
1084 message: message.clone(),
1085 level,
1086 triggered_at: Instant::now(),
1087 severity,
1088 });
1089
1090 self.alert_history.push(AlertRecord {
1092 device_name: device_name.unwrap_or_else(|| device_id.to_string()),
1093 message,
1094 timestamp: time::OffsetDateTime::now_utc(),
1095 severity,
1096 });
1097
1098 while self.alert_history.len() > MAX_ALERT_HISTORY {
1100 self.alert_history.remove(0);
1101 }
1102
1103 if self.bell_enabled {
1105 print!("\x07"); use std::io::Write;
1107 std::io::stdout().flush().ok();
1108 }
1109 }
1110 } else if reading.co2 > 0 && !self.sticky_alerts {
1111 self.alerts
1113 .retain(|a| !(a.device_id == device_id && a.message.contains("CO2")));
1114 }
1115
1116 if reading.battery > 0 && reading.battery < 20 {
1118 let has_battery_alert = self
1120 .alerts
1121 .iter()
1122 .any(|a| a.device_id == device_id && a.message.contains("Battery"));
1123
1124 if !has_battery_alert {
1125 let device_name = self
1126 .devices
1127 .iter()
1128 .find(|d| d.id == device_id)
1129 .and_then(|d| d.name.clone());
1130
1131 let (message, severity) = if reading.battery < 10 {
1133 (
1134 format!("Battery critically low: {}%", reading.battery),
1135 AlertSeverity::Critical,
1136 )
1137 } else {
1138 (
1139 format!("Battery low: {}%", reading.battery),
1140 AlertSeverity::Warning,
1141 )
1142 };
1143
1144 self.alerts.push(Alert {
1145 device_id: device_id.to_string(),
1146 device_name: device_name.clone(),
1147 message: message.clone(),
1148 level: aranet_core::Co2Level::Good, triggered_at: Instant::now(),
1150 severity,
1151 });
1152
1153 self.alert_history.push(AlertRecord {
1155 device_name: device_name.unwrap_or_else(|| device_id.to_string()),
1156 message,
1157 timestamp: time::OffsetDateTime::now_utc(),
1158 severity,
1159 });
1160
1161 while self.alert_history.len() > MAX_ALERT_HISTORY {
1163 self.alert_history.remove(0);
1164 }
1165
1166 if self.bell_enabled {
1168 print!("\x07"); use std::io::Write;
1170 std::io::stdout().flush().ok();
1171 }
1172 }
1173 } else if reading.battery >= 20 && !self.sticky_alerts {
1174 self.alerts
1176 .retain(|a| !(a.device_id == device_id && a.message.contains("Battery")));
1177 }
1178
1179 if let Some(radon) = reading.radon {
1181 if radon >= self.radon_alert_threshold as u32 {
1182 let has_radon_alert = self
1184 .alerts
1185 .iter()
1186 .any(|a| a.device_id == device_id && a.message.contains("Radon"));
1187
1188 if !has_radon_alert {
1189 let device_name = self
1190 .devices
1191 .iter()
1192 .find(|d| d.id == device_id)
1193 .and_then(|d| d.name.clone());
1194
1195 let severity = if radon >= (self.radon_alert_threshold as u32) * 2 {
1197 AlertSeverity::Critical
1198 } else {
1199 AlertSeverity::Warning
1200 };
1201
1202 let message = format!("Radon high: {} Bq/m³", radon);
1203
1204 self.alerts.push(Alert {
1205 device_id: device_id.to_string(),
1206 device_name: device_name.clone(),
1207 message: message.clone(),
1208 level: aranet_core::Co2Level::Good, triggered_at: Instant::now(),
1210 severity,
1211 });
1212
1213 self.alert_history.push(AlertRecord {
1215 device_name: device_name.unwrap_or_else(|| device_id.to_string()),
1216 message,
1217 timestamp: time::OffsetDateTime::now_utc(),
1218 severity,
1219 });
1220
1221 while self.alert_history.len() > MAX_ALERT_HISTORY {
1223 self.alert_history.remove(0);
1224 }
1225
1226 if self.bell_enabled {
1228 print!("\x07"); use std::io::Write;
1230 std::io::stdout().flush().ok();
1231 }
1232 }
1233 } else if !self.sticky_alerts {
1234 self.alerts
1236 .retain(|a| !(a.device_id == device_id && a.message.contains("Radon")));
1237 }
1238 }
1239 }
1240
1241 pub fn dismiss_alert(&mut self, device_id: &str) {
1243 self.alerts.retain(|a| a.device_id != device_id);
1244 }
1245
1246 pub fn toggle_alert_history(&mut self) {
1248 self.show_alert_history = !self.show_alert_history;
1249 }
1250
1251 pub fn toggle_sticky_alerts(&mut self) {
1253 self.sticky_alerts = !self.sticky_alerts;
1254 self.push_status_message(format!(
1255 "Sticky alerts {}",
1256 if self.sticky_alerts {
1257 "enabled"
1258 } else {
1259 "disabled"
1260 }
1261 ));
1262 }
1263
1264 pub fn toggle_logging(&mut self) {
1266 if self.logging_enabled {
1267 self.logging_enabled = false;
1268 self.push_status_message("Logging disabled".to_string());
1269 } else {
1270 let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
1272 let log_dir = dirs::data_local_dir()
1273 .unwrap_or_else(|| std::path::PathBuf::from("."))
1274 .join("aranet")
1275 .join("logs");
1276
1277 if let Err(e) = std::fs::create_dir_all(&log_dir) {
1279 self.push_status_message(format!("Failed to create log dir: {}", e));
1280 return;
1281 }
1282
1283 let log_path = log_dir.join(format!("readings_{}.csv", timestamp));
1284 self.log_file = Some(log_path.clone());
1285 self.logging_enabled = true;
1286 self.push_status_message(format!("Logging to {}", log_path.display()));
1287 }
1288 }
1289
1290 pub fn log_reading(&self, device_id: &str, reading: &CurrentReading) {
1292 if !self.logging_enabled {
1293 return;
1294 }
1295
1296 let Some(log_path) = &self.log_file else {
1297 return;
1298 };
1299
1300 use std::io::Write;
1301
1302 let file_exists = log_path.exists();
1303 let file = match std::fs::OpenOptions::new()
1304 .create(true)
1305 .append(true)
1306 .open(log_path)
1307 {
1308 Ok(f) => f,
1309 Err(_) => return,
1310 };
1311
1312 let mut writer = std::io::BufWriter::new(file);
1313
1314 if !file_exists {
1316 let _ = writeln!(
1317 writer,
1318 "timestamp,device_id,co2,temperature,humidity,pressure,battery,status,radon,radiation_rate"
1319 );
1320 }
1321
1322 let timestamp = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S");
1323 let radon = reading.radon.map(|r| r.to_string()).unwrap_or_default();
1324 let radiation = reading
1325 .radiation_rate
1326 .map(|r| format!("{:.3}", r))
1327 .unwrap_or_default();
1328
1329 let _ = writeln!(
1330 writer,
1331 "{},{},{},{:.1},{},{:.1},{},{:?},{},{}",
1332 timestamp,
1333 device_id,
1334 reading.co2,
1335 reading.temperature,
1336 reading.humidity,
1337 reading.pressure,
1338 reading.battery,
1339 reading.status,
1340 radon,
1341 radiation
1342 );
1343 }
1344
1345 pub fn export_history(&self) -> Option<String> {
1347 use std::io::Write;
1348
1349 let device = self.selected_device()?;
1350 if device.history.is_empty() {
1351 return None;
1352 }
1353
1354 let filtered: Vec<_> = device
1356 .history
1357 .iter()
1358 .filter(|r| self.filter_matches_record(r))
1359 .collect();
1360
1361 if filtered.is_empty() {
1362 return None;
1363 }
1364
1365 let export_dir = dirs::data_local_dir()
1367 .unwrap_or_else(|| std::path::PathBuf::from("."))
1368 .join("aranet")
1369 .join("exports");
1370 std::fs::create_dir_all(&export_dir).ok()?;
1371
1372 let now =
1374 time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
1375 let filename = format!(
1376 "history_{}_{}.csv",
1377 device
1378 .name
1379 .as_deref()
1380 .unwrap_or(&device.id)
1381 .replace(" ", "_"),
1382 now.format(
1383 &time::format_description::parse("[year][month][day]_[hour][minute][second]")
1384 .unwrap()
1385 )
1386 .unwrap_or_default()
1387 );
1388 let path = export_dir.join(&filename);
1389
1390 let mut file = std::fs::File::create(&path).ok()?;
1392
1393 writeln!(
1395 file,
1396 "timestamp,co2,temperature,humidity,pressure,radon,radiation_rate"
1397 )
1398 .ok()?;
1399
1400 for record in filtered {
1402 writeln!(
1403 file,
1404 "{},{},{:.1},{},{:.1},{},{}",
1405 record
1406 .timestamp
1407 .format(&time::format_description::well_known::Rfc3339)
1408 .unwrap_or_default(),
1409 record.co2,
1410 record.temperature,
1411 record.humidity,
1412 record.pressure,
1413 record.radon.map(|v| v.to_string()).unwrap_or_default(),
1414 record
1415 .radiation_rate
1416 .map(|v| format!("{:.3}", v))
1417 .unwrap_or_default(),
1418 )
1419 .ok()?;
1420 }
1421
1422 Some(path.to_string_lossy().to_string())
1423 }
1424
1425 fn filter_matches_record(&self, record: &HistoryRecord) -> bool {
1427 use time::OffsetDateTime;
1428
1429 match self.history_filter {
1430 HistoryFilter::All => true,
1431 HistoryFilter::Today => {
1432 let now = OffsetDateTime::now_utc();
1433 record.timestamp.date() == now.date()
1434 }
1435 HistoryFilter::Last24Hours => {
1436 let cutoff = OffsetDateTime::now_utc() - time::Duration::hours(24);
1437 record.timestamp >= cutoff
1438 }
1439 HistoryFilter::Last7Days => {
1440 let cutoff = OffsetDateTime::now_utc() - time::Duration::days(7);
1441 record.timestamp >= cutoff
1442 }
1443 HistoryFilter::Last30Days => {
1444 let cutoff = OffsetDateTime::now_utc() - time::Duration::days(30);
1445 record.timestamp >= cutoff
1446 }
1447 }
1448 }
1449
1450 pub fn check_auto_refresh(&mut self) -> Vec<String> {
1452 let now = Instant::now();
1453
1454 let interval = self
1457 .devices
1458 .iter()
1459 .find(|d| d.status == ConnectionStatus::Connected)
1460 .and_then(|d| d.reading.as_ref())
1461 .map(|r| Duration::from_secs(r.interval as u64))
1462 .unwrap_or(Duration::from_secs(60));
1463
1464 self.auto_refresh_interval = interval;
1465
1466 let should_refresh = match self.last_auto_refresh {
1468 Some(last) => now.duration_since(last) >= interval,
1469 None => true, };
1471
1472 if should_refresh {
1473 self.last_auto_refresh = Some(now);
1474 self.devices
1476 .iter()
1477 .filter(|d| d.status == ConnectionStatus::Connected)
1478 .map(|d| d.id.clone())
1479 .collect()
1480 } else {
1481 Vec::new()
1482 }
1483 }
1484
1485 pub fn request_confirmation(&mut self, action: PendingAction) {
1487 self.pending_confirmation = Some(action);
1488 }
1489
1490 pub fn confirm_action(&mut self) -> Option<Command> {
1492 if let Some(action) = self.pending_confirmation.take() {
1493 match action {
1494 PendingAction::Disconnect { device_id, .. } => {
1495 return Some(Command::Disconnect { device_id });
1496 }
1497 }
1498 }
1499 None
1500 }
1501
1502 pub fn cancel_confirmation(&mut self) {
1504 self.pending_confirmation = None;
1505 self.push_status_message("Cancelled".to_string());
1506 }
1507
1508 pub fn toggle_sidebar(&mut self) {
1510 self.show_sidebar = !self.show_sidebar;
1511 }
1512
1513 pub fn toggle_sidebar_width(&mut self) {
1515 self.sidebar_width = if self.sidebar_width == 28 { 40 } else { 28 };
1516 }
1517
1518 pub fn start_alias_edit(&mut self) {
1520 if let Some(device) = self.selected_device() {
1521 self.alias_input = device
1522 .alias
1523 .clone()
1524 .or_else(|| device.name.clone())
1525 .unwrap_or_default();
1526 self.editing_alias = true;
1527 }
1528 }
1529
1530 pub fn cancel_alias_edit(&mut self) {
1532 self.editing_alias = false;
1533 self.alias_input.clear();
1534 }
1535
1536 pub fn save_alias(&mut self) {
1538 let display_name = if let Some(device) = self.devices.get_mut(self.selected_device) {
1539 if self.alias_input.trim().is_empty() {
1540 device.alias = None;
1541 } else {
1542 device.alias = Some(self.alias_input.trim().to_string());
1543 }
1544 Some(device.display_name().to_string())
1545 } else {
1546 None
1547 };
1548 if let Some(name) = display_name {
1549 self.push_status_message(format!("Alias set: {}", name));
1550 }
1551 self.editing_alias = false;
1552 self.alias_input.clear();
1553 }
1554
1555 pub fn alias_input_char(&mut self, c: char) {
1557 if self.alias_input.len() < 20 {
1558 self.alias_input.push(c);
1559 }
1560 }
1561
1562 pub fn alias_input_backspace(&mut self) {
1564 self.alias_input.pop();
1565 }
1566
1567 pub fn set_error(&mut self, error: String) {
1569 self.last_error = Some(error);
1570 }
1571
1572 pub fn toggle_error_details(&mut self) {
1574 if self.last_error.is_some() {
1575 self.show_error_details = !self.show_error_details;
1576 } else {
1577 self.push_status_message("No error to display".to_string());
1578 }
1579 }
1580
1581 pub fn average_co2(&self) -> Option<u16> {
1583 let values: Vec<u16> = self
1584 .devices
1585 .iter()
1586 .filter(|d| matches!(d.status, ConnectionStatus::Connected))
1587 .filter_map(|d| d.reading.as_ref())
1588 .filter_map(|r| if r.co2 > 0 { Some(r.co2) } else { None })
1589 .collect();
1590
1591 if values.is_empty() {
1592 None
1593 } else {
1594 Some((values.iter().map(|&v| v as u32).sum::<u32>() / values.len() as u32) as u16)
1595 }
1596 }
1597
1598 pub fn connected_count(&self) -> usize {
1600 self.devices
1601 .iter()
1602 .filter(|d| matches!(d.status, ConnectionStatus::Connected))
1603 .count()
1604 }
1605
1606 pub fn is_any_connecting(&self) -> bool {
1608 self.devices
1609 .iter()
1610 .any(|d| matches!(d.status, ConnectionStatus::Connecting))
1611 }
1612
1613 pub fn is_syncing(&self) -> bool {
1615 self.syncing
1616 }
1617
1618 pub fn toggle_comparison(&mut self) {
1620 if self.devices.len() < 2 {
1621 self.push_status_message("Need at least 2 devices for comparison".to_string());
1622 return;
1623 }
1624
1625 self.show_comparison = !self.show_comparison;
1626
1627 if self.show_comparison {
1628 let next = (self.selected_device + 1) % self.devices.len();
1630 self.comparison_device_index = Some(next);
1631 self.push_status_message(
1632 "Comparison view: use </> to change second device".to_string(),
1633 );
1634 } else {
1635 self.comparison_device_index = None;
1636 }
1637 }
1638
1639 pub fn cycle_comparison_device(&mut self, forward: bool) {
1641 if !self.show_comparison || self.devices.len() < 2 {
1642 return;
1643 }
1644
1645 let current = self.comparison_device_index.unwrap_or(0);
1646 let mut next = if forward {
1647 (current + 1) % self.devices.len()
1648 } else {
1649 current.checked_sub(1).unwrap_or(self.devices.len() - 1)
1650 };
1651
1652 if next == self.selected_device {
1654 next = if forward {
1655 (next + 1) % self.devices.len()
1656 } else {
1657 next.checked_sub(1).unwrap_or(self.devices.len() - 1)
1658 };
1659 }
1660
1661 self.comparison_device_index = Some(next);
1662 }
1663
1664 pub fn comparison_device(&self) -> Option<&DeviceState> {
1666 self.comparison_device_index
1667 .and_then(|i| self.devices.get(i))
1668 }
1669}