diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 73e096d..593cc2c 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -77,7 +77,7 @@ serde = { version = "1.0.192", features = ["derive"] } serde_json = "1.0.108" #timezone -chrono = { version = "0.4.23", default-features = false , features = ["iana-time-zone" , "alloc"] } +chrono = { version = "0.4.23", default-features = false , features = ["iana-time-zone" , "alloc", "serde"] } chrono-tz = {version="0.8.0", default-features = false , features = [ "filter-by-regex" ]} eeprom24x = "0.7.2" url = "2.5.3" diff --git a/rust/build.rs b/rust/build.rs index b0169e5..1499088 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -3,39 +3,39 @@ use std::process::Command; use vergen::EmitBuilder; fn main() { println!("cargo:rerun-if-changed=./src/src_webpack"); - Command::new("rm") - .arg("./src/webserver/bundle.js") - .output() - .unwrap(); - - match Command::new("cmd").spawn() { - Ok(_) => { - println!("Assuming build on windows"); - let output = Command::new("cmd") - .arg("/K") - .arg("npx") - .arg("webpack") - .current_dir("./src_webpack") - .output() - .unwrap(); - println!("status: {}", output.status); - println!("stdout: {}", String::from_utf8_lossy(&output.stdout)); - println!("stderr: {}", String::from_utf8_lossy(&output.stderr)); - assert!(output.status.success()); + Command::new("rm") + .arg("./src/webserver/bundle.js") + .output() + .unwrap(); + + match Command::new("cmd").spawn() { + Ok(_) => { + println!("Assuming build on windows"); + let output = Command::new("cmd") + .arg("/K") + .arg("npx") + .arg("webpack") + .current_dir("./src_webpack") + .output() + .unwrap(); + println!("status: {}", output.status); + println!("stdout: {}", String::from_utf8_lossy(&output.stdout)); + println!("stderr: {}", String::from_utf8_lossy(&output.stderr)); + assert!(output.status.success()); + } + Err(_) => { + println!("Assuming build on linux"); + let output = Command::new("npx") + .arg("webpack") + .current_dir("./src_webpack") + .output() + .unwrap(); + println!("status: {}", output.status); + println!("stdout: {}", String::from_utf8_lossy(&output.stdout)); + println!("stderr: {}", String::from_utf8_lossy(&output.stderr)); + assert!(output.status.success()); + } } - Err(_) => { - println!("Assuming build on linux"); - let output = Command::new("npx") - .arg("webpack") - .current_dir("./src_webpack") - .output() - .unwrap(); - println!("status: {}", output.status); - println!("stdout: {}", String::from_utf8_lossy(&output.stdout)); - println!("stderr: {}", String::from_utf8_lossy(&output.stderr)); - assert!(output.status.success()); - } - } embuild::espidf::sysenv::output(); let _ = EmitBuilder::builder().all_git().all_build().emit(); diff --git a/rust/src/config.rs b/rust/src/config.rs index 0daaf48..f716189 100644 --- a/rust/src/config.rs +++ b/rust/src/config.rs @@ -2,6 +2,7 @@ use std::str::FromStr; use serde::{Deserialize, Serialize}; +use crate::plant_state::PlantWateringMode; use crate::PLANT_COUNT; #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] @@ -83,34 +84,28 @@ pub struct PlantControllerConfig { #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(default)] pub struct PlantConfig { - pub mode: Mode, - pub target_moisture: u8, + pub mode: PlantWateringMode, + pub target_moisture: f32, pub pump_time_s: u16, pub pump_cooldown_min: u16, pub pump_hour_start: u8, pub pump_hour_end: u8, + pub sensor_a: bool, pub sensor_b: bool, pub max_consecutive_pump_count: u8, } impl Default for PlantConfig { fn default() -> Self { Self { - mode: Mode::OFF, - target_moisture: 40, + mode: PlantWateringMode::OFF, + target_moisture: 40., pump_time_s: 30, pump_cooldown_min: 60, pump_hour_start: 9, pump_hour_end: 20, + sensor_a: true, sensor_b: false, max_consecutive_pump_count: 10, } } } - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] -pub enum Mode { - OFF, - TargetMoisture, - TimerOnly, - TimerAndDeadzone, -} diff --git a/rust/src/main.rs b/rust/src/main.rs index f1d6bbc..20d7161 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -4,10 +4,9 @@ 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 config::Mode; use esp_idf_hal::delay::Delay; use esp_idf_sys::{ esp_ota_get_app_partition_count, esp_ota_get_running_partition, esp_ota_get_state_partition, @@ -26,21 +25,15 @@ use crate::{config::PlantControllerConfig, webserver::webserver::httpd}; mod config; mod log; pub mod plant_hal; +mod plant_state; mod tank; +pub mod util; +use plant_state::PlantState; use tank::*; const TIME_ZONE: Tz = Berlin; -const MOIST_SENSOR_MAX_FREQUENCY: u32 = 6500; // 60kHz (500Hz margin) -const MOIST_SENSOR_MIN_FREQUENCY: u32 = 150; // this is really really dry, think like cactus levels - -const FROM: (f32, f32) = ( - MOIST_SENSOR_MIN_FREQUENCY as f32, - MOIST_SENSOR_MAX_FREQUENCY as f32, -); -const TO: (f32, f32) = (0_f32, 100_f32); - pub static BOARD_ACCESS: Lazy> = Lazy::new(|| PlantHal::create().unwrap()); pub static STAY_ALIVE: Lazy = Lazy::new(|| AtomicBool::new(false)); @@ -80,46 +73,6 @@ struct LightState { is_day: bool, } -#[derive(Debug, PartialEq, Default)] -/// State of a single plant to be tracked -/// -/// TODO can some state be replaced with functions -/// TODO unify with PlantStateMQTT -struct PlantState { - /// state of humidity sensor on bank a - a: Option, - /// raw measured frequency value for sensor on bank a in hertz - a_raw: Option, - /// state of humidity sensor on bank b - b: Option, - /// raw measured frequency value for sensor on bank b in hertz - b_raw: Option, - /// how often has the logic determined that plant should have been irrigated but wasn't - consecutive_pump_count: u32, - /// plant needs to be watered - do_water: bool, - /// is plant considerd to be dry according to settings - dry: bool, - /// is pump currently running - active: bool, - /// TODO: convert this to an Option enum for every case that can happen - pump_error: bool, - /// if pump count has increased higher than configured limit - not_effective: bool, - /// plant irrigation cooldown is active - cooldown: bool, - /// we want to irrigate but tank is empty - no_water: bool, - ///TODO: combine with field a using Result - sensor_error_a: Option, - ///TODO: combine with field b using Result - sensor_error_b: Option, - /// pump should not be watered at this time of day - out_of_work_hour: bool, - /// next time when pump should activate - next_pump: Option>, -} - #[derive(Serialize, Deserialize, Debug, PartialEq)] /// humidity sensor error enum SensorError { @@ -128,24 +81,6 @@ enum SensorError { OpenCircuit { hz: f32, min: f32 }, } -#[derive(Serialize)] -struct PlantStateMQTT<'a> { - a: &'a str, - a_raw: &'a str, - b: &'a str, - b_raw: &'a str, - mode: &'a str, - consecutive_pump_count: u32, - dry: bool, - active: bool, - pump_error: bool, - not_effective: bool, - cooldown: bool, - out_of_work_hour: bool, - last_pump: &'a str, - next_pump: &'a str, -} - fn safe_main() -> anyhow::Result<()> { // It is necessary to call this function once. Otherwise some patches to the runtime // implemented by esp-idf-sys might not link properly. See https://github.com/esp-rs/esp-idf-template/issues/71 @@ -471,61 +406,70 @@ fn safe_main() -> anyhow::Result<()> { } }; - let mut plantstate: [PlantState; PLANT_COUNT] = core::array::from_fn(|_| PlantState { - ..Default::default() - }); - determine_plant_state( - &mut plantstate, - timezone_time, - &tank_state, - &config, - &mut board, - ); + 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().any(|it| it.do_water) && !water_frozen; + let pump_required = plantstate + .iter() + .zip(&config.plants) + .any(|(it, conf)| it.needs_to_be_watered(&conf, &timezone_time)) + && !water_frozen; if pump_required { log(log::LogMessage::EnableMain, dry_run as u32, 0, "", ""); if !dry_run { - board.any_pump(true)?; + board.any_pump(true)?; // what does this do? Does it need to be reset? } - for plant in 0..PLANT_COUNT { - let state = &mut plantstate[plant]; - if state.do_water { - let plant_config = &config.plants[plant]; - state.consecutive_pump_count = board.consecutive_pump_count(plant) + 1; - board.store_consecutive_pump_count(plant, state.consecutive_pump_count); - if state.consecutive_pump_count > plant_config.max_consecutive_pump_count as u32 { - log( - log::LogMessage::ConsecutivePumpCountLimit, - state.consecutive_pump_count as u32, - plant_config.max_consecutive_pump_count as u32, - &plant.to_string(), - "", - ); - state.not_effective = true; - board.fault(plant, true); - } + for (plant_id, (state, plant_config)) in plantstate.iter().zip(&config.plants).enumerate() { + if state.needs_to_be_watered(&plant_config, &timezone_time) { + let pump_count = board.consecutive_pump_count(plant_id) + 1; + board.store_consecutive_pump_count(plant_id, pump_count); + //TODO(judge) where to put this? + //if state.consecutive_pump_count > plant_config.max_consecutive_pump_count as u32 { + // log( + // log::LogMessage::ConsecutivePumpCountLimit, + // state.consecutive_pump_count as u32, + // plant_config.max_consecutive_pump_count as u32, + // &plant.to_string(), + // "", + // ); + // state.not_effective = true; + // board.fault(plant, true); + //} log( log::LogMessage::PumpPlant, - (plant + 1) as u32, + (plant_id + 1) as u32, plant_config.pump_time_s as u32, &dry_run.to_string(), "", ); - board.store_last_pump_time(plant, cur); - board.last_pump_time(plant); - state.active = true; + board.store_last_pump_time(plant_id, cur); + board.last_pump_time(plant_id); + //state.active = true; if !dry_run { - board.pump(plant, true)?; - for _ in 0..plant_config.pump_time_s { - Delay::new_default().delay_ms(1000); - } - board.pump(plant, false)?; + board.pump(plant_id, true)?; + Delay::new_default().delay_ms(1000 * plant_config.pump_time_s as u32); + board.pump(plant_id, false)?; } + } else if !state.pump_in_timeout(&plant_config, &timezone_time) { + // plant does not need to be watered and is not in timeout + // -> reset consecutive pump count + board.store_consecutive_pump_count(plant_id, 0); } } } - 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); @@ -633,279 +577,6 @@ fn publish_battery_state( }; } -fn determine_state_target_moisture_for_plant( - board: &mut std::sync::MutexGuard<'_, PlantCtrlBoard<'_>>, - plant: usize, - state: &mut PlantState, - config: &PlantControllerConfig, - tank_state: &TankState, - cur: DateTime, -) { - let plant_config = &config.plants[plant]; - if plant_config.mode == Mode::OFF { - return; - } - match board.measure_moisture_hz(plant, plant_hal::Sensor::A) { - Ok(a) => { - state.a_raw = Some(a); - let mapped = map_range_moisture(a as f32); - match mapped { - Ok(result) => state.a = Some(result), - Err(err) => { - state.sensor_error_a = Some(err); - } - } - } - Err(_) => { - state.sensor_error_a = Some(SensorError::Unknown); - } - } - if plant_config.sensor_b { - match board.measure_moisture_hz(plant, plant_hal::Sensor::B) { - Ok(b) => { - state.b_raw = Some(b); - let mapped = map_range_moisture(b as f32); - match mapped { - Ok(result) => state.b = Some(result), - Err(err) => { - state.sensor_error_b = Some(err); - } - } - } - Err(_) => { - state.sensor_error_b = Some(SensorError::Unknown); - } - } - } - - //FIXME how to average analyze whatever? - let a_low = state.a.is_some() && state.a.unwrap() < plant_config.target_moisture; - let b_low = state.b.is_some() && state.b.unwrap() < plant_config.target_moisture; - - if a_low || b_low { - state.dry = true; - match tank_state.enough_water(&config.tank) { - Err(_tank_err) => { - if !config.tank.tank_allow_pumping_if_sensor_error { - state.no_water = true; - } - } - // when no tank error, if plant should be watered depends on if enough water is in tank - // no_water behaves inversly to enough_water - Ok(enough_water) => state.no_water = !enough_water, - } - } - let duration = TimeDelta::try_minutes(plant_config.pump_cooldown_min as i64).unwrap(); - let last_pump = board.last_pump_time(plant); - match last_pump { - Some(last_pump) => { - let next_pump = last_pump + duration; - if next_pump > cur { - let local_time = next_pump.with_timezone(&TIME_ZONE); - state.next_pump = Some(local_time); - state.cooldown = true; - } - } - None => { - println!( - "Could not restore last pump for plant {}, restoring", - plant + 1 - ); - board.store_last_pump_time(plant, DateTime::from_timestamp_millis(0).unwrap()); - state.pump_error = true; - } - } - - if !in_time_range( - &cur, - plant_config.pump_hour_start, - plant_config.pump_hour_end, - ) { - state.out_of_work_hour = true; - } - if state.dry && !state.no_water && !state.cooldown && !state.out_of_work_hour { - state.do_water = true; - } -} - -fn determine_state_timer_only_for_plant( - board: &mut std::sync::MutexGuard<'_, PlantCtrlBoard<'_>>, - plant: usize, - state: &mut PlantState, - config: &PlantControllerConfig, - tank_state: &TankState, - cur: DateTime, -) { - let plant_config = &config.plants[plant]; - let duration = TimeDelta::try_minutes(plant_config.pump_cooldown_min as i64).unwrap(); - - let last_pump = board.last_pump_time(plant); - match last_pump { - Some(last_pump) => { - let next_pump = last_pump + duration; - if next_pump > cur { - let europe_time = next_pump.with_timezone(&TIME_ZONE); - state.next_pump = Some(europe_time); - state.cooldown = true; - } else { - match tank_state.enough_water(&config.tank) { - Err(_tank_err) => { - if !config.tank.tank_allow_pumping_if_sensor_error { - state.do_water = true; - } - } - Ok(enough_water) => { - state.no_water = !enough_water; - } - } - } - } - None => { - println!( - "Could not restore last pump for plant {}, restoring", - plant + 1 - ); - board.store_last_pump_time(plant, DateTime::from_timestamp_millis(0).unwrap()); - state.pump_error = true; - } - } -} - -fn determine_state_timer_and_deadzone_for_plant( - board: &mut std::sync::MutexGuard<'_, PlantCtrlBoard<'_>>, - plant: usize, - state: &mut PlantState, - config: &PlantControllerConfig, - tank_state: &TankState, - cur: DateTime, -) { - let plant_config = &config.plants[plant]; - let duration = TimeDelta::try_minutes(plant_config.pump_cooldown_min as i64).unwrap(); - - let last_pump = board.last_pump_time(plant); - match last_pump { - Some(last_pump) => { - let next_pump = last_pump + duration; - if next_pump > cur { - let europe_time = next_pump.with_timezone(&TIME_ZONE); - state.next_pump = Some(europe_time); - state.cooldown = true; - } - if !in_time_range( - &cur, - plant_config.pump_hour_start, - plant_config.pump_hour_end, - ) { - state.out_of_work_hour = true; - } - if !state.cooldown && !state.out_of_work_hour { - match tank_state.enough_water(&config.tank) { - Err(_tank_err) => { - if !config.tank.tank_allow_pumping_if_sensor_error { - state.do_water = true; - } - } - Ok(enough_water) => { - state.no_water = !enough_water; - } - } - } - } - None => { - println!( - "Could not restore last pump for plant {}, restoring", - plant + 1 - ); - board.store_last_pump_time(plant, DateTime::from_timestamp_millis(0).unwrap()); - state.pump_error = true; - } - } -} - -fn determine_plant_state( - plantstate: &mut [PlantState; PLANT_COUNT], - cur: DateTime, - tank_state: &TankState, - config: &PlantControllerConfig, - board: &mut std::sync::MutexGuard<'_, PlantCtrlBoard<'_>>, -) { - for plant in 0..PLANT_COUNT { - let state = &mut plantstate[plant]; - let plant_config = &config.plants[plant]; - match plant_config.mode { - config::Mode::OFF => {} - config::Mode::TargetMoisture => { - determine_state_target_moisture_for_plant( - board, plant, state, config, tank_state, cur, - ); - } - config::Mode::TimerOnly => { - determine_state_timer_only_for_plant(board, plant, state, config, tank_state, cur); - } - config::Mode::TimerAndDeadzone => { - determine_state_timer_and_deadzone_for_plant( - board, plant, state, config, tank_state, cur, - ); - } - } - - if state.sensor_error_a.is_some() || state.sensor_error_b.is_some() { - board.fault(plant, true); - } - if !state.dry { - state.consecutive_pump_count = 0; - board.store_consecutive_pump_count(plant, 0); - } - println!("Plant {} state is {:?}", plant, state); - } -} - -fn update_plant_state( - plantstate: &mut [PlantState; 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 != Mode::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); - } - }; - } -} - fn wait_infinity(wait_type: WaitType, reboot_now: Arc) -> ! { let delay = wait_type.blink_pattern(); @@ -963,40 +634,6 @@ fn main() { } } -fn time_to_string_utc(value_option: Option>) -> 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>) -> 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, error: &Option, 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(value: Result) -> String { return match value { Ok(v) => v.to_string(), @@ -1006,19 +643,7 @@ fn to_string(value: Result) -> String { }; } -fn map_range_moisture(s: f32) -> Result { - if s < FROM.0 { - return Err(SensorError::OpenCircuit { hz: s, min: FROM.0 }); - } - if s > FROM.1 { - return Err(SensorError::ShortCircuit { hz: s, max: FROM.1 }); - } - let tmp = TO.0 + (s - FROM.0) * (TO.1 - TO.0) / (FROM.1 - FROM.0); - - return Ok(tmp as u8); -} - -fn in_time_range(cur: &DateTime, start: u8, end: u8) -> bool { +pub fn in_time_range(cur: &DateTime, start: u8, end: u8) -> bool { let curhour = cur.hour() as u8; //eg 10-14 if start < end { diff --git a/rust/src/plant_hal.rs b/rust/src/plant_hal.rs index 9a4829e..7496caf 100644 --- a/rust/src/plant_hal.rs +++ b/rust/src/plant_hal.rs @@ -603,7 +603,7 @@ impl PlantCtrlBoard<'_> { self.time() } - pub fn measure_moisture_hz(&mut self, plant: usize, sensor: Sensor) -> Result { + pub fn measure_moisture_hz(&mut self, plant: usize, sensor: Sensor) -> Result { let sensor_channel = match sensor { Sensor::A => match plant { 0 => SENSOR_A_1, @@ -629,7 +629,7 @@ impl PlantCtrlBoard<'_> { }, }; - let mut results = [0_u32; REPEAT_MOIST_MEASURE]; + let mut results = [0_f32; REPEAT_MOIST_MEASURE]; for repeat in 0..REPEAT_MOIST_MEASURE { self.signal_counter.counter_pause()?; self.signal_counter.counter_clear()?; @@ -644,7 +644,7 @@ impl PlantCtrlBoard<'_> { .unwrap(); let delay = Delay::new_default(); - let measurement = 100; + let measurement = 100; // TODO what is this scaling factor? what is its purpose? let factor = 1000 as f32 / measurement as f32; //give some time to stabilize @@ -658,7 +658,7 @@ impl PlantCtrlBoard<'_> { .unwrap(); delay.delay_ms(10); let unscaled = self.signal_counter.get_counter_value()? as i32; - let hz = (unscaled as f32 * factor) as u32; + let hz = unscaled as f32 * factor; log( LogMessage::RawMeasure, unscaled as u32, @@ -668,7 +668,7 @@ impl PlantCtrlBoard<'_> { ); results[repeat] = hz; } - results.sort(); + results.sort_by(|a, b| a.partial_cmp(b).unwrap()); // floats don't seem to implement total_ord let mid = results.len() / 2; diff --git a/rust/src/plant_state.rs b/rust/src/plant_state.rs new file mode 100644 index 0000000..98b3fb1 --- /dev/null +++ b/rust/src/plant_state.rs @@ -0,0 +1,296 @@ +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 = 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 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) -> Result { + if s < MOIST_SENSOR_MIN_FREQUENCY { + return Err(MoistureSensorError::OpenLoop { + hz: s, + min: MOIST_SENSOR_MIN_FREQUENCY, + }); + } + if s > MOIST_SENSOR_MAX_FREQUENCY { + return Err(MoistureSensorError::ShortCircuit { + hz: s, + max: MOIST_SENSOR_MAX_FREQUENCY, + }); + } + let moisture_percent = (s - MOIST_SENSOR_MIN_FREQUENCY) * 100.0 + / (MOIST_SENSOR_MAX_FREQUENCY - MOIST_SENSOR_MIN_FREQUENCY); + + return 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) { + 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) { + 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 { + true + } 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>, +} diff --git a/rust/src/util.rs b/rust/src/util.rs new file mode 100644 index 0000000..3d46fb4 --- /dev/null +++ b/rust/src/util.rs @@ -0,0 +1,9 @@ +pub trait LimitPrecision { + fn to_precision(self, presision: i32) -> Self; +} + +impl LimitPrecision for f32 { + fn to_precision(self, precision: i32) -> Self { + (self * (10_f32).powi(precision)).round() / (10_f32).powi(precision) + } +} diff --git a/rust/src/webserver/webserver.rs b/rust/src/webserver/webserver.rs index 0fb1f6d..f47a0fa 100644 --- a/rust/src/webserver/webserver.rs +++ b/rust/src/webserver/webserver.rs @@ -1,8 +1,8 @@ //offer ota and config mode use crate::{ - determine_tank_state, get_version, log::LogMessage, map_range_moisture, plant_hal::PLANT_COUNT, - BOARD_ACCESS, + determine_tank_state, get_version, log::LogMessage, plant_hal::PLANT_COUNT, + plant_state::PlantState, util::LimitPrecision, BOARD_ACCESS, }; use anyhow::bail; use chrono::DateTime; @@ -35,8 +35,8 @@ struct LoadData<'a> { #[derive(Serialize, Debug)] struct Moistures { - moisture_a: Vec, - moisture_b: Vec, + moisture_a: Vec>, + moisture_b: Vec>, } #[derive(Deserialize, Debug)] @@ -81,33 +81,21 @@ fn get_live_moisture( _request: &mut Request<&mut EspHttpConnection>, ) -> Result, anyhow::Error> { let mut board = BOARD_ACCESS.lock().unwrap(); + let config = board.get_config().unwrap(); - let mut a: Vec = Vec::new(); - let mut b: Vec = Vec::new(); - for plant in 0..8 { - let a_hz = board.measure_moisture_hz(plant, crate::plant_hal::Sensor::A)?; - let b_hz = board.measure_moisture_hz(plant, crate::plant_hal::Sensor::B)?; - let a_pct = map_range_moisture(a_hz as f32); - - match a_pct { - Ok(result) => { - a.push(result); - } - Err(_) => { - a.push(200); - } - } - - let b_pct = map_range_moisture(b_hz as f32); - match b_pct { - Ok(result) => { - b.push(result); - } - Err(_) => { - b.push(200); - } - } - } + let plant_state = Vec::from_iter( + (0..PLANT_COUNT).map(|i| PlantState::read_hardware_state(i, &mut board, &config.plants[i])), + ); + let a = Vec::from_iter( + plant_state + .iter() + .map(|s| s.sensor_a.moisture_percent().map(|f| f.to_precision(2))), + ); + let b = Vec::from_iter( + plant_state + .iter() + .map(|s| s.sensor_b.moisture_percent().map(|f| f.to_precision(2))), + ); let data = Moistures { moisture_a: a,