use chrono::{DateTime, TimeDelta, Utc}; use chrono_tz::Tz; use serde::{Deserialize, Serialize}; use crate::{ config::{self, PlantConfig}, in_time_range, plant_hal, }; const MOIST_SENSOR_MAX_FREQUENCY: f32 = 6500.; // 60kHz (500Hz margin) const MOIST_SENSOR_MIN_FREQUENCY: f32 = 150.; // this is really really dry, think like cactus levels #[derive(Debug, PartialEq, Serialize)] pub enum MoistureSensorError { ShortCircuit { hz: f32, max: f32 }, OpenLoop { hz: f32, min: f32 }, BoardError(String), } #[derive(Debug, PartialEq, Serialize)] pub enum MoistureSensorState { Disabled, MoistureValue { raw_hz: f32, moisture_percent: f32 }, SensorError(MoistureSensorError), } impl MoistureSensorState { pub fn is_err(&self) -> Option<&MoistureSensorError> { match self { MoistureSensorState::SensorError(moisture_sensor_error) => Some(moisture_sensor_error), _ => None, } } pub fn moisture_percent(&self) -> Option { if let MoistureSensorState::MoistureValue { raw_hz: _, moisture_percent, } = self { Some(*moisture_percent) } else { None } } } impl MoistureSensorState {} #[derive(Debug, PartialEq, Serialize)] pub enum PumpError { PumpNotWorking { failed_attempts: usize, max_allowed_failures: usize, }, } #[derive(Debug, Serialize)] pub struct PumpState { consecutive_pump_count: u32, previous_pump: Option>, } impl PumpState { fn is_err(&self, plant_config: &PlantConfig) -> Option { if self.consecutive_pump_count > plant_config.max_consecutive_pump_count as u32 { Some(PumpError::PumpNotWorking { failed_attempts: self.consecutive_pump_count as usize, max_allowed_failures: plant_config.max_consecutive_pump_count as usize, }) } else { None } } } #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)] pub enum PlantWateringMode { OFF, TargetMoisture, TimerOnly, } pub struct PlantState { pub sensor_a: MoistureSensorState, pub sensor_b: MoistureSensorState, pub pump: PumpState, } fn map_range_moisture( s: f32, min_frequency: Option, max_frequency: Option, ) -> Result { // Use overrides if provided, otherwise fallback to defaults let min_freq = min_frequency.unwrap_or(MOIST_SENSOR_MIN_FREQUENCY); let max_freq = max_frequency.unwrap_or(MOIST_SENSOR_MAX_FREQUENCY); if s < min_freq { return Err(MoistureSensorError::OpenLoop { hz: s, min: min_freq, }); } if s > max_freq { return Err(MoistureSensorError::ShortCircuit { hz: s, max: max_freq, }); } let moisture_percent = (s - min_freq) * 100.0 / (max_freq - min_freq); Ok(moisture_percent) } impl PlantState { pub fn read_hardware_state( plant_id: usize, board: &mut plant_hal::PlantCtrlBoard, config: &config::PlantConfig, ) -> Self { let sensor_a = if config.sensor_a { match board.measure_moisture_hz(plant_id, plant_hal::Sensor::A) { Ok(raw) => match map_range_moisture( raw, config.moisture_sensor_min_frequency, config.moisture_sensor_max_frequency, ) { Ok(moisture_percent) => MoistureSensorState::MoistureValue { raw_hz: raw, moisture_percent, }, Err(err) => MoistureSensorState::SensorError(err), }, Err(err) => MoistureSensorState::SensorError(MoistureSensorError::BoardError( err.to_string(), )), } } else { MoistureSensorState::Disabled }; let sensor_b = if config.sensor_b { match board.measure_moisture_hz(plant_id, plant_hal::Sensor::B) { Ok(raw) => match map_range_moisture( raw, config.moisture_sensor_min_frequency, config.moisture_sensor_max_frequency, ) { Ok(moisture_percent) => MoistureSensorState::MoistureValue { raw_hz: raw, moisture_percent, }, Err(err) => MoistureSensorState::SensorError(err), }, Err(err) => MoistureSensorState::SensorError(MoistureSensorError::BoardError( err.to_string(), )), } } else { MoistureSensorState::Disabled }; let previous_pump = board.last_pump_time(plant_id); let consecutive_pump_count = board.consecutive_pump_count(plant_id); let state = Self { sensor_a, sensor_b, pump: PumpState { consecutive_pump_count, previous_pump, }, }; if state.is_err() { board.fault(plant_id, true); } state } pub fn pump_in_timeout(&self, plant_conf: &PlantConfig, current_time: &DateTime) -> bool { if matches!(plant_conf.mode, PlantWateringMode::OFF) { return false; } self.pump.previous_pump.is_some_and(|last_pump| { last_pump .checked_add_signed(TimeDelta::minutes(plant_conf.pump_cooldown_min.into())) .is_some_and(|earliest_next_allowed_pump| { earliest_next_allowed_pump > *current_time }) }) } pub fn is_err(&self) -> bool { self.sensor_a.is_err().is_some() || self.sensor_b.is_err().is_some() } pub fn plant_moisture( &self, ) -> ( Option, (Option<&MoistureSensorError>, Option<&MoistureSensorError>), ) { match ( self.sensor_a.moisture_percent(), self.sensor_b.moisture_percent(), ) { (Some(moisture_a), Some(moisture_b)) => { (Some((moisture_a + moisture_b) / 2.), (None, None)) } (Some(moisture_percent), _) => (Some(moisture_percent), (None, self.sensor_b.is_err())), (_, Some(moisture_percent)) => (Some(moisture_percent), (self.sensor_a.is_err(), None)), _ => (None, (self.sensor_a.is_err(), self.sensor_b.is_err())), } } pub fn needs_to_be_watered( &self, plant_conf: &PlantConfig, current_time: &DateTime, ) -> bool { match plant_conf.mode { PlantWateringMode::OFF => false, PlantWateringMode::TargetMoisture => { let (moisture_percent, _) = self.plant_moisture(); if let Some(moisture_percent) = moisture_percent { if self.pump_in_timeout(plant_conf, current_time) { false } else { if moisture_percent < plant_conf.target_moisture { in_time_range( current_time, plant_conf.pump_hour_start, plant_conf.pump_hour_end, ) } else { false } } } else { // in case no moisture can be determined do not water plant return false; } } PlantWateringMode::TimerOnly => { if self.pump_in_timeout(plant_conf, current_time) { false } else { true } } } } pub fn to_mqtt_info(&self, plant_conf: &PlantConfig, current_time: &DateTime) -> PlantInfo { PlantInfo { sensor_a: &self.sensor_a, sensor_b: &self.sensor_b, mode: plant_conf.mode, do_water: self.needs_to_be_watered(plant_conf, current_time), dry: if let Some(moisture_percent) = self.plant_moisture().0 { moisture_percent < plant_conf.target_moisture } else { false }, cooldown: self.pump_in_timeout(plant_conf, current_time), out_of_work_hour: in_time_range( current_time, plant_conf.pump_hour_start, plant_conf.pump_hour_end, ), consecutive_pump_count: self.pump.consecutive_pump_count, pump_error: self.pump.is_err(plant_conf), last_pump: self .pump .previous_pump .map(|t| t.with_timezone(¤t_time.timezone())), next_pump: if matches!( plant_conf.mode, PlantWateringMode::TimerOnly | PlantWateringMode::TargetMoisture ) { self.pump.previous_pump.and_then(|last_pump| { last_pump .checked_add_signed(TimeDelta::minutes(plant_conf.pump_cooldown_min.into())) .map(|t| t.with_timezone(¤t_time.timezone())) }) } else { None }, } } } #[derive(Debug, PartialEq, Serialize)] /// State of a single plant to be tracked pub struct PlantInfo<'a> { /// state of humidity sensor on bank a sensor_a: &'a MoistureSensorState, /// state of humidity sensor on bank b sensor_b: &'a MoistureSensorState, /// configured plant watering mode mode: PlantWateringMode, /// plant needs to be watered do_water: bool, /// is plant considerd to be dry according to settings dry: bool, /// plant irrigation cooldown is active cooldown: bool, /// plant should not be watered at this time of day TODO: does this really belong here? Isn't this a global setting? out_of_work_hour: bool, /// how often has the pump been watered without reaching target moisture consecutive_pump_count: u32, pump_error: Option, /// last time when pump was active last_pump: Option>, /// next time when pump should activate next_pump: Option>, }