use std::collections::VecDeque;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use aranet_core::settings::DeviceSettings;
use aranet_types::{CurrentReading, DeviceType, HistoryRecord};
use super::messages::{CachedDevice, Command, SensorEvent};
const MAX_ALERT_HISTORY: usize = 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BleRange {
#[default]
Standard,
Extended,
}
impl BleRange {
pub fn name(self) -> &'static str {
match self {
Self::Standard => "Standard",
Self::Extended => "Extended",
}
}
pub fn toggle(self) -> Self {
match self {
Self::Standard => Self::Extended,
Self::Extended => Self::Standard,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Theme {
#[default]
Dark,
Light,
}
impl Theme {
pub fn bg(self) -> ratatui::style::Color {
match self {
Self::Dark => ratatui::style::Color::Reset,
Self::Light => ratatui::style::Color::White,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum ConnectionStatus {
#[default]
Disconnected,
Connecting,
Connected,
Error(String),
}
#[derive(Debug, Clone)]
pub struct DeviceState {
pub id: String,
pub name: Option<String>,
pub alias: Option<String>,
pub device_type: Option<DeviceType>,
pub reading: Option<CurrentReading>,
pub history: Vec<HistoryRecord>,
pub status: ConnectionStatus,
pub last_updated: Option<Instant>,
pub error: Option<String>,
pub previous_reading: Option<CurrentReading>,
pub session_stats: SessionStats,
pub last_sync: Option<time::OffsetDateTime>,
pub rssi: Option<i16>,
pub connected_at: Option<std::time::Instant>,
pub settings: Option<DeviceSettings>,
}
impl DeviceState {
pub fn new(id: String) -> Self {
Self {
id,
name: None,
alias: None,
device_type: None,
reading: None,
history: Vec::new(),
status: ConnectionStatus::Disconnected,
last_updated: None,
error: None,
previous_reading: None,
session_stats: SessionStats::default(),
last_sync: None,
rssi: None,
connected_at: None,
settings: None,
}
}
pub fn display_name(&self) -> &str {
self.alias
.as_deref()
.or(self.name.as_deref())
.unwrap_or(&self.id)
}
pub fn uptime(&self) -> Option<String> {
let connected_at = self.connected_at?;
let elapsed = connected_at.elapsed();
let secs = elapsed.as_secs();
if secs < 60 {
Some(format!("{}s", secs))
} else if secs < 3600 {
Some(format!("{}m {}s", secs / 60, secs % 60))
} else {
let hours = secs / 3600;
let mins = (secs % 3600) / 60;
Some(format!("{}h {}m", hours, mins))
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Tab {
#[default]
Dashboard,
History,
Settings,
Service,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum HistoryFilter {
#[default]
All,
Today,
Last24Hours,
Last7Days,
Last30Days,
#[allow(dead_code)]
Custom {
start: Option<time::Date>,
end: Option<time::Date>,
},
}
impl HistoryFilter {
pub fn label(&self) -> &'static str {
match self {
HistoryFilter::All => "All",
HistoryFilter::Today => "Today",
HistoryFilter::Last24Hours => "24h",
HistoryFilter::Last7Days => "7d",
HistoryFilter::Last30Days => "30d",
HistoryFilter::Custom { .. } => "Custom",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ExportFormat {
#[default]
Csv,
Json,
}
impl ExportFormat {
pub fn extension(&self) -> &'static str {
match self {
ExportFormat::Csv => "csv",
ExportFormat::Json => "json",
}
}
pub fn toggle(&self) -> Self {
match self {
ExportFormat::Csv => ExportFormat::Json,
ExportFormat::Json => ExportFormat::Csv,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum DeviceFilter {
#[default]
All,
Aranet4Only,
RadonOnly,
RadiationOnly,
ConnectedOnly,
}
impl DeviceFilter {
pub fn label(&self) -> &'static str {
match self {
Self::All => "All",
Self::Aranet4Only => "Aranet4",
Self::RadonOnly => "Radon",
Self::RadiationOnly => "Radiation",
Self::ConnectedOnly => "Connected",
}
}
pub fn next(&self) -> Self {
match self {
Self::All => Self::Aranet4Only,
Self::Aranet4Only => Self::RadonOnly,
Self::RadonOnly => Self::RadiationOnly,
Self::RadiationOnly => Self::ConnectedOnly,
Self::ConnectedOnly => Self::All,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlertSeverity {
Info,
Warning,
Critical,
}
impl AlertSeverity {
pub fn color(self) -> ratatui::style::Color {
match self {
Self::Info => ratatui::style::Color::Blue,
Self::Warning => ratatui::style::Color::Yellow,
Self::Critical => ratatui::style::Color::Red,
}
}
pub fn icon(self) -> &'static str {
match self {
Self::Info => "(i)",
Self::Warning => "(!)",
Self::Critical => "(X)",
}
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Alert {
pub device_id: String,
pub device_name: Option<String>,
pub message: String,
pub level: aranet_core::Co2Level,
pub triggered_at: Instant,
pub severity: AlertSeverity,
}
#[derive(Debug, Clone)]
pub struct AlertRecord {
pub device_name: String,
pub message: String,
pub timestamp: time::OffsetDateTime,
pub severity: AlertSeverity,
}
#[derive(Debug, Clone, Default)]
pub struct SessionStats {
pub co2_min: Option<u16>,
pub co2_max: Option<u16>,
pub co2_sum: u64,
pub co2_count: u32,
pub temp_min: Option<f32>,
pub temp_max: Option<f32>,
}
impl SessionStats {
pub fn update(&mut self, reading: &CurrentReading) {
if reading.co2 > 0 {
self.co2_min = Some(self.co2_min.map_or(reading.co2, |m| m.min(reading.co2)));
self.co2_max = Some(self.co2_max.map_or(reading.co2, |m| m.max(reading.co2)));
self.co2_sum += reading.co2 as u64;
self.co2_count += 1;
}
self.temp_min = Some(
self.temp_min
.map_or(reading.temperature, |m| m.min(reading.temperature)),
);
self.temp_max = Some(
self.temp_max
.map_or(reading.temperature, |m| m.max(reading.temperature)),
);
}
pub fn co2_avg(&self) -> Option<u16> {
if self.co2_count > 0 {
Some((self.co2_sum / self.co2_count as u64) as u16)
} else {
None
}
}
}
pub fn calculate_radon_averages(history: &[HistoryRecord]) -> (Option<u32>, Option<u32>) {
use time::OffsetDateTime;
let now = OffsetDateTime::now_utc();
let day_ago = now - time::Duration::days(1);
let week_ago = now - time::Duration::days(7);
let mut day_sum: u64 = 0;
let mut day_count: u32 = 0;
let mut week_sum: u64 = 0;
let mut week_count: u32 = 0;
for record in history {
if let Some(radon) = record.radon
&& record.timestamp >= week_ago
{
week_sum += radon as u64;
week_count += 1;
if record.timestamp >= day_ago {
day_sum += radon as u64;
day_count += 1;
}
}
}
let day_avg = if day_count > 0 {
Some((day_sum / day_count as u64) as u32)
} else {
None
};
let week_avg = if week_count > 0 {
Some((week_sum / week_count as u64) as u32)
} else {
None
};
(day_avg, week_avg)
}
#[derive(Debug, Clone)]
pub enum PendingAction {
Disconnect {
device_id: String,
device_name: String,
},
}
pub struct App {
pub should_quit: bool,
pub active_tab: Tab,
pub selected_device: usize,
pub devices: Vec<DeviceState>,
pub scanning: bool,
pub status_messages: Vec<(String, Instant)>,
pub status_message_timeout: u64,
pub show_help: bool,
#[allow(dead_code)]
pub command_tx: mpsc::Sender<Command>,
pub event_rx: mpsc::Receiver<SensorEvent>,
pub thresholds: aranet_core::Thresholds,
pub alerts: Vec<Alert>,
pub alert_history: VecDeque<AlertRecord>,
pub show_alert_history: bool,
pub log_file: Option<std::path::PathBuf>,
pub logging_enabled: bool,
pub last_auto_refresh: Option<Instant>,
pub auto_refresh_interval: Duration,
pub history_scroll: usize,
pub history_filter: HistoryFilter,
pub spinner_frame: usize,
pub selected_setting: usize,
pub interval_options: Vec<u16>,
pub co2_alert_threshold: u16,
pub radon_alert_threshold: u16,
pub bell_enabled: bool,
pub device_filter: DeviceFilter,
pub pending_confirmation: Option<PendingAction>,
pub show_sidebar: bool,
pub show_fullscreen_chart: bool,
pub editing_alias: bool,
pub alias_input: String,
pub sticky_alerts: bool,
pub last_error: Option<String>,
pub show_error_details: bool,
pub show_comparison: bool,
pub comparison_device_index: Option<usize>,
pub sidebar_width: u16,
pub theme: Theme,
pub chart_metrics: u8,
pub smart_home_enabled: bool,
pub ble_range: BleRange,
pub syncing: bool,
pub export_format: ExportFormat,
pub do_not_disturb: bool,
#[allow(dead_code)]
pub service_client: Option<aranet_core::service_client::ServiceClient>,
pub service_url: String,
pub service_status: Option<ServiceState>,
pub service_refreshing: bool,
pub service_selected_item: usize,
}
#[derive(Debug, Clone)]
pub struct ServiceState {
pub reachable: bool,
pub collector_running: bool,
#[allow(dead_code)]
pub started_at: Option<time::OffsetDateTime>,
pub uptime_seconds: Option<u64>,
pub devices: Vec<aranet_core::service_client::DeviceCollectionStats>,
#[allow(dead_code)]
pub fetched_at: Instant,
}
impl App {
pub fn new(
command_tx: mpsc::Sender<Command>,
event_rx: mpsc::Receiver<SensorEvent>,
service_url: String,
service_api_key: Option<String>,
) -> Self {
Self {
should_quit: false,
active_tab: Tab::default(),
selected_device: 0,
devices: Vec::new(),
scanning: false,
status_messages: Vec::new(),
status_message_timeout: 5, show_help: false,
command_tx,
event_rx,
thresholds: aranet_core::Thresholds::default(),
alerts: Vec::new(),
alert_history: VecDeque::new(),
show_alert_history: false,
log_file: None,
logging_enabled: false,
last_auto_refresh: None,
auto_refresh_interval: Duration::from_secs(60),
history_scroll: 0,
history_filter: HistoryFilter::default(),
spinner_frame: 0,
selected_setting: 0,
interval_options: vec![60, 120, 300, 600], co2_alert_threshold: 1500,
radon_alert_threshold: 300,
bell_enabled: true,
device_filter: DeviceFilter::default(),
pending_confirmation: None,
show_sidebar: true,
show_fullscreen_chart: false,
editing_alias: false,
alias_input: String::new(),
sticky_alerts: false,
last_error: None,
show_error_details: false,
show_comparison: false,
comparison_device_index: None,
sidebar_width: 28,
theme: Theme::default(),
chart_metrics: Self::METRIC_PRIMARY, smart_home_enabled: false,
ble_range: BleRange::default(),
syncing: false,
export_format: ExportFormat::default(),
do_not_disturb: false,
service_client: aranet_core::service_client::ServiceClient::new_with_api_key(
&service_url,
service_api_key,
)
.ok(),
service_url,
service_status: None,
service_refreshing: false,
service_selected_item: 0,
}
}
pub fn toggle_ble_range(&mut self) {
self.ble_range = self.ble_range.toggle();
self.push_status_message(format!("BLE range: {}", self.ble_range.name()));
}
pub const METRIC_PRIMARY: u8 = 0b001;
pub const METRIC_TEMP: u8 = 0b010;
pub const METRIC_HUMIDITY: u8 = 0b100;
pub fn toggle_chart_metric(&mut self, metric: u8) {
self.chart_metrics ^= metric;
if self.chart_metrics == 0 {
self.chart_metrics = Self::METRIC_PRIMARY;
}
}
pub fn chart_shows(&self, metric: u8) -> bool {
self.chart_metrics & metric != 0
}
pub fn toggle_theme(&mut self) {
self.theme = match self.theme {
Theme::Dark => Theme::Light,
Theme::Light => Theme::Dark,
};
}
#[must_use]
pub fn app_theme(&self) -> super::ui::theme::AppTheme {
match self.theme {
Theme::Dark => super::ui::theme::AppTheme::dark(),
Theme::Light => super::ui::theme::AppTheme::light(),
}
}
pub fn toggle_smart_home(&mut self) {
self.smart_home_enabled = !self.smart_home_enabled;
let status = if self.smart_home_enabled {
"enabled"
} else {
"disabled"
};
self.push_status_message(format!("Smart Home mode {}", status));
}
pub fn toggle_fullscreen_chart(&mut self) {
self.show_fullscreen_chart = !self.show_fullscreen_chart;
}
pub fn should_quit(&self) -> bool {
self.should_quit
}
pub fn push_status_message(&mut self, message: String) {
self.status_messages.push((message, Instant::now()));
while self.status_messages.len() > 5 {
self.status_messages.remove(0);
}
}
pub fn clean_expired_messages(&mut self) {
let timeout = std::time::Duration::from_secs(self.status_message_timeout);
self.status_messages
.retain(|(_, created)| created.elapsed() < timeout);
}
pub fn current_status_message(&self) -> Option<&str> {
self.status_messages.last().map(|(msg, _)| msg.as_str())
}
pub fn handle_sensor_event(&mut self, event: SensorEvent) -> Vec<Command> {
match event {
SensorEvent::CachedDataLoaded { .. }
| SensorEvent::ScanStarted
| SensorEvent::ScanComplete { .. }
| SensorEvent::DeviceConnecting { .. }
| SensorEvent::DeviceConnected { .. }
| SensorEvent::DeviceDisconnected { .. }
| SensorEvent::AliasChanged { .. }
| SensorEvent::DeviceForgotten { .. }
| SensorEvent::SignalStrengthUpdate { .. }
| SensorEvent::BackgroundPollingStarted { .. }
| SensorEvent::BackgroundPollingStopped { .. } => self.handle_device_event(event),
SensorEvent::ReadingUpdated { .. }
| SensorEvent::HistoryLoaded { .. }
| SensorEvent::HistorySyncStarted { .. }
| SensorEvent::HistorySynced { .. }
| SensorEvent::HistorySyncProgress { .. } => self.handle_reading_event(event),
SensorEvent::IntervalChanged { .. }
| SensorEvent::SettingsLoaded { .. }
| SensorEvent::BluetoothRangeChanged { .. }
| SensorEvent::SmartHomeChanged { .. } => {
self.handle_settings_event(event);
Vec::new()
}
SensorEvent::ScanError { .. }
| SensorEvent::ConnectionError { .. }
| SensorEvent::ReadingError { .. }
| SensorEvent::HistorySyncError { .. }
| SensorEvent::IntervalError { .. }
| SensorEvent::BluetoothRangeError { .. }
| SensorEvent::SmartHomeError { .. }
| SensorEvent::AliasError { .. }
| SensorEvent::ForgetDeviceError { .. } => {
self.handle_error_event(event);
Vec::new()
}
SensorEvent::ServiceStatusRefreshed { .. }
| SensorEvent::ServiceStatusError { .. }
| SensorEvent::ServiceCollectorStarted
| SensorEvent::ServiceCollectorStopped
| SensorEvent::ServiceCollectorError { .. } => {
self.handle_service_event(event);
Vec::new()
}
SensorEvent::OperationCancelled { operation } => {
self.push_status_message(format!("{} cancelled", operation));
Vec::new()
}
SensorEvent::SystemServiceStatus { .. }
| SensorEvent::SystemServiceInstalled
| SensorEvent::SystemServiceUninstalled
| SensorEvent::SystemServiceStarted
| SensorEvent::SystemServiceStopped
| SensorEvent::SystemServiceError { .. }
| SensorEvent::ServiceConfigFetched { .. }
| SensorEvent::ServiceConfigError { .. }
| SensorEvent::ServiceDeviceAdded { .. }
| SensorEvent::ServiceDeviceUpdated { .. }
| SensorEvent::ServiceDeviceRemoved { .. }
| SensorEvent::ServiceDeviceError { .. } => Vec::new(),
}
}
fn handle_device_event(&mut self, event: SensorEvent) -> Vec<Command> {
let mut commands = Vec::new();
match event {
SensorEvent::CachedDataLoaded { devices } => {
let device_ids: Vec<String> = devices.iter().map(|d| d.id.clone()).collect();
self.handle_cached_data(devices);
for device_id in device_ids {
commands.push(Command::Connect { device_id });
}
}
SensorEvent::ScanStarted => {
self.scanning = true;
self.push_status_message("Scanning for devices...".to_string());
}
SensorEvent::ScanComplete { devices } => {
self.scanning = false;
self.push_status_message(format!("Found {} device(s)", devices.len()));
for discovered in devices {
let id_str = discovered.id.to_string();
if !self.devices.iter().any(|d| d.id == id_str) {
let mut device = DeviceState::new(id_str);
device.name = discovered.name;
device.device_type = discovered.device_type;
self.devices.push(device);
}
}
}
SensorEvent::DeviceConnecting { device_id } => {
if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
device.status = ConnectionStatus::Connecting;
device.last_updated = Some(Instant::now());
}
self.push_status_message("Connecting...".to_string());
}
SensorEvent::DeviceConnected {
device_id,
name,
device_type,
rssi,
} => {
if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
device.status = ConnectionStatus::Connected;
device.name = name.or(device.name.take());
device.device_type = device_type.or(device.device_type);
device.rssi = rssi;
device.last_updated = Some(Instant::now());
device.error = None;
device.connected_at = Some(Instant::now());
}
self.push_status_message("Connected".to_string());
commands.push(Command::SyncHistory {
device_id: device_id.clone(),
});
}
SensorEvent::DeviceDisconnected { device_id } => {
if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
device.status = ConnectionStatus::Disconnected;
device.last_updated = Some(Instant::now());
device.connected_at = None;
}
}
SensorEvent::AliasChanged { device_id, alias } => {
if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
device.name = alias;
}
self.push_status_message("Device renamed".to_string());
}
SensorEvent::DeviceForgotten { device_id } => {
if let Some(pos) = self.devices.iter().position(|d| d.id == device_id) {
self.devices.remove(pos);
if self.selected_device >= self.devices.len() && !self.devices.is_empty() {
self.selected_device = self.devices.len() - 1;
}
}
self.push_status_message("Device forgotten".to_string());
}
SensorEvent::SignalStrengthUpdate {
device_id,
rssi,
quality: _,
} => {
if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
device.rssi = Some(rssi);
}
}
SensorEvent::BackgroundPollingStarted {
device_id: _,
interval_secs,
} => {
self.push_status_message(format!(
"Background polling started ({}s interval)",
interval_secs
));
}
SensorEvent::BackgroundPollingStopped { device_id: _ } => {
self.push_status_message("Background polling stopped".to_string());
}
_ => {}
}
self.ensure_selected_device_visible();
commands
}
fn handle_reading_event(&mut self, event: SensorEvent) -> Vec<Command> {
match event {
SensorEvent::ReadingUpdated { device_id, reading } => {
self.check_thresholds(&device_id, &reading);
self.log_reading(&device_id, &reading);
if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
device.session_stats.update(&reading);
device.previous_reading = device.reading.take();
device.reading = Some(reading);
device.last_updated = Some(Instant::now());
device.error = None;
}
}
SensorEvent::HistoryLoaded { device_id, records } => {
if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
device.history = records;
device.last_updated = Some(Instant::now());
}
}
SensorEvent::HistorySyncStarted { device_id, .. } => {
self.syncing = true;
self.push_status_message(format!("Syncing history for {}...", device_id));
}
SensorEvent::HistorySynced { device_id, count } => {
self.syncing = false;
if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
device.last_sync = Some(time::OffsetDateTime::now_utc());
}
self.push_status_message(format!("Synced {} records for {}", count, device_id));
}
SensorEvent::HistorySyncProgress {
device_id: _,
downloaded,
total,
} => {
self.push_status_message(format!(
"Syncing history: {}/{} records",
downloaded, total
));
}
_ => {}
}
Vec::new()
}
fn handle_settings_event(&mut self, event: SensorEvent) {
match event {
SensorEvent::IntervalChanged {
device_id,
interval_secs,
} => {
if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id)
&& let Some(reading) = &mut device.reading
{
reading.interval = interval_secs;
}
self.push_status_message(format!("Interval set to {}m", interval_secs / 60));
}
SensorEvent::SettingsLoaded {
device_id,
settings,
} => {
if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
device.settings = Some(settings);
device.last_updated = Some(Instant::now());
}
}
SensorEvent::BluetoothRangeChanged {
device_id: _,
extended,
} => {
let range = if extended { "Extended" } else { "Standard" };
self.push_status_message(format!("Bluetooth range set to {}", range));
}
SensorEvent::SmartHomeChanged {
device_id: _,
enabled,
} => {
let mode = if enabled { "enabled" } else { "disabled" };
self.push_status_message(format!("Smart Home {}", mode));
}
_ => {}
}
}
fn handle_error_event(&mut self, event: SensorEvent) {
match event {
SensorEvent::ScanError { error } => {
self.scanning = false;
let error_msg = format!("Scan: {}", error);
self.set_error(error_msg);
self.push_status_message(format!(
"Scan error: {} (press E for details)",
error.chars().take(40).collect::<String>()
));
}
SensorEvent::ConnectionError {
device_id, error, ..
} => {
let device_name = self.device_display_name(&device_id);
let error_msg = format!("{}: {}", device_name, error);
self.set_error(error_msg);
self.push_status_message(format!(
"Connection error: {} (press E for details)",
error.chars().take(40).collect::<String>()
));
if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
device.status = ConnectionStatus::Error(error.clone());
device.error = Some(error);
device.last_updated = Some(Instant::now());
}
}
SensorEvent::ReadingError {
device_id, error, ..
} => {
let device_name = self.device_display_name(&device_id);
let error_msg = format!("{}: {}", device_name, error);
self.set_error(error_msg);
self.push_status_message(format!(
"Reading error: {} (press E for details)",
error.chars().take(40).collect::<String>()
));
if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
device.error = Some(error);
device.last_updated = Some(Instant::now());
}
}
SensorEvent::HistorySyncError {
device_id, error, ..
} => {
self.syncing = false;
let device_name = self.device_display_name(&device_id);
let error_msg = format!("{}: {}", device_name, error);
self.set_error(error_msg);
self.push_status_message(format!(
"History sync failed: {} (press E for details)",
error.chars().take(40).collect::<String>()
));
if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
device.error = Some(error);
}
}
SensorEvent::IntervalError {
device_id,
error,
context,
} => {
let device_name = self.device_display_name(&device_id);
let error_msg = Self::format_error_with_context(&device_name, &error, &context);
self.set_error(error_msg);
self.push_status_message(format!(
"Set interval failed: {} (press E for details)",
error.chars().take(40).collect::<String>()
));
if let Some(device) = self.devices.iter_mut().find(|d| d.id == device_id) {
device.error = Some(error);
}
}
SensorEvent::BluetoothRangeError {
device_id,
error,
context,
} => {
let device_name = self.device_name_or_id(&device_id);
let error_msg = Self::format_error_with_context(&device_name, &error, &context);
self.set_error(error_msg);
self.push_status_message(format!(
"Set BT range failed: {} (press E for details)",
error.chars().take(40).collect::<String>()
));
}
SensorEvent::SmartHomeError {
device_id,
error,
context,
} => {
let device_name = self.device_name_or_id(&device_id);
let error_msg = Self::format_error_with_context(&device_name, &error, &context);
self.set_error(error_msg);
self.push_status_message(format!(
"Set Smart Home failed: {} (press E for details)",
error.chars().take(40).collect::<String>()
));
}
SensorEvent::AliasError {
device_id: _,
error,
} => {
self.push_status_message(format!("Rename failed: {}", error));
}
SensorEvent::ForgetDeviceError {
device_id: _,
error,
} => {
self.push_status_message(format!("Forget failed: {}", error));
}
_ => {}
}
}
fn handle_service_event(&mut self, event: SensorEvent) {
match event {
SensorEvent::ServiceStatusRefreshed {
reachable,
collector_running,
uptime_seconds,
devices,
} => {
self.service_refreshing = false;
self.service_status = Some(ServiceState {
reachable,
collector_running,
started_at: None,
uptime_seconds,
devices: devices
.into_iter()
.map(|d| aranet_core::service_client::DeviceCollectionStats {
device_id: d.device_id,
alias: d.alias,
poll_interval: d.poll_interval,
polling: d.polling,
success_count: d.success_count,
failure_count: d.failure_count,
last_poll_at: d.last_poll_at,
last_error_at: None,
last_error: d.last_error,
})
.collect(),
fetched_at: Instant::now(),
});
if reachable {
let status = if collector_running {
"running"
} else {
"stopped"
};
self.push_status_message(format!("Service collector: {}", status));
} else {
self.push_status_message("Service not reachable".to_string());
}
}
SensorEvent::ServiceStatusError { error } => {
self.service_refreshing = false;
self.push_status_message(format!("Service error: {}", error));
}
SensorEvent::ServiceCollectorStarted => {
self.push_status_message("Collector started".to_string());
}
SensorEvent::ServiceCollectorStopped => {
self.push_status_message("Collector stopped".to_string());
}
SensorEvent::ServiceCollectorError { error } => {
self.push_status_message(format!("Collector error: {}", error));
}
_ => {}
}
}
fn device_display_name(&self, device_id: &str) -> String {
self.devices
.iter()
.find(|d| d.id == device_id)
.map(|d| d.display_name().to_string())
.unwrap_or_else(|| device_id.to_string())
}
fn device_name_or_id(&self, device_id: &str) -> String {
self.devices
.iter()
.find(|d| d.id == device_id)
.and_then(|d| d.name.clone())
.unwrap_or_else(|| device_id.to_string())
}
fn format_error_with_context(
device_name: &str,
error: &str,
context: &Option<aranet_core::messages::ErrorContext>,
) -> String {
if let Some(ctx) = context
&& let Some(suggestion) = &ctx.suggestion
{
return format!("{}: {}. {}", device_name, error, suggestion);
}
format!("{}: {}", device_name, error)
}
pub fn selected_device(&self) -> Option<&DeviceState> {
self.devices.get(self.selected_device)
}
#[must_use]
pub fn filtered_device_indices(&self) -> Vec<usize> {
self.devices
.iter()
.enumerate()
.filter(|(_, d)| match self.device_filter {
DeviceFilter::All => true,
DeviceFilter::Aranet4Only => {
matches!(d.device_type, Some(DeviceType::Aranet4))
}
DeviceFilter::RadonOnly => {
matches!(d.device_type, Some(DeviceType::AranetRadon))
}
DeviceFilter::RadiationOnly => {
matches!(d.device_type, Some(DeviceType::AranetRadiation))
}
DeviceFilter::ConnectedOnly => {
matches!(d.status, ConnectionStatus::Connected)
}
})
.map(|(index, _)| index)
.collect()
}
pub fn ensure_selected_device_visible(&mut self) {
if self.devices.is_empty() {
self.selected_device = 0;
return;
}
let filtered = self.filtered_device_indices();
if filtered.is_empty() {
return;
}
if !filtered.contains(&self.selected_device) {
self.selected_device = filtered[0];
self.reset_history_scroll();
}
}
pub fn select_filtered_row(&mut self, row: usize) {
if let Some(index) = self.filtered_device_indices().get(row).copied() {
self.selected_device = index;
self.reset_history_scroll();
}
}
pub fn select_next_device(&mut self) {
let filtered = self.filtered_device_indices();
if !filtered.is_empty() {
let current = filtered
.iter()
.position(|&idx| idx == self.selected_device)
.unwrap_or(0);
self.selected_device = filtered[(current + 1) % filtered.len()];
self.reset_history_scroll();
}
}
pub fn select_previous_device(&mut self) {
let filtered = self.filtered_device_indices();
if !filtered.is_empty() {
let current = filtered
.iter()
.position(|&idx| idx == self.selected_device)
.unwrap_or(0);
self.selected_device = filtered
.get(current.checked_sub(1).unwrap_or(filtered.len() - 1))
.copied()
.unwrap_or(self.selected_device);
self.reset_history_scroll();
}
}
pub fn scroll_history_up(&mut self) {
self.history_scroll = self.history_scroll.saturating_sub(5);
}
pub fn scroll_history_down(&mut self) {
if let Some(device) = self.selected_device() {
let max_scroll = device.history.len().saturating_sub(10);
self.history_scroll = (self.history_scroll + 5).min(max_scroll);
}
}
pub fn reset_history_scroll(&mut self) {
self.history_scroll = 0;
}
pub fn tick_spinner(&mut self) {
self.spinner_frame = (self.spinner_frame + 1) % 10;
}
pub fn spinner_char(&self) -> &'static str {
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
SPINNER[self.spinner_frame]
}
pub fn set_history_filter(&mut self, filter: HistoryFilter) {
self.history_filter = filter;
self.history_scroll = 0; }
pub fn cycle_device_filter(&mut self) {
self.device_filter = self.device_filter.next();
self.ensure_selected_device_visible();
self.push_status_message(format!("Filter: {}", self.device_filter.label()));
}
pub fn select_next_setting(&mut self) {
self.selected_setting = (self.selected_setting + 1) % 3; }
pub fn select_previous_setting(&mut self) {
self.selected_setting = self.selected_setting.checked_sub(1).unwrap_or(2);
}
pub fn increase_co2_threshold(&mut self) {
self.co2_alert_threshold = (self.co2_alert_threshold + 100).min(3000);
}
pub fn decrease_co2_threshold(&mut self) {
self.co2_alert_threshold = self.co2_alert_threshold.saturating_sub(100).max(500);
}
pub fn increase_radon_threshold(&mut self) {
self.radon_alert_threshold = (self.radon_alert_threshold + 50).min(1000);
}
pub fn decrease_radon_threshold(&mut self) {
self.radon_alert_threshold = self.radon_alert_threshold.saturating_sub(50).max(100);
}
pub fn cycle_interval(&mut self) -> Option<(String, u16)> {
let device = self.selected_device()?;
let reading = device.reading.as_ref()?;
let current_idx = self
.interval_options
.iter()
.position(|&i| i == reading.interval)
.unwrap_or(0);
let next_idx = (current_idx + 1) % self.interval_options.len();
let new_interval = self.interval_options[next_idx];
Some((device.id.clone(), new_interval))
}
fn handle_cached_data(&mut self, cached_devices: Vec<CachedDevice>) {
let count = cached_devices.len();
if count > 0 {
self.push_status_message(format!("Loaded {} cached device(s)", count));
}
for cached in cached_devices {
if let Some(device) = self.devices.iter_mut().find(|d| d.id == cached.id) {
if device.reading.is_none() {
device.reading = cached.reading;
}
if device.name.is_none() {
device.name = cached.name;
}
if device.device_type.is_none() {
device.device_type = cached.device_type;
}
if device.last_sync.is_none() {
device.last_sync = cached.last_sync;
}
} else {
let mut device = DeviceState::new(cached.id);
device.name = cached.name;
device.device_type = cached.device_type;
device.reading = cached.reading;
device.last_sync = cached.last_sync;
device.status = ConnectionStatus::Disconnected;
self.devices.push(device);
}
}
}
fn add_alert(
&mut self,
device_id: &str,
category: &str,
message: String,
level: aranet_core::Co2Level,
severity: AlertSeverity,
) {
if self
.alerts
.iter()
.any(|a| a.device_id == device_id && a.message.contains(category))
{
return;
}
let device_name = self
.devices
.iter()
.find(|d| d.id == device_id)
.and_then(|d| d.name.clone());
self.alerts.push(Alert {
device_id: device_id.to_string(),
device_name: device_name.clone(),
message: message.clone(),
level,
triggered_at: Instant::now(),
severity,
});
self.alert_history.push_back(AlertRecord {
device_name: device_name.unwrap_or_else(|| device_id.to_string()),
message,
timestamp: time::OffsetDateTime::now_utc(),
severity,
});
while self.alert_history.len() > MAX_ALERT_HISTORY {
self.alert_history.pop_front();
}
if self.bell_enabled && !self.do_not_disturb {
print!("\x07");
use std::io::Write;
std::io::stdout().flush().ok();
}
}
fn clear_alert(&mut self, device_id: &str, category: &str) {
if !self.sticky_alerts {
self.alerts
.retain(|a| !(a.device_id == device_id && a.message.contains(category)));
}
}
pub fn check_thresholds(&mut self, device_id: &str, reading: &CurrentReading) {
if reading.co2 > 0 && reading.co2 >= self.co2_alert_threshold {
let level = self.thresholds.evaluate_co2(reading.co2);
let severity = if reading.co2 >= self.co2_alert_threshold * 2 {
AlertSeverity::Critical
} else if reading.co2 >= (self.co2_alert_threshold * 3) / 2 {
AlertSeverity::Warning
} else {
AlertSeverity::Info
};
let message = format!("CO2 at {} ppm - {}", reading.co2, level.action());
self.add_alert(device_id, "CO2", message, level, severity);
} else if reading.co2 > 0 {
self.clear_alert(device_id, "CO2");
}
if reading.battery > 0 && reading.battery < 20 {
let (message, severity) = if reading.battery < 10 {
(
format!("Battery critically low: {}%", reading.battery),
AlertSeverity::Critical,
)
} else {
(
format!("Battery low: {}%", reading.battery),
AlertSeverity::Warning,
)
};
self.add_alert(
device_id,
"Battery",
message,
aranet_core::Co2Level::Good,
severity,
);
} else if reading.battery >= 20 {
self.clear_alert(device_id, "Battery");
}
if let Some(radon) = reading.radon {
if radon >= self.radon_alert_threshold as u32 {
let severity = if radon >= (self.radon_alert_threshold as u32) * 2 {
AlertSeverity::Critical
} else {
AlertSeverity::Warning
};
let message = format!("Radon high: {} Bq/m³", radon);
self.add_alert(
device_id,
"Radon",
message,
aranet_core::Co2Level::Good,
severity,
);
} else {
self.clear_alert(device_id, "Radon");
}
}
}
pub fn dismiss_alert(&mut self, device_id: &str) {
self.alerts.retain(|a| a.device_id != device_id);
}
pub fn toggle_alert_history(&mut self) {
self.show_alert_history = !self.show_alert_history;
}
pub fn toggle_sticky_alerts(&mut self) {
self.sticky_alerts = !self.sticky_alerts;
self.push_status_message(format!(
"Sticky alerts {}",
if self.sticky_alerts {
"enabled"
} else {
"disabled"
}
));
}
pub fn toggle_logging(&mut self) {
if self.logging_enabled {
self.logging_enabled = false;
self.push_status_message("Logging disabled".to_string());
} else {
let timestamp = {
let now = time::OffsetDateTime::now_local()
.unwrap_or_else(|_| time::OffsetDateTime::now_utc());
now.format(
&time::format_description::parse("[year][month][day]_[hour][minute][second]")
.unwrap_or_default(),
)
.unwrap_or_default()
};
let log_dir = dirs::data_local_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("aranet")
.join("logs");
if let Err(e) = std::fs::create_dir_all(&log_dir) {
self.push_status_message(format!("Failed to create log dir: {}", e));
return;
}
let log_path = log_dir.join(format!("readings_{}.csv", timestamp));
self.log_file = Some(log_path.clone());
self.logging_enabled = true;
self.push_status_message(format!("Logging to {}", log_path.display()));
}
}
pub fn log_reading(&self, device_id: &str, reading: &CurrentReading) {
if !self.logging_enabled {
return;
}
let Some(log_path) = &self.log_file else {
return;
};
use std::io::Write;
let file_exists = log_path.exists();
let file = match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
{
Ok(f) => f,
Err(_) => return,
};
let mut writer = std::io::BufWriter::new(file);
if !file_exists {
let _ = writeln!(
writer,
"timestamp,device_id,co2,temperature,humidity,pressure,battery,status,radon,radiation_rate"
);
}
let timestamp = {
let now = time::OffsetDateTime::now_local()
.unwrap_or_else(|_| time::OffsetDateTime::now_utc());
now.format(
&time::format_description::parse("[year]-[month]-[day]T[hour]:[minute]:[second]")
.unwrap_or_default(),
)
.unwrap_or_default()
};
let radon = reading.radon.map(|r| r.to_string()).unwrap_or_default();
let radiation = reading
.radiation_rate
.map(|r| format!("{:.3}", r))
.unwrap_or_default();
let _ = writeln!(
writer,
"{},{},{},{:.1},{},{:.1},{},{:?},{},{}",
timestamp,
device_id,
reading.co2,
reading.temperature,
reading.humidity,
reading.pressure,
reading.battery,
reading.status,
radon,
radiation
);
}
pub fn export_history(&self) -> Option<String> {
use std::io::Write;
let device = self.selected_device()?;
if device.history.is_empty() {
return None;
}
let filtered: Vec<_> = device
.history
.iter()
.filter(|r| self.filter_matches_record(r))
.collect();
if filtered.is_empty() {
return None;
}
let export_dir = dirs::data_local_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("aranet")
.join("exports");
std::fs::create_dir_all(&export_dir).ok()?;
let now =
time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
let filename = format!(
"history_{}_{}.{}",
device
.name
.as_deref()
.unwrap_or(&device.id)
.replace(' ', "_"),
time::format_description::parse("[year][month][day]_[hour][minute][second]")
.ok()
.and_then(|fmt| now.format(&fmt).ok())
.unwrap_or_else(|| "export".to_string()),
self.export_format.extension()
);
let path = export_dir.join(&filename);
let mut file = std::fs::File::create(&path).ok()?;
match self.export_format {
ExportFormat::Csv => {
writeln!(
file,
"timestamp,co2,temperature,humidity,pressure,radon,radiation_rate"
)
.ok()?;
for record in filtered {
writeln!(
file,
"{},{},{:.1},{},{:.1},{},{}",
record
.timestamp
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default(),
record.co2,
record.temperature,
record.humidity,
record.pressure,
record.radon.map(|v| v.to_string()).unwrap_or_default(),
record
.radiation_rate
.map(|v| format!("{:.3}", v))
.unwrap_or_default(),
)
.ok()?;
}
}
ExportFormat::Json => {
let json_records: Vec<serde_json::Value> = filtered
.iter()
.map(|record| {
let mut obj = serde_json::json!({
"timestamp": record.timestamp
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default(),
"co2": record.co2,
"temperature": record.temperature,
"humidity": record.humidity,
"pressure": record.pressure,
});
if let Some(radon) = record.radon {
obj["radon"] = serde_json::json!(radon);
}
if let Some(rate) = record.radiation_rate {
obj["radiation_rate"] = serde_json::json!(rate);
}
if let Some(total) = record.radiation_total {
obj["radiation_total"] = serde_json::json!(total);
}
obj
})
.collect();
let json_output = serde_json::json!({
"device": device.name.as_deref().unwrap_or(&device.id),
"device_id": device.id,
"device_type": device.device_type.map(|dt| format!("{:?}", dt)),
"export_time": now.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(),
"record_count": json_records.len(),
"records": json_records,
});
serde_json::to_writer_pretty(&file, &json_output).ok()?;
}
}
Some(path.to_string_lossy().to_string())
}
pub fn toggle_export_format(&mut self) {
self.export_format = self.export_format.toggle();
self.push_status_message(format!(
"Export format: {}",
self.export_format.extension().to_uppercase()
));
}
pub fn toggle_do_not_disturb(&mut self) {
self.do_not_disturb = !self.do_not_disturb;
let status = if self.do_not_disturb {
"enabled - alerts silenced"
} else {
"disabled"
};
self.push_status_message(format!("Do Not Disturb {}", status));
}
fn filter_matches_record(&self, record: &HistoryRecord) -> bool {
use time::OffsetDateTime;
match &self.history_filter {
HistoryFilter::All => true,
HistoryFilter::Today => {
let now = OffsetDateTime::now_utc();
record.timestamp.date() == now.date()
}
HistoryFilter::Last24Hours => {
let cutoff = OffsetDateTime::now_utc() - time::Duration::hours(24);
record.timestamp >= cutoff
}
HistoryFilter::Last7Days => {
let cutoff = OffsetDateTime::now_utc() - time::Duration::days(7);
record.timestamp >= cutoff
}
HistoryFilter::Last30Days => {
let cutoff = OffsetDateTime::now_utc() - time::Duration::days(30);
record.timestamp >= cutoff
}
HistoryFilter::Custom { start, end } => {
let record_date = record.timestamp.date();
let after_start = start.is_none_or(|s| record_date >= s);
let before_end = end.is_none_or(|e| record_date <= e);
after_start && before_end
}
}
}
#[allow(dead_code)]
pub fn set_custom_date_filter(&mut self, start: Option<time::Date>, end: Option<time::Date>) {
if let (Some(s), Some(e)) = (start, end)
&& e < s
{
self.push_status_message("Warning: end date is before start date".to_string());
}
self.history_filter = HistoryFilter::Custom { start, end };
self.history_scroll = 0;
self.push_status_message("Custom date range set".to_string());
}
pub fn check_auto_refresh(&mut self) -> Vec<String> {
let now = Instant::now();
let interval = self
.devices
.iter()
.find(|d| d.status == ConnectionStatus::Connected)
.and_then(|d| d.reading.as_ref())
.map(|r| Duration::from_secs(r.interval as u64))
.unwrap_or(Duration::from_secs(60));
self.auto_refresh_interval = interval;
let should_refresh = match self.last_auto_refresh {
Some(last) => now.duration_since(last) >= interval,
None => true, };
if should_refresh {
self.last_auto_refresh = Some(now);
self.devices
.iter()
.filter(|d| d.status == ConnectionStatus::Connected)
.map(|d| d.id.clone())
.collect()
} else {
Vec::new()
}
}
pub fn request_confirmation(&mut self, action: PendingAction) {
self.pending_confirmation = Some(action);
}
pub fn confirm_action(&mut self) -> Option<Command> {
if let Some(action) = self.pending_confirmation.take() {
match action {
PendingAction::Disconnect { device_id, .. } => {
return Some(Command::Disconnect { device_id });
}
}
}
None
}
pub fn cancel_confirmation(&mut self) {
self.pending_confirmation = None;
self.push_status_message("Cancelled".to_string());
}
pub fn toggle_sidebar(&mut self) {
self.show_sidebar = !self.show_sidebar;
}
pub fn toggle_sidebar_width(&mut self) {
self.sidebar_width = if self.sidebar_width == 28 { 40 } else { 28 };
}
pub fn start_alias_edit(&mut self) {
if let Some(device) = self.selected_device() {
self.alias_input = device
.alias
.clone()
.or_else(|| device.name.clone())
.unwrap_or_default();
self.editing_alias = true;
}
}
pub fn cancel_alias_edit(&mut self) {
self.editing_alias = false;
self.alias_input.clear();
}
pub fn save_alias(&mut self) {
let display_name = if let Some(device) = self.devices.get_mut(self.selected_device) {
if self.alias_input.trim().is_empty() {
device.alias = None;
} else {
device.alias = Some(self.alias_input.trim().to_string());
}
Some(device.display_name().to_string())
} else {
None
};
if let Some(name) = display_name {
self.push_status_message(format!("Alias set: {}", name));
}
self.editing_alias = false;
self.alias_input.clear();
}
pub fn alias_input_char(&mut self, c: char) {
if self.alias_input.len() < 20 {
self.alias_input.push(c);
}
}
pub fn alias_input_backspace(&mut self) {
self.alias_input.pop();
}
pub fn set_error(&mut self, error: String) {
self.last_error = Some(error);
}
pub fn toggle_error_details(&mut self) {
if self.last_error.is_some() {
self.show_error_details = !self.show_error_details;
} else {
self.push_status_message("No error to display".to_string());
}
}
pub fn average_co2(&self) -> Option<u16> {
let values: Vec<u16> = self
.devices
.iter()
.filter(|d| matches!(d.status, ConnectionStatus::Connected))
.filter_map(|d| d.reading.as_ref())
.filter_map(|r| if r.co2 > 0 { Some(r.co2) } else { None })
.collect();
if values.is_empty() {
None
} else {
Some((values.iter().map(|&v| v as u32).sum::<u32>() / values.len() as u32) as u16)
}
}
pub fn connected_count(&self) -> usize {
self.devices
.iter()
.filter(|d| matches!(d.status, ConnectionStatus::Connected))
.count()
}
pub fn is_any_connecting(&self) -> bool {
self.devices
.iter()
.any(|d| matches!(d.status, ConnectionStatus::Connecting))
}
pub fn is_syncing(&self) -> bool {
self.syncing
}
pub fn toggle_comparison(&mut self) {
if self.devices.len() < 2 {
self.push_status_message("Need at least 2 devices for comparison".to_string());
return;
}
self.show_comparison = !self.show_comparison;
if self.show_comparison {
let next = (self.selected_device + 1) % self.devices.len();
self.comparison_device_index = Some(next);
self.push_status_message(
"Comparison view: use </> to change second device".to_string(),
);
} else {
self.comparison_device_index = None;
}
}
pub fn cycle_comparison_device(&mut self, forward: bool) {
if !self.show_comparison || self.devices.len() < 2 {
return;
}
let current = self.comparison_device_index.unwrap_or(0);
let mut next = if forward {
(current + 1) % self.devices.len()
} else {
current.checked_sub(1).unwrap_or(self.devices.len() - 1)
};
if next == self.selected_device {
next = if forward {
(next + 1) % self.devices.len()
} else {
next.checked_sub(1).unwrap_or(self.devices.len() - 1)
};
}
self.comparison_device_index = Some(next);
}
pub fn comparison_device(&self) -> Option<&DeviceState> {
self.comparison_device_index
.and_then(|i| self.devices.get(i))
}
}