refactor/plant-state-handling #11
| @@ -4,7 +4,7 @@ use std::{ | ||||
| }; | ||||
|  | ||||
| use anyhow::{bail, Result}; | ||||
| use chrono::{DateTime, Datelike, TimeDelta, Timelike, Utc}; | ||||
| use chrono::{DateTime, Datelike, Timelike}; | ||||
| use chrono_tz::{Europe::Berlin, Tz}; | ||||
|  | ||||
| use esp_idf_hal::delay::Delay; | ||||
| @@ -29,7 +29,7 @@ mod plant_state; | ||||
| mod tank; | ||||
| pub mod util; | ||||
|  | ||||
| use plant_state::{PlantInfo, PlantState}; | ||||
| use plant_state::PlantState; | ||||
| use tank::*; | ||||
|  | ||||
| const TIME_ZONE: Tz = Berlin; | ||||
| @@ -406,8 +406,21 @@ fn safe_main() -> anyhow::Result<()> { | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     let mut plantstate: [PlantState; PLANT_COUNT] = | ||||
|     let plantstate: [PlantState; PLANT_COUNT] = | ||||
|         core::array::from_fn(|i| PlantState::read_hardware_state(i, &mut board, &config.plants[i])); | ||||
|     for (plant_id, (plant_state, plant_conf)) in plantstate.iter().zip(&config.plants).enumerate() { | ||||
|         match serde_json::to_string(&plant_state.to_mqtt_info(plant_conf, &timezone_time)) { | ||||
|             Ok(state) => { | ||||
|                 let plant_topic = format!("/plant{}", plant_id + 1); | ||||
|                 let _ = board.mqtt_publish(&config, &plant_topic, state.as_bytes()); | ||||
|                 //reduce speed as else messages will be dropped | ||||
|                 Delay::new_default().delay_ms(200); | ||||
|             } | ||||
|             Err(err) => { | ||||
|                 println!("Error publishing plant state {}", err); | ||||
|             } | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     let pump_required = plantstate | ||||
|         .iter() | ||||
| @@ -457,7 +470,6 @@ fn safe_main() -> anyhow::Result<()> { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     //update_plant_state(&mut plantstate, &mut board, &config); | ||||
|  | ||||
|     let is_day = board.is_day(); | ||||
|     let state_of_charge = board.state_charge_percent().unwrap_or(0); | ||||
| @@ -622,40 +634,6 @@ fn main() { | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn time_to_string_utc(value_option: Option<DateTime<Utc>>) -> String { | ||||
|     let converted = value_option.and_then(|utc| Some(utc.with_timezone(&TIME_ZONE))); | ||||
|     return time_to_string(converted); | ||||
| } | ||||
|  | ||||
| fn time_to_string(value_option: Option<DateTime<Tz>>) -> String { | ||||
|     match value_option { | ||||
|         Some(value) => { | ||||
|             let europe_time = value.with_timezone(&TIME_ZONE); | ||||
|             if europe_time.year() > 2023 { | ||||
|                 return europe_time.to_rfc3339(); | ||||
|             } else { | ||||
|                 //initial value of 0 in rtc memory | ||||
|                 return "N/A".to_owned(); | ||||
|             } | ||||
|         } | ||||
|         None => return "N/A".to_owned(), | ||||
|     }; | ||||
| } | ||||
|  | ||||
| fn sensor_to_string(value: &Option<u8>, error: &Option<SensorError>, enabled: bool) -> String { | ||||
|     if enabled { | ||||
|         match error { | ||||
|             Some(error) => return format!("{:?}", error), | ||||
|             None => match value { | ||||
|                 Some(v) => return v.to_string(), | ||||
|                 None => return "Error".to_owned(), | ||||
|             }, | ||||
|         } | ||||
|     } else { | ||||
|         return "disabled".to_owned(); | ||||
|     }; | ||||
| } | ||||
|  | ||||
| fn to_string<T: Display>(value: Result<T>) -> String { | ||||
|     return match value { | ||||
|         Ok(v) => v.to_string(), | ||||
| @@ -665,7 +643,7 @@ fn to_string<T: Display>(value: Result<T>) -> String { | ||||
|     }; | ||||
| } | ||||
|  | ||||
| fn in_time_range(cur: &DateTime<Tz>, start: u8, end: u8) -> bool { | ||||
| pub fn in_time_range(cur: &DateTime<Tz>, start: u8, end: u8) -> bool { | ||||
|     let curhour = cur.hour() as u8; | ||||
|     //eg 10-14 | ||||
|     if start < end { | ||||
|   | ||||
| @@ -1,38 +1,40 @@ | ||||
| use chrono::{DateTime, TimeDelta, Utc}; | ||||
| use chrono_tz::Tz; | ||||
| use measurements::humidity; | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| use crate::{ | ||||
|     config::{self, PlantConfig}, | ||||
|     plant_hal::{self, PLANT_COUNT}, | ||||
|     in_time_range, plant_hal, | ||||
| }; | ||||
|  | ||||
| const MOIST_SENSOR_MAX_FREQUENCY: f32 = 5500.; // 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 HumiditySensorError { | ||||
| pub enum MoistureSensorError { | ||||
|     ShortCircuit { hz: f32, max: f32 }, | ||||
|     OpenLoop { hz: f32, min: f32 }, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, PartialEq, Serialize)] | ||||
| pub enum HumiditySensorState { | ||||
|     Disabled, | ||||
|     HumidityValue { raw_hz: f32, moisture_percent: f32 }, | ||||
|     SensorError(HumiditySensorError), | ||||
|     BoardError(String), | ||||
| } | ||||
|  | ||||
| impl HumiditySensorState { | ||||
|     pub fn is_err(&self) -> bool { | ||||
|         matches!(self, Self::SensorError(_)) || matches!(self, Self::BoardError(_)) | ||||
| #[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<f32> { | ||||
|         if let HumiditySensorState::HumidityValue { | ||||
|             raw_hz, | ||||
|         if let MoistureSensorState::MoistureValue { | ||||
|             raw_hz: _, | ||||
|             moisture_percent, | ||||
|         } = self | ||||
|         { | ||||
| @@ -43,7 +45,7 @@ impl HumiditySensorState { | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl HumiditySensorState {} | ||||
| impl MoistureSensorState {} | ||||
|  | ||||
| #[derive(Debug, PartialEq, Serialize)] | ||||
| pub enum PumpError { | ||||
| @@ -59,30 +61,41 @@ pub struct PumpState { | ||||
|     previous_pump: Option<DateTime<Utc>>, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] | ||||
| impl PumpState { | ||||
|     fn is_err(&self, plant_config: &PlantConfig) -> Option<PumpError> { | ||||
|         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 enum PlantError {} | ||||
|  | ||||
| pub struct PlantState { | ||||
|     pub sensor_a: HumiditySensorState, | ||||
|     pub sensor_b: HumiditySensorState, | ||||
|     pub sensor_a: MoistureSensorState, | ||||
|     pub sensor_b: MoistureSensorState, | ||||
|     pub pump: PumpState, | ||||
| } | ||||
|  | ||||
| fn map_range_moisture(s: f32) -> Result<f32, HumiditySensorError> { | ||||
| fn map_range_moisture(s: f32) -> Result<f32, MoistureSensorError> { | ||||
|     if s < MOIST_SENSOR_MIN_FREQUENCY { | ||||
|         return Err(HumiditySensorError::OpenLoop { | ||||
|         return Err(MoistureSensorError::OpenLoop { | ||||
|             hz: s, | ||||
|             min: MOIST_SENSOR_MIN_FREQUENCY, | ||||
|         }); | ||||
|     } | ||||
|     if s > MOIST_SENSOR_MAX_FREQUENCY { | ||||
|         return Err(HumiditySensorError::ShortCircuit { | ||||
|         return Err(MoistureSensorError::ShortCircuit { | ||||
|             hz: s, | ||||
|             max: MOIST_SENSOR_MAX_FREQUENCY, | ||||
|         }); | ||||
| @@ -102,30 +115,34 @@ impl PlantState { | ||||
|         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) { | ||||
|                     Ok(moisture_percent) => HumiditySensorState::HumidityValue { | ||||
|                     Ok(moisture_percent) => MoistureSensorState::MoistureValue { | ||||
|                         raw_hz: raw, | ||||
|                         moisture_percent, | ||||
|                     }, | ||||
|                     Err(err) => HumiditySensorState::SensorError(err), | ||||
|                     Err(err) => MoistureSensorState::SensorError(err), | ||||
|                 }, | ||||
|                 Err(err) => HumiditySensorState::BoardError(err.to_string()), | ||||
|                 Err(err) => MoistureSensorState::SensorError(MoistureSensorError::BoardError( | ||||
|                     err.to_string(), | ||||
|                 )), | ||||
|             } | ||||
|         } else { | ||||
|             HumiditySensorState::Disabled | ||||
|             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) { | ||||
|                     Ok(moisture_percent) => HumiditySensorState::HumidityValue { | ||||
|                     Ok(moisture_percent) => MoistureSensorState::MoistureValue { | ||||
|                         raw_hz: raw, | ||||
|                         moisture_percent, | ||||
|                     }, | ||||
|                     Err(err) => HumiditySensorState::SensorError(err), | ||||
|                     Err(err) => MoistureSensorState::SensorError(err), | ||||
|                 }, | ||||
|                 Err(err) => HumiditySensorState::BoardError(err.to_string()), | ||||
|                 Err(err) => MoistureSensorState::SensorError(MoistureSensorError::BoardError( | ||||
|                     err.to_string(), | ||||
|                 )), | ||||
|             } | ||||
|         } else { | ||||
|             HumiditySensorState::Disabled | ||||
|             MoistureSensorState::Disabled | ||||
|         }; | ||||
|         let previous_pump = board.last_pump_time(plant_id); | ||||
|         let consecutive_pump_count = board.consecutive_pump_count(plant_id); | ||||
| @@ -144,6 +161,9 @@ impl PlantState { | ||||
|     } | ||||
|  | ||||
|     pub fn pump_in_timeout(&self, plant_conf: &PlantConfig, current_time: &DateTime<Tz>) -> 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())) | ||||
| @@ -154,7 +174,26 @@ impl PlantState { | ||||
|     } | ||||
|  | ||||
|     pub fn is_err(&self) -> bool { | ||||
|         self.sensor_a.is_err() || self.sensor_b.is_err() | ||||
|         self.sensor_a.is_err().is_some() || self.sensor_b.is_err().is_some() | ||||
|     } | ||||
|  | ||||
|     pub fn plant_moisture( | ||||
|         &self, | ||||
|     ) -> ( | ||||
|         Option<f32>, | ||||
|         (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( | ||||
| @@ -165,18 +204,8 @@ impl PlantState { | ||||
|         match plant_conf.mode { | ||||
|             PlantWateringMode::OFF => false, | ||||
|             PlantWateringMode::TargetMoisture => { | ||||
|                 let moisture_percent = match ( | ||||
|                     self.sensor_a.moisture_percent(), | ||||
|                     self.sensor_b.moisture_percent(), | ||||
|                 ) { | ||||
|                     (Some(moisture_a), Some(moisture_b)) => (moisture_a + moisture_b) / 2., | ||||
|                     (Some(moisture_percent), _) => moisture_percent, | ||||
|                     (_, Some(moisture_percent)) => moisture_percent, | ||||
|                     _ => { | ||||
|                         // Case for both sensors hitting an error do not water plant in this case | ||||
|                         return false; | ||||
|                     } | ||||
|                 }; | ||||
|                 let (moisture_percent, _) = self.plant_moisture(); | ||||
|                 if let Some(moisture_percent) = moisture_percent { | ||||
|                     if self.pump_in_timeout(plant_conf, current_time) { | ||||
|                         false | ||||
|                     } else { | ||||
| @@ -186,6 +215,10 @@ impl PlantState { | ||||
|                             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) { | ||||
| @@ -196,61 +229,53 @@ impl PlantState { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| //fn update_plant_state( | ||||
| //    plantstate: &mut [PlantInfo; PLANT_COUNT], | ||||
| //    board: &mut std::sync::MutexGuard<'_, PlantCtrlBoard<'_>>, | ||||
| //    config: &PlantControllerConfig, | ||||
| //) { | ||||
| //    for plant in 0..PLANT_COUNT { | ||||
| //        let state = &plantstate[plant]; | ||||
| //        let plant_config = &config.plants[plant]; | ||||
| // | ||||
| //        let mode = format!("{:?}", plant_config.mode); | ||||
| // | ||||
| //        let plant_dto = PlantStateMQTT { | ||||
| //            a: &sensor_to_string( | ||||
| //                &state.a, | ||||
| //                &state.sensor_error_a, | ||||
| //                plant_config.mode != PlantWateringMode::OFF, | ||||
| //            ), | ||||
| //            a_raw: &state.a_raw.unwrap_or(0).to_string(), | ||||
| //            b: &sensor_to_string(&state.b, &state.sensor_error_b, plant_config.sensor_b), | ||||
| //            b_raw: &state.b_raw.unwrap_or(0).to_string(), | ||||
| //            active: state.active, | ||||
| //            mode: &mode, | ||||
| //            last_pump: &time_to_string_utc(board.last_pump_time(plant)), | ||||
| //            next_pump: &time_to_string(state.next_pump), | ||||
| //            consecutive_pump_count: state.consecutive_pump_count, | ||||
| //            cooldown: state.cooldown, | ||||
| //            dry: state.dry, | ||||
| //            not_effective: state.not_effective, | ||||
| //            out_of_work_hour: state.out_of_work_hour, | ||||
| //            pump_error: state.pump_error, | ||||
| //        }; | ||||
| // | ||||
| //        match serde_json::to_string(&plant_dto) { | ||||
| //            Ok(state) => { | ||||
| //                let plant_topic = format!("/plant{}", plant + 1); | ||||
| //                let _ = board.mqtt_publish(&config, &plant_topic, state.as_bytes()); | ||||
| //                //reduce speed as else messages will be dropped | ||||
| //                Delay::new_default().delay_ms(200); | ||||
| //            } | ||||
| //            Err(err) => { | ||||
| //                println!("Error publishing lightstate {}", err); | ||||
| //            } | ||||
| //        }; | ||||
| //    } | ||||
| //} | ||||
|     pub fn to_mqtt_info(&self, plant_conf: &PlantConfig, current_time: &DateTime<Tz>) -> 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 { | ||||
| pub struct PlantInfo<'a> { | ||||
|     /// state of humidity sensor on bank a | ||||
|     sensor_a: HumiditySensorState, | ||||
|     sensor_a: &'a MoistureSensorState, | ||||
|     /// state of humidity sensor on bank b | ||||
|     sensor_b: HumiditySensorState, | ||||
|     sensor_b: &'a MoistureSensorState, | ||||
|     /// configured plant watering mode | ||||
|     mode: PlantWateringMode, | ||||
|     /// plant needs to be watered | ||||
| @@ -259,13 +284,9 @@ pub struct PlantInfo { | ||||
|     dry: bool, | ||||
|     /// plant irrigation cooldown is active | ||||
|     cooldown: bool, | ||||
|     /// we want to irrigate but tank is empty | ||||
|     no_water: 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, | ||||
|     /// is pump currently running | ||||
|     active: bool, | ||||
|     /// how often has the logic determined that plant should have been irrigated but wasn't | ||||
|     /// how often has the pump been watered without reaching target moisture | ||||
|     consecutive_pump_count: u32, | ||||
|     pump_error: Option<PumpError>, | ||||
|     /// last time when pump was active | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
|  | ||||
| pub trait LimitPrecision { | ||||
|     fn to_precision(self, presision: i32) -> Self; | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user