317 lines
10 KiB
Rust
317 lines
10 KiB
Rust
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<f32> {
|
|
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<DateTime<Utc>>,
|
|
}
|
|
|
|
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 struct PlantState {
|
|
pub sensor_a: MoistureSensorState,
|
|
pub sensor_b: MoistureSensorState,
|
|
pub pump: PumpState,
|
|
}
|
|
|
|
fn map_range_moisture(
|
|
s: f32,
|
|
min_frequency: Option<f32>,
|
|
max_frequency: Option<f32>,
|
|
) -> Result<f32, MoistureSensorError> {
|
|
// 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<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()))
|
|
.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<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(
|
|
&self,
|
|
plant_conf: &PlantConfig,
|
|
current_time: &DateTime<Tz>,
|
|
) -> 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<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<'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<PumpError>,
|
|
/// last time when pump was active
|
|
last_pump: Option<DateTime<Tz>>,
|
|
/// next time when pump should activate
|
|
next_pump: Option<DateTime<Tz>>,
|
|
} |