From c9a96f37f0443bea0b6e4ab2c068156ab3e33516 Mon Sep 17 00:00:00 2001 From: Empire Date: Fri, 29 May 2026 11:22:12 +0200 Subject: [PATCH] feat: add sensor combine mode with Min, Max, and Avg options, update web UI and configuration for multi-sensor support --- Software/MainBoard/rust/src/config.rs | 15 +++ Software/MainBoard/rust/src/plant_state.rs | 94 +++++++++++++------ .../MainBoard/rust/src_webpack/src/api.ts | 1 + .../MainBoard/rust/src_webpack/src/plant.html | 11 +++ .../MainBoard/rust/src_webpack/src/plant.ts | 27 ++++++ 5 files changed, 120 insertions(+), 28 deletions(-) diff --git a/Software/MainBoard/rust/src/config.rs b/Software/MainBoard/rust/src/config.rs index 97abef8..5bb52e7 100644 --- a/Software/MainBoard/rust/src/config.rs +++ b/Software/MainBoard/rust/src/config.rs @@ -112,6 +112,19 @@ pub struct PlantControllerConfig { pub timezone: Option, } +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub enum SensorCombineMode { + Min, + Max, + Avg, +} + +impl Default for SensorCombineMode { + fn default() -> Self { + SensorCombineMode::Avg + } +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(default)] pub struct PlantConfig { @@ -133,6 +146,7 @@ pub struct PlantConfig { pub ignore_current_error: bool, pub fertilizer_s: u16, pub fertilizer_cooldown_min: u16, + pub sensor_combine_mode: SensorCombineMode, } impl Default for PlantConfig { @@ -156,6 +170,7 @@ impl Default for PlantConfig { ignore_current_error: true, fertilizer_s: 0, fertilizer_cooldown_min: 1440, // 1 day default + sensor_combine_mode: SensorCombineMode::Avg, } } } diff --git a/Software/MainBoard/rust/src/plant_state.rs b/Software/MainBoard/rust/src/plant_state.rs index 5092d29..bfa62e4 100644 --- a/Software/MainBoard/rust/src/plant_state.rs +++ b/Software/MainBoard/rust/src/plant_state.rs @@ -3,10 +3,7 @@ use crate::{config::PlantConfig, hal::HAL, in_time_range}; use chrono::{DateTime, TimeDelta, Utc}; use chrono_tz::Tz; use serde::{Deserialize, Serialize}; - -// Embedded environments may not have floating-point math functions. -// For no_std with k=0.5 (square root), we use Newton's method approximation. -// Formula: sqrt(t) ≈ iterative refinement for better wet-range discrimination. +use crate::config::SensorCombineMode; 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 @@ -113,8 +110,8 @@ pub struct PlantState { pub sensor_a_firmware_build_minutes: Option, /// Last known firmware build timestamp for sensor B. pub sensor_b_firmware_build_minutes: Option, - /// Last time fertilizer was applied (Unix timestamp in seconds). - pub last_fertilizer_time: i64, + /// Last time fertilizer was applied. + pub last_fertilizer_time: Option>, } /// Map sensor frequency to moisture percentage using inverse power-law scaling (quadratic). @@ -228,8 +225,12 @@ impl PlantState { 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_time = board.board_hal.get_esp().last_fertilizer_time(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, @@ -242,7 +243,17 @@ impl PlantState { sensor_b_firmware_build_minutes: b_builds[plant_id], last_fertilizer_time, }; - if state.is_err() { + + // 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 @@ -265,27 +276,36 @@ impl PlantState { self.sensor_a.is_err().is_some() || self.sensor_b.is_err().is_some() } - pub fn plant_moisture( + /// 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, - ) -> ( - Option, - (Option<&MoistureSensorError>, Option<&MoistureSensorError>), - ) { - match ( + plant_conf: &PlantConfig, + ) -> Option + { + let moisture = 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)) + 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_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())), - } + (Some(moisture), _) => Some(moisture), + (_, Some(moisture)) => Some(moisture), + _ => None, + }; + + + moisture + } pub fn needs_to_be_watered( @@ -296,7 +316,7 @@ impl PlantState { match plant_conf.mode { PlantWateringMode::Off => false, PlantWateringMode::TargetMoisture => { - let (moisture_percent, _) = self.plant_moisture(); + 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 @@ -327,7 +347,7 @@ impl PlantState { plant_conf: &PlantConfig, current_time: &DateTime, ) -> PlantInfo { - let (moisture_pct, _) = self.plant_moisture(); + let moisture_pct = self.plant_moisture_with_warning(plant_conf); PlantInfo { moisture_pct, sensor_a: Self::sensor_to_telemetry(&self.sensor_a), @@ -365,9 +385,25 @@ impl PlantState { } 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, - last_fertilizer_time: self.last_fertilizer_time, } } @@ -423,10 +459,12 @@ pub struct PlantInfo { last_pump: Option>, /// next time when pump should activate next_pump: Option>, + /// last time when fertilizer was applied + last_fertilizer: Option>, + /// next time when fertilizer should be applied + next_fertilizer: Option>, /// firmware build timestamp of sensor A (minutes since Unix epoch); None if unknown sensor_a_firmware_build_minutes: Option, /// firmware build timestamp of sensor B (minutes since Unix epoch); None if unknown sensor_b_firmware_build_minutes: Option, - /// last time when fertilizer was applied - last_fertilizer_time: i64, } diff --git a/Software/MainBoard/rust/src_webpack/src/api.ts b/Software/MainBoard/rust/src_webpack/src/api.ts index 6436f4d..34307f4 100644 --- a/Software/MainBoard/rust/src_webpack/src/api.ts +++ b/Software/MainBoard/rust/src_webpack/src/api.ts @@ -141,6 +141,7 @@ export interface PlantConfig { min_pump_current_ma: number, max_pump_current_ma: number, ignore_current_error: boolean, + sensor_combine_mode: string, } export interface PumpTestResult { diff --git a/Software/MainBoard/rust/src_webpack/src/plant.html b/Software/MainBoard/rust/src_webpack/src/plant.html index 1ef21b6..371e3b6 100644 --- a/Software/MainBoard/rust/src_webpack/src/plant.html +++ b/Software/MainBoard/rust/src_webpack/src/plant.html @@ -29,6 +29,9 @@ .plantSensorEnabledOnly_ ${plantId} { } + .plantBothSensorsOnly_ ${plantId} { + } + .plantHidden_ ${plantId} { display: none; } @@ -48,6 +51,14 @@
Sensor B installed:
+
+
Sensor Combine Mode:
+ +
Mode: diff --git a/Software/MainBoard/rust/src_webpack/src/plant.ts b/Software/MainBoard/rust/src_webpack/src/plant.ts index 4472488..a0cc4de 100644 --- a/Software/MainBoard/rust/src_webpack/src/plant.ts +++ b/Software/MainBoard/rust/src_webpack/src/plant.ts @@ -93,6 +93,7 @@ export class PlantView { private readonly pumpHourEnd: HTMLSelectElement; private readonly sensorAInstalled: HTMLInputElement; private readonly sensorBInstalled: HTMLInputElement; + private readonly sensorCombineMode: HTMLSelectElement; private readonly mode: HTMLSelectElement; private readonly moistureA: HTMLElement; private readonly moistureB: HTMLElement; @@ -236,6 +237,14 @@ export class PlantView { controller.configChanged() } + this.sensorCombineMode = document.getElementById("plant_" + plantId + "_sensor_combine_mode") as HTMLSelectElement; + this.sensorCombineMode.onchange = function () { + controller.configChanged() + } + + // Initial visibility update for sensor combine mode + this.updateSensorCombineModeState(); + this.minPumpCurrentMa = document.getElementById("plant_" + plantId + "_min_pump_current_ma") as HTMLInputElement; this.minPumpCurrentMa.onchange = function () { controller.configChanged() @@ -271,6 +280,19 @@ export class PlantView { }; } + updateSensorCombineModeState() { + const bothActive = this.sensorAInstalled.checked && this.sensorBInstalled.checked; + const bothOnlyElements = document.getElementsByClassName("plantBothSensorsOnly_" + this.plantId); + for (const element of Array.from(bothOnlyElements)) { + if (bothActive) { + element.classList.remove("plantHidden_" + this.plantId); + } else { + element.classList.add("plantHidden_" + this.plantId); + } + } + this.sensorCombineMode.disabled = !bothActive; + } + updateVisibility(plantConfig: PlantConfig) { let sensorOnly = document.getElementsByClassName("plantSensorEnabledOnly_" + this.plantId) let pumpOnly = document.getElementsByClassName("plantPumpEnabledOnly_" + this.plantId) @@ -324,6 +346,9 @@ export class PlantView { // element.classList.add("plantHidden_" + this.plantId) // } // } + + // Update sensor combine mode visibility based on whether both sensors are active + this.updateSensorCombineModeState(); } setTestResult(result: PumpTestResult) { @@ -354,6 +379,7 @@ export class PlantView { this.pumpHourEnd.value = plantConfig.pump_hour_end.toString(); this.sensorBInstalled.checked = plantConfig.sensor_b; this.sensorAInstalled.checked = plantConfig.sensor_a; + this.sensorCombineMode.value = plantConfig.sensor_combine_mode || "Min"; this.maxConsecutivePumpCount.value = plantConfig.max_consecutive_pump_count.toString(); this.minPumpCurrentMa.value = plantConfig.min_pump_current_ma.toString(); this.maxPumpCurrentMa.value = plantConfig.max_pump_current_ma.toString(); @@ -383,6 +409,7 @@ export class PlantView { pump_hour_end: +this.pumpHourEnd.value, sensor_b: this.sensorBInstalled.checked, sensor_a: this.sensorAInstalled.checked, + sensor_combine_mode: this.sensorCombineMode.value, max_consecutive_pump_count: this.maxConsecutivePumpCount.valueAsNumber, moisture_sensor_min_frequency: this.moistureSensorMinFrequency.valueAsNumber || null, moisture_sensor_max_frequency: this.moistureSensorMaxFrequency.valueAsNumber || null,