Files
PlantCtrl/Software/MainBoard/rust/src/plant_state.rs
2026-05-30 20:59:58 +02:00

470 lines
17 KiB
Rust

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<f32> {
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<f32>,
pub raw_hz: Option<f32>,
pub error: Option<MoistureSensorError>,
}
#[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<DateTime<Utc>>,
pub overcurrent_error: Option<u16>,
}
impl PumpState {
fn is_err(&self, plant_config: &PlantConfig) -> Option<PumpError> {
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<u32>,
/// Last known firmware build timestamp for sensor B.
pub sensor_b_firmware_build_minutes: Option<u32>,
/// Last time fertilizer was applied.
pub last_fertilizer_time: Option<DateTime<Utc>>,
}
/// 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<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,
});
}
// 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<f32>, 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<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()
}
/// 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<f32> {
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<Tz>,
) -> 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<Tz>) -> 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(&current_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(&current_time.timezone()))
})
} else {
None
},
last_fertilizer: self
.last_fertilizer_time
.map(|t| t.with_timezone(&current_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(&current_time.timezone());
tz_last_fert
.checked_add_signed(TimeDelta::minutes(
plant_conf.fertilizer_cooldown_min.into(),
))
.map(|t| t.with_timezone(&current_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<f32>,
/// moisture target, if in targetmode
target_pct: Option<f32>,
/// 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<PumpError>,
/// last time when the pump was active
last_pump: Option<DateTime<Tz>>,
/// next time when pump should activate
next_pump: Option<DateTime<Tz>>,
/// last time when fertilizer was applied
last_fertilizer: Option<DateTime<Tz>>,
/// next time when fertilizer should be applied
next_fertilizer: Option<DateTime<Tz>>,
/// firmware build timestamp of sensor A (minutes since Unix epoch); None if unknown
sensor_a_firmware_build_minutes: Option<u32>,
/// firmware build timestamp of sensor B (minutes since Unix epoch); None if unknown
sensor_b_firmware_build_minutes: Option<u32>,
}