470 lines
17 KiB
Rust
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(¤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<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>,
|
|
}
|