use crate::config::SensorCombineMode; use crate::hal::Moistures; use crate::plant_state::PlantWateringMode::TargetMoisture; use crate::{config::PlantConfig, hal::HAL, in_time_range}; use chrono::{DateTime, TimeDelta, Utc}; use chrono_tz::Tz; use serde::{Deserialize, Serialize}; const MOIST_SENSOR_MAX_FREQUENCY: f32 = 160000.; // 160kHz -> very wet const MOIST_SENSOR_MIN_FREQUENCY: f32 = 400.; // this is really, really dry, think like cactus levels #[derive(Debug, PartialEq, Clone, Serialize)] #[serde(tag = "kind")] pub enum MoistureSensorError { MissingMessage, NotExpectedMessage { hz: f32 }, ShortCircuit { hz: f32, max: f32 }, OpenLoop { hz: f32, min: f32 }, } #[derive(Debug, PartialEq, Serialize)] pub enum MoistureSensorState { MoistureValue { hz: f32, moisture_percent: f32 }, NoMessage, 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 { hz: _, moisture_percent, } = self { Some(*moisture_percent) } else { None } } } impl MoistureSensorState {} #[derive(Debug, PartialEq, Serialize)] pub struct SensorTelemetry { pub moisture_pct: Option, pub raw_hz: Option, pub error: Option, } #[derive(Debug, PartialEq, Serialize)] #[serde(tag = "kind")] pub enum PumpError { PumpNotWorking { failed_attempts: usize, max_allowed_failures: usize, }, OverCurrent { current_ma: u16, max_allowed_ma: u16, }, } #[derive(Debug, Serialize)] pub struct PumpState { consecutive_pump_count: u32, previous_pump: Option>, pub overcurrent_error: Option, } impl PumpState { fn is_err(&self, plant_config: &PlantConfig) -> Option { if let Some(current_ma) = self.overcurrent_error { return Some(PumpError::OverCurrent { current_ma, max_allowed_ma: plant_config.max_pump_current_ma, }); } 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, MinMoisture, TimerOnly, } pub struct PlantState { pub sensor_a: MoistureSensorState, pub sensor_b: MoistureSensorState, pub pump: PumpState, /// Last known firmware build timestamp for sensor A (minutes since Unix epoch). /// Set during sensor detection; None if detection has not been run yet. pub sensor_a_firmware_build_minutes: Option, /// Last known firmware build timestamp for sensor B. pub sensor_b_firmware_build_minutes: Option, /// Last time fertilizer was applied. pub last_fertilizer_time: Option>, } /// Map sensor frequency to moisture percentage using inverse power-law scaling (quadratic). /// /// For resistive probes with 555 timer oscillator: /// - Dry soil has high resistance → low oscillation frequency /// - Wet soil has low resistance → high oscillation frequency /// /// The relationship is non-linear: most frequency change occurs in the wet range. /// Using inverse power-law to give better discrimination at high moisture levels. /// /// Formula: moisture = (1 - (f_max - f) / (f_max - f_min))^2 * 100 /// = ((f - f_min) / (f_max - f_min))^2 * 100 /// /// But with k=0.5 (square root) for better high-end discrimination: /// Formula: moisture = sqrt((f - f_min) / (f_max - f_min)) * 100 /// /// Examples with default range (400-160000 Hz) using k=0.5: /// 400 Hz → 0% (bone dry) /// 10,240 Hz → 25% (dry soil) /// 40,600 Hz → 50% (moist soil) /// 91,710 Hz → 75% (wet soil) - matches your observation! /// 160,000 Hz → 100% (saturated) 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, }); } // Normalize to 0-1 range let t = (s - min_freq) / (max_freq - min_freq); // Apply power-law mapping with k=0.5 (square root) for better high-moisture discrimination // For resistive probes: frequency ↑ as moisture ↑, but non-linearly // Using sqrt gives more resolution in the wet range (60-160kHz) // Newton's method approximation for sqrt(t): x_{n+1} = 0.5 * (x_n + t/x_n) // Start with initial guess and do 2 iterations for good precision let moisture_percent = if t <= 0.0 { 0.0 } else if t >= 1.0 { 100.0 } else { // Newton's method for sqrt(t) let mut x = t; // Initial guess x = 0.5 * (x + t / x); // First iteration x = 0.5 * (x + t / x); // Second iteration for better precision x * 100.0 }; Ok(moisture_percent.clamp(0.0, 100.0)) } impl PlantState { pub async fn interpret_raw_values( moistures: Moistures, plant_id: usize, board: &mut HAL<'_>, ) -> Self { let min = board.board_hal.get_config().plants[plant_id].moisture_sensor_min_frequency; let max = board.board_hal.get_config().plants[plant_id].moisture_sensor_max_frequency; let raw_to_value = |raw: Option, expected: bool| -> MoistureSensorState { match raw { None => { if expected { MoistureSensorState::SensorError(MoistureSensorError::MissingMessage) } else { MoistureSensorState::NoMessage } } Some(raw) => { if expected { match map_range_moisture(raw, min.map(|a| a as f32), max.map(|b| b as f32)) { Ok(moisture_percent) => MoistureSensorState::MoistureValue { hz: raw, moisture_percent, }, Err(err) => MoistureSensorState::SensorError(err), } } else { MoistureSensorState::SensorError(MoistureSensorError::NotExpectedMessage { hz: raw, }) } } } }; let expected_a = board.board_hal.get_config().plants[plant_id].sensor_a; let expected_b = board.board_hal.get_config().plants[plant_id].sensor_b; let sensor_a = { raw_to_value(moistures.sensor_a_hz[plant_id], expected_a) }; let sensor_b = { raw_to_value(moistures.sensor_b_hz[plant_id], expected_b) }; let previous_pump = board.board_hal.get_esp().last_pump_time(plant_id); let consecutive_pump_count = board.board_hal.get_esp().consecutive_pump_count(plant_id); let last_fertilizer_timestamp = board.board_hal.get_esp().last_fertilizer_time(plant_id); let (a_builds, b_builds) = board.board_hal.get_sensor_build_minutes(); let last_fertilizer_time = DateTime::from_timestamp_millis(last_fertilizer_timestamp); // Create plant state first, then check for warnings let state = Self { sensor_a, sensor_b, pump: PumpState { consecutive_pump_count, previous_pump, overcurrent_error: None, }, sensor_a_firmware_build_minutes: a_builds[plant_id], sensor_b_firmware_build_minutes: b_builds[plant_id], last_fertilizer_time, }; // Check for sensor warning condition (expected 2 sensors, only 1 responding) let has_a = state.sensor_a.moisture_percent().is_some() && state.sensor_a.is_err().is_none(); let has_b = state.sensor_b.moisture_percent().is_some() && state.sensor_b.is_err().is_none(); // Check if we expected two sensors but only got one let has_sensor_warning = expected_a && expected_b && ((has_a && !has_b) || (!has_a && has_b)); // Set fault LED for both errors AND sensor warnings let has_issue = state.is_err() || has_sensor_warning; if has_issue { let _ = board.board_hal.fault(plant_id, true).await; } 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() } /// Get combined moisture value with configurable combination mode and sensor warning. /// /// Returns: /// - Combined moisture percentage (or None if no valid readings) /// - Tuple of errors from sensor A and B /// - Sensor warning indicating if warning LED should be lit (MissingSecondSensor) pub fn plant_moisture_with_warning(&self, plant_conf: &PlantConfig) -> Option { match ( self.sensor_a.moisture_percent(), self.sensor_b.moisture_percent(), ) { (Some(moisture_a), Some(moisture_b)) => match plant_conf.sensor_combine_mode { SensorCombineMode::Min => Some(moisture_a.min(moisture_b)), SensorCombineMode::Max => Some(moisture_a.max(moisture_b)), SensorCombineMode::Avg => Some((moisture_a + moisture_b) / 2.0), }, (Some(moisture), _) => Some(moisture), (_, Some(moisture)) => Some(moisture), _ => None, } } 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_with_warning(plant_conf); 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.into() { 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 the plant false } } PlantWateringMode::MinMoisture => { // TODO false } PlantWateringMode::TimerOnly => !self.pump_in_timeout(plant_conf, current_time), } } pub fn to_mqtt_info(&self, plant_conf: &PlantConfig, current_time: &DateTime) -> PlantInfo { let moisture_pct = self.plant_moisture_with_warning(plant_conf); PlantInfo { moisture_pct, sensor_a: Self::sensor_to_telemetry(&self.sensor_a), sensor_b: Self::sensor_to_telemetry(&self.sensor_b), mode: plant_conf.mode, target_pct: if plant_conf.mode == TargetMoisture { Some(plant_conf.target_moisture as f32) } else { None }, do_water: self.needs_to_be_watered(plant_conf, current_time), dry: if let Some(moisture_percent) = moisture_pct { moisture_percent < plant_conf.target_moisture.into() } 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 | PlantWateringMode::MinMoisture ) { 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 }, last_fertilizer: self .last_fertilizer_time .map(|t| t.with_timezone(¤t_time.timezone())), next_fertilizer: if matches!( plant_conf.mode, PlantWateringMode::TimerOnly | PlantWateringMode::TargetMoisture | PlantWateringMode::MinMoisture ) { self.last_fertilizer_time.and_then(|last_fert| { // Convert to Tz for calculation, then back let tz_last_fert = last_fert.with_timezone(¤t_time.timezone()); tz_last_fert .checked_add_signed(TimeDelta::minutes( plant_conf.fertilizer_cooldown_min.into(), )) .map(|t| t.with_timezone(¤t_time.timezone())) }) } else { None }, sensor_a_firmware_build_minutes: self.sensor_a_firmware_build_minutes, sensor_b_firmware_build_minutes: self.sensor_b_firmware_build_minutes, } } fn sensor_to_telemetry(sensor: &MoistureSensorState) -> SensorTelemetry { match sensor { MoistureSensorState::NoMessage => SensorTelemetry { moisture_pct: None, raw_hz: None, error: None, }, MoistureSensorState::MoistureValue { hz, moisture_percent, } => SensorTelemetry { moisture_pct: Some(*moisture_percent), raw_hz: Some(*hz), error: None, }, MoistureSensorState::SensorError(err) => SensorTelemetry { moisture_pct: None, raw_hz: None, error: Some(err.clone()), }, } } } #[derive(Debug, PartialEq, Serialize)] /// State of a single plant to be tracked pub struct PlantInfo { /// combined plant moisture from available sensors moisture_pct: Option, /// moisture target, if in targetmode target_pct: Option, /// state of humidity sensor on bank a sensor_a: SensorTelemetry, /// state of humidity sensor on bank b sensor_b: SensorTelemetry, /// configured plant watering mode mode: PlantWateringMode, /// the plant needs to be watered do_water: bool, /// plant is considered 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 the pump was active last_pump: Option>, /// next time when pump should activate next_pump: Option>, /// last time when fertilizer was applied last_fertilizer: Option>, /// next time when fertilizer should be applied next_fertilizer: Option>, /// firmware build timestamp of sensor A (minutes since Unix epoch); None if unknown sensor_a_firmware_build_minutes: Option, /// firmware build timestamp of sensor B (minutes since Unix epoch); None if unknown sensor_b_firmware_build_minutes: Option, }