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 Service,
171}
172
173#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
175pub enum HistoryFilter {
176 #[default]
178 All,
179 Today,
181 Last24Hours,
183 Last7Days,
185 Last30Days,
187}
188
189impl HistoryFilter {
190 pub fn label(&self) -> &'static str {
192 match self {
193 HistoryFilter::All => "All",
194 HistoryFilter::Today => "Today",
195 HistoryFilter::Last24Hours => "24h",
196 HistoryFilter::Last7Days => "7d",
197 HistoryFilter::Last30Days => "30d",
198 }
199 }
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Default)]
204pub enum DeviceFilter {
205 #[default]
207 All,
208 Aranet4Only,
210 RadonOnly,
212 RadiationOnly,
214 ConnectedOnly,
216}
217
218impl DeviceFilter {
219 pub fn label(&self) -> &'static str {
221 match self {
222 Self::All => "All",
223 Self::Aranet4Only => "Aranet4",
224 Self::RadonOnly => "Radon",
225 Self::RadiationOnly => "Radiation",
226 Self::ConnectedOnly => "Connected",
227 }
228 }
229
230 pub fn next(&self) -> Self {
232 match self {
233 Self::All => Self::Aranet4Only,
234 Self::Aranet4Only => Self::RadonOnly,
235 Self::RadonOnly => Self::RadiationOnly,
236 Self::RadiationOnly => Self::ConnectedOnly,
237 Self::ConnectedOnly => Self::All,
238 }
239 }
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum AlertSeverity {
245 Info,
247 Warning,
249 Critical,
251}
252
253impl AlertSeverity {
254 pub fn color(self) -> ratatui::style::Color {
256 match self {
257 Self::Info => ratatui::style::Color::Blue,
258 Self::Warning => ratatui::style::Color::Yellow,
259 Self::Critical => ratatui::style::Color::Red,
260 }
261 }
262
263 pub fn icon(self) -> &'static str {
265 match self {
266 Self::Info => "(i)",
267 Self::Warning => "(!)",
268 Self::Critical => "(X)",
269 }
270 }
271}
272
273#[derive(Debug, Clone)]
275#[allow(dead_code)]
276pub struct Alert {
277 pub device_id: String,
279 pub device_name: Option<String>,
281 pub message: String,
283 pub level: aranet_core::Co2Level,
285 pub triggered_at: Instant,
287 pub severity: AlertSeverity,
289}
290
291#[derive(Debug, Clone)]
293pub struct AlertRecord {
294 pub device_name: String,
296 pub message: String,
298 pub timestamp: time::OffsetDateTime,
300 pub severity: AlertSeverity,
302}
303
304#[derive(Debug, Clone, Default)]
306pub struct SessionStats {
307 pub co2_min: Option<u16>,
309 pub co2_max: Option<u16>,
311 pub co2_sum: u64,
313 pub co2_count: u32,
315 pub temp_min: Option<f32>,
317 pub temp_max: Option<f32>,
319}
320
321impl SessionStats {
322 pub fn update(&mut self, reading: &CurrentReading) {
324 if reading.co2 > 0 {
326 self.co2_min = Some(self.co2_min.map_or(reading.co2, |m| m.min(reading.co2)));
327 self.co2_max = Some(self.co2_max.map_or(reading.co2, |m| m.max(reading.co2)));
328 self.co2_sum += reading.co2 as u64;
329 self.co2_count += 1;
330 }
331
332 self.temp_min = Some(
334 self.temp_min
335 .map_or(reading.temperature, |m| m.min(reading.temperature)),
336 );
337 self.temp_max = Some(
338 self.temp_max
339 .map_or(reading.temperature, |m| m.max(reading.temperature)),
340 );
341 }
342
343 pub fn co2_avg(&self) -> Option<u16> {
345 if self.co2_count > 0 {
346 Some((self.co2_sum / self.co2_count as u64) as u16)
347 } else {
348 None
349 }
350 }
351}
352
353pub fn calculate_radon_averages(history: &[HistoryRecord]) -> (Option<u32>, Option<u32>) {
355 use time::OffsetDateTime;
356
357 let now = OffsetDateTime::now_utc();
358 let day_ago = now - time::Duration::days(1);
359 let week_ago = now - time::Duration::days(7);
360
361 let mut day_sum: u64 = 0;
362 let mut day_count: u32 = 0;
363 let mut week_sum: u64 = 0;
364 let mut week_count: u32 = 0;
365
366 for record in history {
367 if let Some(radon) = record.radon
368 && record.timestamp >= week_ago
369 {
370 week_sum += radon as u64;
371 week_count += 1;
372
373 if record.timestamp >= day_ago {
374 day_sum += radon as u64;
375 day_count += 1;
376 }
377 }
378 }
379
380 let day_avg = if day_count > 0 {
381 Some((day_sum / day_count as u64) as u32)
382 } else {
383 None
384 };
385
386 let week_avg = if week_count > 0 {
387 Some((week_sum / week_count as u64) as u32)
388 } else {
389 None
390 };
391
392 (day_avg, week_avg)
393}
394
395#[derive(Debug, Clone)]
397pub enum PendingAction {
398 Disconnect {
400 device_id: String,
401 device_name: String,
402 },
403}
404
405pub struct App {
407 pub should_quit: bool,
409 pub active_tab: Tab,
411 pub selected_device: usize,
413 pub devices: Vec<DeviceState>,
415 pub scanning: bool,
417 pub status_messages: Vec<(String, Instant)>,
419 pub status_message_timeout: u64,
421 pub show_help: bool,
423 #[allow(dead_code)]
425 pub command_tx: mpsc::Sender<Command>,
426 pub event_rx: mpsc::Receiver<SensorEvent>,
428 pub thresholds: aranet_core::Thresholds,
430 pub alerts: Vec<Alert>,
432 pub alert_history: Vec<AlertRecord>,
434 pub show_alert_history: bool,
436 pub log_file: Option<std::path::PathBuf>,
438 pub logging_enabled: bool,
440 pub last_auto_refresh: Option<Instant>,
442 pub auto_refresh_interval: Duration,
444 pub history_scroll: usize,
446 pub history_filter: HistoryFilter,
448 pub spinner_frame: usize,
450 pub selected_setting: usize,
452 pub interval_options: Vec<u16>,
454 pub co2_alert_threshold: u16,
456 pub radon_alert_threshold: u16,
458 pub bell_enabled: bool,
460 pub device_filter: DeviceFilter,
462 pub pending_confirmation: Option<PendingAction>,
464 pub show_sidebar: bool,
466 pub show_fullscreen_chart: bool,
468 pub editing_alias: bool,
470 pub alias_input: String,
472 pub sticky_alerts: bool,
474 pub last_error: Option<String>,
476 pub show_error_details: bool,
478 pub show_comparison: bool,
480 pub comparison_device_index: Option<usize>,
482 pub sidebar_width: u16,
484 pub theme: Theme,
486 pub chart_metrics: u8,
488 pub smart_home_enabled: bool,
490 pub ble_range: BleRange,
492 pub syncing: bool,
494 #[allow(dead_code)]
497 pub service_client: Option<aranet_core::service_client::ServiceClient>,
498 pub service_url: String,
500 pub service_status: Option<ServiceState>,
502 pub service_refreshing: bool,
504 pub service_selected_item: usize,
506}
507
508#[derive(Debug, Clone)]
510pub struct ServiceState {
511 pub reachable: bool,
513 pub collector_running: bool,
515 #[allow(dead_code)]
517 pub started_at: Option<time::OffsetDateTime>,
518 pub uptime_seconds: Option<u64>,
520 pub devices: Vec<aranet_core::service_client::DeviceCollectionStats>,
522 #[allow(dead_code)]
524 pub fetched_at: Instant,
525}
526
527impl App {
528 pub fn new(command_tx: mpsc::Sender<Command>, event_rx: mpsc::Receiver<SensorEvent>) -> Self {
530 Self {
531 should_quit: false,
532 active_tab: Tab::default(),
533 selected_device: 0,
534 devices: Vec::new(),
535 scanning: false,
536 status_messages: Vec::new(),
537 status_message_timeout: 5, show_help: false,
539 command_tx,
540 event_rx,
541 thresholds: aranet_core::Thresholds::default(),
542 alerts: Vec::new(),
543 alert_history: Vec::new(),
544 show_alert_history: false,
545 log_file: None,
546 logging_enabled: false,
547 last_auto_refresh: None,
548 auto_refresh_interval: Duration::from_secs(60),
549 history_scroll: 0,
550 history_filter: HistoryFilter::default(),
551 spinner_frame: 0,
552 selected_setting: 0,
553 interval_options: vec![60, 120, 300, 600], co2_alert_threshold: 1500,
555 radon_alert_threshold: 300,
556 bell_enabled: true,
557 device_filter: DeviceFilter::default(),
558 pending_confirmation: None,
559 show_sidebar: true,
560 show_fullscreen_chart: false,
561 editing_alias: false,
562 alias_input: String::new(),
563 sticky_alerts: false,
564 last_error: None,
565 show_error_details: false,
566 show_comparison: false,
567 comparison_device_index: None,
568 sidebar_width: 28,
569 theme: Theme::default(),
570 chart_metrics: Self::METRIC_PRIMARY, smart_home_enabled: false,
572 ble_range: BleRange::default(),
573 syncing: false,
574 service_client: aranet_core::service_client::ServiceClient::new(
575 "http://localhost:8080",
576 )
577 .ok(),
578 service_url: "http://localhost:8080".to_string(),
579 service_status: None,
580 service_refreshing: false,
581 service_selected_item: 0,
582 }
583 }
584
585 pub fn toggle_ble_range(&mut self) {
587 self.ble_range = self.ble_range.toggle();
588 self.push_status_message(format!("BLE range: {}", self.ble_range.name()));
589 }
590
591 pub const METRIC_PRIMARY: u8 = 0b001;
593 pub const METRIC_TEMP: u8 = 0b010;
595 pub const METRIC_HUMIDITY: u8 = 0b100;
597
598 pub fn toggle_chart_metric(&mut self, metric: u8) {
600 self.chart_metrics ^= metric;
601 if self.chart_metrics == 0 {
603 self.chart_metrics = Self::METRIC_PRIMARY;
604 }
605 }
606
607 pub fn chart_shows(&self, metric: u8) -> bool {
609 self.chart_metrics & metric != 0
610 }
611
612 pub fn toggle_theme(&mut self) {
614 self.theme = match self.theme {
615 Theme::Dark => Theme::Light,
616 Theme::Light => Theme::Dark,
617 };
618 }
619
620 #[must_use]
622 pub fn app_theme(&self) -> super::ui::theme::AppTheme {
623 match self.theme {
624 Theme::Dark => super::ui::theme::AppTheme::dark(),
625 Theme::Light => super::ui::theme::AppTheme::light(),
626 }
627 }
628
629 pub fn toggle_smart_home(&mut self) {
631 self.smart_home_enabled = !self.smart_home_enabled;
632 let status = if self.smart_home_enabled {
633 "enabled"
634 } else {
635 "disabled"
636 };
637 self.push_status_message(format!("Smart Home mode {}", status));
638 }
639
640 pub fn toggle_fullscreen_chart(&mut self) {
642 self.show_fullscreen_chart = !self.show_fullscreen_chart;
643 }
644
645 pub fn should_quit(&self) -> bool {
647 self.should_quit
648 }
649
650 pub fn push_status_message(&mut self, message: String) {
652 self.status_messages.push((message, Instant::now()));
653 while self.status_messages.len() > 5 {
655 self.status_messages.remove(0);
656 }
657 }
658
659 pub fn clean_expired_messages(&mut self) {
661 let timeout = std::time::Duration::from_secs(self.status_message_timeout);
662 self.status_messages
663 .retain(|(_, created)| created.elapsed() < timeout);
664 }
665
666 pub fn current_status_message(&self) -> Option<&str> {
668 self.status_messages.last().map(|(msg, _)| msg.as_str())
669 }
670
671 pub fn handle_sensor_event(&mut self, event: SensorEvent) -> Vec<Command> {
675 let mut commands = Vec::new();
676
677 match event {
678 SensorEvent::CachedDataLoaded { devices } => {
679 let device_ids: Vec<String> = devices.iter().map(|d| d.id.clone()).collect();
681 self.handle_cached_data(devices);
682
683 for device_id in device_ids {
685 commands.push(Command::Connect { device_id });
686 }
687 }
688 SensorEvent::ScanStarted => {
689 self.scanning = true;
690 self.push_status_message("Scanning for devices...".to_string());
691 }
692 SensorEvent::ScanComplete { devices } => {
693 self.scanning = false;
694 self.push_status_message(format!("Found {} device(s)", devices.len()));
695 for discovered in devices {
697 let id_str = discovered.id.to_string();
698 if !self.devices.iter().any(|d| d.id == id_str) {
699 let mut device = DeviceState::new(id_str);
700 device.name = discovered.name;
701 device.device_type = discovered.device_type;
702 self.devices.push(device);
703 }
704 }
705 }
706 SensorEvent::ScanError { error } => {
707 self.scanning = false;
708 let error_msg = format!("Scan: {}", error);
709 self.set_error(error_msg);
710 self.push_status_message(format!(
711 "Scan error: {} (press E for details)",
712 error.chars().take(40).collect::<String>()
713 ));
714 }
715 SensorEvent::DeviceConnecting { device_id } => {
716 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
717 device.status = ConnectionStatus::Connecting;
718 device.last_updated = Some(Instant::now());
719 }
720 self.push_status_message("Connecting...".to_string());
721 }
722 SensorEvent::DeviceConnected {
723 device_id,
724 name,
725 device_type,
726 rssi,
727 } => {
728 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
729 device.status = ConnectionStatus::Connected;
730 device.name = name.or(device.name.take());
731 device.device_type = device_type.or(device.device_type);
732 device.rssi = rssi;
733 device.last_updated = Some(Instant::now());
734 device.error = None;
735 device.connected_at = Some(Instant::now());
736 }
737 self.push_status_message("Connected".to_string());
738
739 commands.push(Command::SyncHistory {
741 device_id: device_id.clone(),
742 });
743 }
744 SensorEvent::DeviceDisconnected { device_id } => {
745 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
746 device.status = ConnectionStatus::Disconnected;
747 device.last_updated = Some(Instant::now());
748 device.connected_at = None;
749 }
750 }
751 SensorEvent::ConnectionError { device_id, error } => {
752 let device_name = self
753 .devices
754 .iter()
755 .find(|d| d.id == device_id)
756 .map(|d| d.display_name().to_string())
757 .unwrap_or_else(|| device_id.clone());
758 let error_msg = format!("{}: {}", device_name, error);
759 self.set_error(error_msg);
760 self.push_status_message(format!(
761 "Connection error: {} (press E for details)",
762 error.chars().take(40).collect::<String>()
763 ));
764 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
765 device.status = ConnectionStatus::Error(error.clone());
766 device.error = Some(error);
767 device.last_updated = Some(Instant::now());
768 }
769 }
770 SensorEvent::ReadingUpdated { device_id, reading } => {
771 self.check_thresholds(&device_id, &reading);
773
774 self.log_reading(&device_id, &reading);
776
777 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
778 device.session_stats.update(&reading);
780 device.previous_reading = device.reading.take();
782 device.reading = Some(reading);
783 device.last_updated = Some(Instant::now());
784 device.error = None;
785 }
786 }
787 SensorEvent::ReadingError { device_id, error } => {
788 let device_name = self
789 .devices
790 .iter()
791 .find(|d| d.id == device_id)
792 .map(|d| d.display_name().to_string())
793 .unwrap_or_else(|| device_id.clone());
794 let error_msg = format!("{}: {}", device_name, error);
795 self.set_error(error_msg);
796 self.push_status_message(format!(
797 "Reading error: {} (press E for details)",
798 error.chars().take(40).collect::<String>()
799 ));
800 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
801 device.error = Some(error);
802 device.last_updated = Some(Instant::now());
803 }
804 }
805 SensorEvent::HistoryLoaded { device_id, records } => {
806 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
807 device.history = records;
808 device.last_updated = Some(Instant::now());
809 }
810 }
811 SensorEvent::HistorySyncStarted { device_id } => {
812 self.syncing = true;
813 self.push_status_message(format!("Syncing history for {}...", device_id));
814 }
815 SensorEvent::HistorySynced { device_id, count } => {
816 self.syncing = false;
817 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
818 device.last_sync = Some(time::OffsetDateTime::now_utc());
819 }
820 self.push_status_message(format!("Synced {} records for {}", count, device_id));
821 }
822 SensorEvent::HistorySyncError { device_id, error } => {
823 self.syncing = false;
824 let device_name = self
825 .devices
826 .iter()
827 .find(|d| d.id == device_id)
828 .map(|d| d.display_name().to_string())
829 .unwrap_or_else(|| device_id.clone());
830 let error_msg = format!("{}: {}", device_name, error);
831 self.set_error(error_msg);
832 self.push_status_message(format!(
833 "History sync failed: {} (press E for details)",
834 error.chars().take(40).collect::<String>()
835 ));
836 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
837 device.error = Some(error);
838 }
839 }
840 SensorEvent::IntervalChanged {
841 device_id,
842 interval_secs,
843 } => {
844 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id)
845 && let Some(reading) = &mut device.reading
846 {
847 reading.interval = interval_secs;
848 }
849 self.push_status_message(format!("Interval set to {}m", interval_secs / 60));
850 }
851 SensorEvent::IntervalError { device_id, error } => {
852 let device_name = self
853 .devices
854 .iter()
855 .find(|d| d.id == device_id)
856 .map(|d| d.display_name().to_string())
857 .unwrap_or_else(|| device_id.clone());
858 let error_msg = format!("{}: {}", device_name, error);
859 self.set_error(error_msg);
860 self.push_status_message(format!(
861 "Set interval failed: {} (press E for details)",
862 error.chars().take(40).collect::<String>()
863 ));
864 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
865 device.error = Some(error);
866 }
867 }
868 SensorEvent::SettingsLoaded {
869 device_id,
870 settings,
871 } => {
872 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
873 device.settings = Some(settings);
874 device.last_updated = Some(Instant::now());
875 }
876 }
877 SensorEvent::BluetoothRangeChanged {
878 device_id: _,
879 extended,
880 } => {
881 let range = if extended { "Extended" } else { "Standard" };
882 self.push_status_message(format!("Bluetooth range set to {}", range));
883 }
884 SensorEvent::BluetoothRangeError { device_id, error } => {
885 let device_name = self
886 .devices
887 .iter()
888 .find(|d| d.id == device_id)
889 .and_then(|d| d.name.clone())
890 .unwrap_or_else(|| device_id.clone());
891 let error_msg = format!("{}: {}", device_name, error);
892 self.set_error(error_msg);
893 self.push_status_message(format!(
894 "Set BT range failed: {} (press E for details)",
895 error.chars().take(40).collect::<String>()
896 ));
897 }
898 SensorEvent::SmartHomeChanged {
899 device_id: _,
900 enabled,
901 } => {
902 let mode = if enabled { "enabled" } else { "disabled" };
903 self.push_status_message(format!("Smart Home {}", mode));
904 }
905 SensorEvent::SmartHomeError { device_id, error } => {
906 let device_name = self
907 .devices
908 .iter()
909 .find(|d| d.id == device_id)
910 .and_then(|d| d.name.clone())
911 .unwrap_or_else(|| device_id.clone());
912 let error_msg = format!("{}: {}", device_name, error);
913 self.set_error(error_msg);
914 self.push_status_message(format!(
915 "Set Smart Home failed: {} (press E for details)",
916 error.chars().take(40).collect::<String>()
917 ));
918 }
919 SensorEvent::ServiceStatusRefreshed {
920 reachable,
921 collector_running,
922 uptime_seconds,
923 devices,
924 } => {
925 self.service_refreshing = false;
926 self.service_status = Some(ServiceState {
927 reachable,
928 collector_running,
929 started_at: None, uptime_seconds,
931 devices: devices
932 .into_iter()
933 .map(|d| aranet_core::service_client::DeviceCollectionStats {
934 device_id: d.device_id,
935 alias: d.alias,
936 poll_interval: d.poll_interval,
937 polling: d.polling,
938 success_count: d.success_count,
939 failure_count: d.failure_count,
940 last_poll_at: d.last_poll_at,
941 last_error_at: None, last_error: d.last_error,
943 })
944 .collect(),
945 fetched_at: Instant::now(),
946 });
947 if reachable {
948 let status = if collector_running {
949 "running"
950 } else {
951 "stopped"
952 };
953 self.push_status_message(format!("Service collector: {}", status));
954 } else {
955 self.push_status_message("Service not reachable".to_string());
956 }
957 }
958 SensorEvent::ServiceStatusError { error } => {
959 self.service_refreshing = false;
960 self.push_status_message(format!("Service error: {}", error));
961 }
962 SensorEvent::ServiceCollectorStarted => {
963 self.push_status_message("Collector started".to_string());
964 }
965 SensorEvent::ServiceCollectorStopped => {
966 self.push_status_message("Collector stopped".to_string());
967 }
968 SensorEvent::ServiceCollectorError { error } => {
969 self.push_status_message(format!("Collector error: {}", error));
970 }
971 SensorEvent::AliasChanged { device_id, alias } => {
972 if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
973 device.name = alias;
974 }
975 self.push_status_message("Device renamed".to_string());
976 }
977 SensorEvent::AliasError {
978 device_id: _,
979 error,
980 } => {
981 self.push_status_message(format!("Rename failed: {}", error));
982 }
983 SensorEvent::DeviceForgotten { device_id } => {
984 if let Some(pos) = self.devices.iter().position(|d| d.id == device_id) {
985 self.devices.remove(pos);
986 if self.selected_device >= self.devices.len() && !self.devices.is_empty() {
987 self.selected_device = self.devices.len() - 1;
988 }
989 }
990 self.push_status_message("Device forgotten".to_string());
991 }
992 SensorEvent::ForgetDeviceError {
993 device_id: _,
994 error,
995 } => {
996 self.push_status_message(format!("Forget failed: {}", error));
997 }
998 }
999
1000 commands
1001 }
1002
1003 pub fn selected_device(&self) -> Option<&DeviceState> {
1005 self.devices.get(self.selected_device)
1006 }
1007
1008 pub fn select_next_device(&mut self) {
1010 if !self.devices.is_empty() {
1011 self.selected_device = (self.selected_device + 1) % self.devices.len();
1012 self.reset_history_scroll();
1013 }
1014 }
1015
1016 pub fn select_previous_device(&mut self) {
1018 if !self.devices.is_empty() {
1019 self.selected_device = self
1020 .selected_device
1021 .checked_sub(1)
1022 .unwrap_or(self.devices.len() - 1);
1023 self.reset_history_scroll();
1024 }
1025 }
1026
1027 pub fn scroll_history_up(&mut self) {
1029 self.history_scroll = self.history_scroll.saturating_sub(5);
1030 }
1031
1032 pub fn scroll_history_down(&mut self) {
1034 if let Some(device) = self.selected_device() {
1035 let max_scroll = device.history.len().saturating_sub(10);
1036 self.history_scroll = (self.history_scroll + 5).min(max_scroll);
1037 }
1038 }
1039
1040 pub fn reset_history_scroll(&mut self) {
1042 self.history_scroll = 0;
1043 }
1044
1045 pub fn tick_spinner(&mut self) {
1047 self.spinner_frame = (self.spinner_frame + 1) % 10;
1048 }
1049
1050 pub fn spinner_char(&self) -> &'static str {
1052 const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1053 SPINNER[self.spinner_frame]
1054 }
1055
1056 pub fn set_history_filter(&mut self, filter: HistoryFilter) {
1058 self.history_filter = filter;
1059 self.history_scroll = 0; }
1061
1062 pub fn filtered_devices(&self) -> Vec<&DeviceState> {
1064 self.devices
1065 .iter()
1066 .filter(|d| match self.device_filter {
1067 DeviceFilter::All => true,
1068 DeviceFilter::Aranet4Only => {
1069 matches!(d.device_type, Some(DeviceType::Aranet4))
1070 }
1071 DeviceFilter::RadonOnly => {
1072 matches!(d.device_type, Some(DeviceType::AranetRadon))
1073 }
1074 DeviceFilter::RadiationOnly => {
1075 matches!(d.device_type, Some(DeviceType::AranetRadiation))
1076 }
1077 DeviceFilter::ConnectedOnly => {
1078 matches!(d.status, ConnectionStatus::Connected)
1079 }
1080 })
1081 .collect()
1082 }
1083
1084 pub fn cycle_device_filter(&mut self) {
1086 self.device_filter = self.device_filter.next();
1087 self.push_status_message(format!("Filter: {}", self.device_filter.label()));
1088 }
1089
1090 pub fn select_next_setting(&mut self) {
1092 self.selected_setting = (self.selected_setting + 1) % 3; }
1094
1095 pub fn select_previous_setting(&mut self) {
1097 self.selected_setting = self.selected_setting.checked_sub(1).unwrap_or(2);
1098 }
1099
1100 pub fn increase_co2_threshold(&mut self) {
1102 self.co2_alert_threshold = (self.co2_alert_threshold + 100).min(3000);
1103 }
1104
1105 pub fn decrease_co2_threshold(&mut self) {
1107 self.co2_alert_threshold = self.co2_alert_threshold.saturating_sub(100).max(500);
1108 }
1109
1110 pub fn increase_radon_threshold(&mut self) {
1112 self.radon_alert_threshold = (self.radon_alert_threshold + 50).min(1000);
1113 }
1114
1115 pub fn decrease_radon_threshold(&mut self) {
1117 self.radon_alert_threshold = self.radon_alert_threshold.saturating_sub(50).max(100);
1118 }
1119
1120 pub fn cycle_interval(&mut self) -> Option<(String, u16)> {
1122 let device = self.selected_device()?;
1123 let reading = device.reading.as_ref()?;
1124 let current_idx = self
1125 .interval_options
1126 .iter()
1127 .position(|&i| i == reading.interval)
1128 .unwrap_or(0);
1129 let next_idx = (current_idx + 1) % self.interval_options.len();
1130 let new_interval = self.interval_options[next_idx];
1131 Some((device.id.clone(), new_interval))
1132 }
1133
1134 fn handle_cached_data(&mut self, cached_devices: Vec<CachedDevice>) {
1136 let count = cached_devices.len();
1137 if count > 0 {
1138 self.push_status_message(format!("Loaded {} cached device(s)", count));
1139 }
1140
1141 for cached in cached_devices {
1142 if let Some(device) = self.devices.iter_mut().find(|d| d.id == cached.id) {
1144 if device.reading.is_none() {
1146 device.reading = cached.reading;
1147 }
1148 if device.name.is_none() {
1149 device.name = cached.name;
1150 }
1151 if device.device_type.is_none() {
1152 device.device_type = cached.device_type;
1153 }
1154 if device.last_sync.is_none() {
1156 device.last_sync = cached.last_sync;
1157 }
1158 } else {
1159 let mut device = DeviceState::new(cached.id);
1161 device.name = cached.name;
1162 device.device_type = cached.device_type;
1163 device.reading = cached.reading;
1164 device.last_sync = cached.last_sync;
1165 device.status = ConnectionStatus::Disconnected;
1167 self.devices.push(device);
1168 }
1169 }
1170 }
1171
1172 pub fn check_thresholds(&mut self, device_id: &str, reading: &CurrentReading) {
1174 if reading.co2 > 0 && reading.co2 >= self.co2_alert_threshold {
1176 let level = self.thresholds.evaluate_co2(reading.co2);
1177
1178 let severity = if reading.co2 >= self.co2_alert_threshold * 2 {
1180 AlertSeverity::Critical
1181 } else if reading.co2 >= (self.co2_alert_threshold * 3) / 2 {
1182 AlertSeverity::Warning
1183 } else {
1184 AlertSeverity::Info
1185 };
1186
1187 if !self
1189 .alerts
1190 .iter()
1191 .any(|a| a.device_id == device_id && a.message.contains("CO2"))
1192 {
1193 let device_name = self
1194 .devices
1195 .iter()
1196 .find(|d| d.id == device_id)
1197 .and_then(|d| d.name.clone());
1198
1199 let message = format!("CO2 at {} ppm - {}", reading.co2, level.action());
1200
1201 self.alerts.push(Alert {
1202 device_id: device_id.to_string(),
1203 device_name: device_name.clone(),
1204 message: message.clone(),
1205 level,
1206 triggered_at: Instant::now(),
1207 severity,
1208 });
1209
1210 self.alert_history.push(AlertRecord {
1212 device_name: device_name.unwrap_or_else(|| device_id.to_string()),
1213 message,
1214 timestamp: time::OffsetDateTime::now_utc(),
1215 severity,
1216 });
1217
1218 while self.alert_history.len() > MAX_ALERT_HISTORY {
1220 self.alert_history.remove(0);
1221 }
1222
1223 if self.bell_enabled {
1225 print!("\x07"); use std::io::Write;
1227 std::io::stdout().flush().ok();
1228 }
1229 }
1230 } else if reading.co2 > 0 && !self.sticky_alerts {
1231 self.alerts
1233 .retain(|a| !(a.device_id == device_id && a.message.contains("CO2")));
1234 }
1235
1236 if reading.battery > 0 && reading.battery < 20 {
1238 let has_battery_alert = self
1240 .alerts
1241 .iter()
1242 .any(|a| a.device_id == device_id && a.message.contains("Battery"));
1243
1244 if !has_battery_alert {
1245 let device_name = self
1246 .devices
1247 .iter()
1248 .find(|d| d.id == device_id)
1249 .and_then(|d| d.name.clone());
1250
1251 let (message, severity) = if reading.battery < 10 {
1253 (
1254 format!("Battery critically low: {}%", reading.battery),
1255 AlertSeverity::Critical,
1256 )
1257 } else {
1258 (
1259 format!("Battery low: {}%", reading.battery),
1260 AlertSeverity::Warning,
1261 )
1262 };
1263
1264 self.alerts.push(Alert {
1265 device_id: device_id.to_string(),
1266 device_name: device_name.clone(),
1267 message: message.clone(),
1268 level: aranet_core::Co2Level::Good, triggered_at: Instant::now(),
1270 severity,
1271 });
1272
1273 self.alert_history.push(AlertRecord {
1275 device_name: device_name.unwrap_or_else(|| device_id.to_string()),
1276 message,
1277 timestamp: time::OffsetDateTime::now_utc(),
1278 severity,
1279 });
1280
1281 while self.alert_history.len() > MAX_ALERT_HISTORY {
1283 self.alert_history.remove(0);
1284 }
1285
1286 if self.bell_enabled {
1288 print!("\x07"); use std::io::Write;
1290 std::io::stdout().flush().ok();
1291 }
1292 }
1293 } else if reading.battery >= 20 && !self.sticky_alerts {
1294 self.alerts
1296 .retain(|a| !(a.device_id == device_id && a.message.contains("Battery")));
1297 }
1298
1299 if let Some(radon) = reading.radon {
1301 if radon >= self.radon_alert_threshold as u32 {
1302 let has_radon_alert = self
1304 .alerts
1305 .iter()
1306 .any(|a| a.device_id == device_id && a.message.contains("Radon"));
1307
1308 if !has_radon_alert {
1309 let device_name = self
1310 .devices
1311 .iter()
1312 .find(|d| d.id == device_id)
1313 .and_then(|d| d.name.clone());
1314
1315 let severity = if radon >= (self.radon_alert_threshold as u32) * 2 {
1317 AlertSeverity::Critical
1318 } else {
1319 AlertSeverity::Warning
1320 };
1321
1322 let message = format!("Radon high: {} Bq/m³", radon);
1323
1324 self.alerts.push(Alert {
1325 device_id: device_id.to_string(),
1326 device_name: device_name.clone(),
1327 message: message.clone(),
1328 level: aranet_core::Co2Level::Good, triggered_at: Instant::now(),
1330 severity,
1331 });
1332
1333 self.alert_history.push(AlertRecord {
1335 device_name: device_name.unwrap_or_else(|| device_id.to_string()),
1336 message,
1337 timestamp: time::OffsetDateTime::now_utc(),
1338 severity,
1339 });
1340
1341 while self.alert_history.len() > MAX_ALERT_HISTORY {
1343 self.alert_history.remove(0);
1344 }
1345
1346 if self.bell_enabled {
1348 print!("\x07"); use std::io::Write;
1350 std::io::stdout().flush().ok();
1351 }
1352 }
1353 } else if !self.sticky_alerts {
1354 self.alerts
1356 .retain(|a| !(a.device_id == device_id && a.message.contains("Radon")));
1357 }
1358 }
1359 }
1360
1361 pub fn dismiss_alert(&mut self, device_id: &str) {
1363 self.alerts.retain(|a| a.device_id != device_id);
1364 }
1365
1366 pub fn toggle_alert_history(&mut self) {
1368 self.show_alert_history = !self.show_alert_history;
1369 }
1370
1371 pub fn toggle_sticky_alerts(&mut self) {
1373 self.sticky_alerts = !self.sticky_alerts;
1374 self.push_status_message(format!(
1375 "Sticky alerts {}",
1376 if self.sticky_alerts {
1377 "enabled"
1378 } else {
1379 "disabled"
1380 }
1381 ));
1382 }
1383
1384 pub fn toggle_logging(&mut self) {
1386 if self.logging_enabled {
1387 self.logging_enabled = false;
1388 self.push_status_message("Logging disabled".to_string());
1389 } else {
1390 let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
1392 let log_dir = dirs::data_local_dir()
1393 .unwrap_or_else(|| std::path::PathBuf::from("."))
1394 .join("aranet")
1395 .join("logs");
1396
1397 if let Err(e) = std::fs::create_dir_all(&log_dir) {
1399 self.push_status_message(format!("Failed to create log dir: {}", e));
1400 return;
1401 }
1402
1403 let log_path = log_dir.join(format!("readings_{}.csv", timestamp));
1404 self.log_file = Some(log_path.clone());
1405 self.logging_enabled = true;
1406 self.push_status_message(format!("Logging to {}", log_path.display()));
1407 }
1408 }
1409
1410 pub fn log_reading(&self, device_id: &str, reading: &CurrentReading) {
1412 if !self.logging_enabled {
1413 return;
1414 }
1415
1416 let Some(log_path) = &self.log_file else {
1417 return;
1418 };
1419
1420 use std::io::Write;
1421
1422 let file_exists = log_path.exists();
1423 let file = match std::fs::OpenOptions::new()
1424 .create(true)
1425 .append(true)
1426 .open(log_path)
1427 {
1428 Ok(f) => f,
1429 Err(_) => return,
1430 };
1431
1432 let mut writer = std::io::BufWriter::new(file);
1433
1434 if !file_exists {
1436 let _ = writeln!(
1437 writer,
1438 "timestamp,device_id,co2,temperature,humidity,pressure,battery,status,radon,radiation_rate"
1439 );
1440 }
1441
1442 let timestamp = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S");
1443 let radon = reading.radon.map(|r| r.to_string()).unwrap_or_default();
1444 let radiation = reading
1445 .radiation_rate
1446 .map(|r| format!("{:.3}", r))
1447 .unwrap_or_default();
1448
1449 let _ = writeln!(
1450 writer,
1451 "{},{},{},{:.1},{},{:.1},{},{:?},{},{}",
1452 timestamp,
1453 device_id,
1454 reading.co2,
1455 reading.temperature,
1456 reading.humidity,
1457 reading.pressure,
1458 reading.battery,
1459 reading.status,
1460 radon,
1461 radiation
1462 );
1463 }
1464
1465 pub fn export_history(&self) -> Option<String> {
1467 use std::io::Write;
1468
1469 let device = self.selected_device()?;
1470 if device.history.is_empty() {
1471 return None;
1472 }
1473
1474 let filtered: Vec<_> = device
1476 .history
1477 .iter()
1478 .filter(|r| self.filter_matches_record(r))
1479 .collect();
1480
1481 if filtered.is_empty() {
1482 return None;
1483 }
1484
1485 let export_dir = dirs::data_local_dir()
1487 .unwrap_or_else(|| std::path::PathBuf::from("."))
1488 .join("aranet")
1489 .join("exports");
1490 std::fs::create_dir_all(&export_dir).ok()?;
1491
1492 let now =
1494 time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
1495 let filename = format!(
1496 "history_{}_{}.csv",
1497 device
1498 .name
1499 .as_deref()
1500 .unwrap_or(&device.id)
1501 .replace(" ", "_"),
1502 now.format(
1503 &time::format_description::parse("[year][month][day]_[hour][minute][second]")
1504 .unwrap()
1505 )
1506 .unwrap_or_default()
1507 );
1508 let path = export_dir.join(&filename);
1509
1510 let mut file = std::fs::File::create(&path).ok()?;
1512
1513 writeln!(
1515 file,
1516 "timestamp,co2,temperature,humidity,pressure,radon,radiation_rate"
1517 )
1518 .ok()?;
1519
1520 for record in filtered {
1522 writeln!(
1523 file,
1524 "{},{},{:.1},{},{:.1},{},{}",
1525 record
1526 .timestamp
1527 .format(&time::format_description::well_known::Rfc3339)
1528 .unwrap_or_default(),
1529 record.co2,
1530 record.temperature,
1531 record.humidity,
1532 record.pressure,
1533 record.radon.map(|v| v.to_string()).unwrap_or_default(),
1534 record
1535 .radiation_rate
1536 .map(|v| format!("{:.3}", v))
1537 .unwrap_or_default(),
1538 )
1539 .ok()?;
1540 }
1541
1542 Some(path.to_string_lossy().to_string())
1543 }
1544
1545 fn filter_matches_record(&self, record: &HistoryRecord) -> bool {
1547 use time::OffsetDateTime;
1548
1549 match self.history_filter {
1550 HistoryFilter::All => true,
1551 HistoryFilter::Today => {
1552 let now = OffsetDateTime::now_utc();
1553 record.timestamp.date() == now.date()
1554 }
1555 HistoryFilter::Last24Hours => {
1556 let cutoff = OffsetDateTime::now_utc() - time::Duration::hours(24);
1557 record.timestamp >= cutoff
1558 }
1559 HistoryFilter::Last7Days => {
1560 let cutoff = OffsetDateTime::now_utc() - time::Duration::days(7);
1561 record.timestamp >= cutoff
1562 }
1563 HistoryFilter::Last30Days => {
1564 let cutoff = OffsetDateTime::now_utc() - time::Duration::days(30);
1565 record.timestamp >= cutoff
1566 }
1567 }
1568 }
1569
1570 pub fn check_auto_refresh(&mut self) -> Vec<String> {
1572 let now = Instant::now();
1573
1574 let interval = self
1577 .devices
1578 .iter()
1579 .find(|d| d.status == ConnectionStatus::Connected)
1580 .and_then(|d| d.reading.as_ref())
1581 .map(|r| Duration::from_secs(r.interval as u64))
1582 .unwrap_or(Duration::from_secs(60));
1583
1584 self.auto_refresh_interval = interval;
1585
1586 let should_refresh = match self.last_auto_refresh {
1588 Some(last) => now.duration_since(last) >= interval,
1589 None => true, };
1591
1592 if should_refresh {
1593 self.last_auto_refresh = Some(now);
1594 self.devices
1596 .iter()
1597 .filter(|d| d.status == ConnectionStatus::Connected)
1598 .map(|d| d.id.clone())
1599 .collect()
1600 } else {
1601 Vec::new()
1602 }
1603 }
1604
1605 pub fn request_confirmation(&mut self, action: PendingAction) {
1607 self.pending_confirmation = Some(action);
1608 }
1609
1610 pub fn confirm_action(&mut self) -> Option<Command> {
1612 if let Some(action) = self.pending_confirmation.take() {
1613 match action {
1614 PendingAction::Disconnect { device_id, .. } => {
1615 return Some(Command::Disconnect { device_id });
1616 }
1617 }
1618 }
1619 None
1620 }
1621
1622 pub fn cancel_confirmation(&mut self) {
1624 self.pending_confirmation = None;
1625 self.push_status_message("Cancelled".to_string());
1626 }
1627
1628 pub fn toggle_sidebar(&mut self) {
1630 self.show_sidebar = !self.show_sidebar;
1631 }
1632
1633 pub fn toggle_sidebar_width(&mut self) {
1635 self.sidebar_width = if self.sidebar_width == 28 { 40 } else { 28 };
1636 }
1637
1638 pub fn start_alias_edit(&mut self) {
1640 if let Some(device) = self.selected_device() {
1641 self.alias_input = device
1642 .alias
1643 .clone()
1644 .or_else(|| device.name.clone())
1645 .unwrap_or_default();
1646 self.editing_alias = true;
1647 }
1648 }
1649
1650 pub fn cancel_alias_edit(&mut self) {
1652 self.editing_alias = false;
1653 self.alias_input.clear();
1654 }
1655
1656 pub fn save_alias(&mut self) {
1658 let display_name = if let Some(device) = self.devices.get_mut(self.selected_device) {
1659 if self.alias_input.trim().is_empty() {
1660 device.alias = None;
1661 } else {
1662 device.alias = Some(self.alias_input.trim().to_string());
1663 }
1664 Some(device.display_name().to_string())
1665 } else {
1666 None
1667 };
1668 if let Some(name) = display_name {
1669 self.push_status_message(format!("Alias set: {}", name));
1670 }
1671 self.editing_alias = false;
1672 self.alias_input.clear();
1673 }
1674
1675 pub fn alias_input_char(&mut self, c: char) {
1677 if self.alias_input.len() < 20 {
1678 self.alias_input.push(c);
1679 }
1680 }
1681
1682 pub fn alias_input_backspace(&mut self) {
1684 self.alias_input.pop();
1685 }
1686
1687 pub fn set_error(&mut self, error: String) {
1689 self.last_error = Some(error);
1690 }
1691
1692 pub fn toggle_error_details(&mut self) {
1694 if self.last_error.is_some() {
1695 self.show_error_details = !self.show_error_details;
1696 } else {
1697 self.push_status_message("No error to display".to_string());
1698 }
1699 }
1700
1701 pub fn average_co2(&self) -> Option<u16> {
1703 let values: Vec<u16> = self
1704 .devices
1705 .iter()
1706 .filter(|d| matches!(d.status, ConnectionStatus::Connected))
1707 .filter_map(|d| d.reading.as_ref())
1708 .filter_map(|r| if r.co2 > 0 { Some(r.co2) } else { None })
1709 .collect();
1710
1711 if values.is_empty() {
1712 None
1713 } else {
1714 Some((values.iter().map(|&v| v as u32).sum::<u32>() / values.len() as u32) as u16)
1715 }
1716 }
1717
1718 pub fn connected_count(&self) -> usize {
1720 self.devices
1721 .iter()
1722 .filter(|d| matches!(d.status, ConnectionStatus::Connected))
1723 .count()
1724 }
1725
1726 pub fn is_any_connecting(&self) -> bool {
1728 self.devices
1729 .iter()
1730 .any(|d| matches!(d.status, ConnectionStatus::Connecting))
1731 }
1732
1733 pub fn is_syncing(&self) -> bool {
1735 self.syncing
1736 }
1737
1738 pub fn toggle_comparison(&mut self) {
1740 if self.devices.len() < 2 {
1741 self.push_status_message("Need at least 2 devices for comparison".to_string());
1742 return;
1743 }
1744
1745 self.show_comparison = !self.show_comparison;
1746
1747 if self.show_comparison {
1748 let next = (self.selected_device + 1) % self.devices.len();
1750 self.comparison_device_index = Some(next);
1751 self.push_status_message(
1752 "Comparison view: use </> to change second device".to_string(),
1753 );
1754 } else {
1755 self.comparison_device_index = None;
1756 }
1757 }
1758
1759 pub fn cycle_comparison_device(&mut self, forward: bool) {
1761 if !self.show_comparison || self.devices.len() < 2 {
1762 return;
1763 }
1764
1765 let current = self.comparison_device_index.unwrap_or(0);
1766 let mut next = if forward {
1767 (current + 1) % self.devices.len()
1768 } else {
1769 current.checked_sub(1).unwrap_or(self.devices.len() - 1)
1770 };
1771
1772 if next == self.selected_device {
1774 next = if forward {
1775 (next + 1) % self.devices.len()
1776 } else {
1777 next.checked_sub(1).unwrap_or(self.devices.len() - 1)
1778 };
1779 }
1780
1781 self.comparison_device_index = Some(next);
1782 }
1783
1784 pub fn comparison_device(&self) -> Option<&DeviceState> {
1786 self.comparison_device_index
1787 .and_then(|i| self.devices.get(i))
1788 }
1789}