feat: add sensor combine mode with Min, Max, and Avg options, update web UI and configuration for multi-sensor support
This commit is contained in:
@@ -112,6 +112,19 @@ pub struct PlantControllerConfig {
|
|||||||
pub timezone: Option<String>,
|
pub timezone: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct PlantConfig {
|
pub struct PlantConfig {
|
||||||
@@ -133,6 +146,7 @@ pub struct PlantConfig {
|
|||||||
pub ignore_current_error: bool,
|
pub ignore_current_error: bool,
|
||||||
pub fertilizer_s: u16,
|
pub fertilizer_s: u16,
|
||||||
pub fertilizer_cooldown_min: u16,
|
pub fertilizer_cooldown_min: u16,
|
||||||
|
pub sensor_combine_mode: SensorCombineMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PlantConfig {
|
impl Default for PlantConfig {
|
||||||
@@ -156,6 +170,7 @@ impl Default for PlantConfig {
|
|||||||
ignore_current_error: true,
|
ignore_current_error: true,
|
||||||
fertilizer_s: 0,
|
fertilizer_s: 0,
|
||||||
fertilizer_cooldown_min: 1440, // 1 day default
|
fertilizer_cooldown_min: 1440, // 1 day default
|
||||||
|
sensor_combine_mode: SensorCombineMode::Avg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ use crate::{config::PlantConfig, hal::HAL, in_time_range};
|
|||||||
use chrono::{DateTime, TimeDelta, Utc};
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::config::SensorCombineMode;
|
||||||
// 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.
|
|
||||||
|
|
||||||
const MOIST_SENSOR_MAX_FREQUENCY: f32 = 160000.; // 160kHz -> very wet
|
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
|
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<u32>,
|
pub sensor_a_firmware_build_minutes: Option<u32>,
|
||||||
/// Last known firmware build timestamp for sensor B.
|
/// Last known firmware build timestamp for sensor B.
|
||||||
pub sensor_b_firmware_build_minutes: Option<u32>,
|
pub sensor_b_firmware_build_minutes: Option<u32>,
|
||||||
/// Last time fertilizer was applied (Unix timestamp in seconds).
|
/// Last time fertilizer was applied.
|
||||||
pub last_fertilizer_time: i64,
|
pub last_fertilizer_time: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map sensor frequency to moisture percentage using inverse power-law scaling (quadratic).
|
/// 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 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 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 (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 {
|
let state = Self {
|
||||||
sensor_a,
|
sensor_a,
|
||||||
sensor_b,
|
sensor_b,
|
||||||
@@ -242,7 +243,17 @@ impl PlantState {
|
|||||||
sensor_b_firmware_build_minutes: b_builds[plant_id],
|
sensor_b_firmware_build_minutes: b_builds[plant_id],
|
||||||
last_fertilizer_time,
|
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;
|
let _ = board.board_hal.fault(plant_id, true).await;
|
||||||
}
|
}
|
||||||
state
|
state
|
||||||
@@ -265,27 +276,36 @@ impl PlantState {
|
|||||||
self.sensor_a.is_err().is_some() || self.sensor_b.is_err().is_some()
|
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,
|
&self,
|
||||||
) -> (
|
plant_conf: &PlantConfig,
|
||||||
Option<f32>,
|
) -> Option<f32>
|
||||||
(Option<&MoistureSensorError>, Option<&MoistureSensorError>),
|
{
|
||||||
) {
|
let moisture = match (
|
||||||
match (
|
|
||||||
self.sensor_a.moisture_percent(),
|
self.sensor_a.moisture_percent(),
|
||||||
self.sensor_b.moisture_percent(),
|
self.sensor_b.moisture_percent(),
|
||||||
) {
|
) {
|
||||||
(Some(moisture_a), Some(moisture_b)) => {
|
(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(
|
pub fn needs_to_be_watered(
|
||||||
@@ -296,7 +316,7 @@ impl PlantState {
|
|||||||
match plant_conf.mode {
|
match plant_conf.mode {
|
||||||
PlantWateringMode::Off => false,
|
PlantWateringMode::Off => false,
|
||||||
PlantWateringMode::TargetMoisture => {
|
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 let Some(moisture_percent) = moisture_percent {
|
||||||
if self.pump_in_timeout(plant_conf, current_time) {
|
if self.pump_in_timeout(plant_conf, current_time) {
|
||||||
false
|
false
|
||||||
@@ -327,7 +347,7 @@ impl PlantState {
|
|||||||
plant_conf: &PlantConfig,
|
plant_conf: &PlantConfig,
|
||||||
current_time: &DateTime<Tz>,
|
current_time: &DateTime<Tz>,
|
||||||
) -> PlantInfo {
|
) -> PlantInfo {
|
||||||
let (moisture_pct, _) = self.plant_moisture();
|
let moisture_pct = self.plant_moisture_with_warning(plant_conf);
|
||||||
PlantInfo {
|
PlantInfo {
|
||||||
moisture_pct,
|
moisture_pct,
|
||||||
sensor_a: Self::sensor_to_telemetry(&self.sensor_a),
|
sensor_a: Self::sensor_to_telemetry(&self.sensor_a),
|
||||||
@@ -365,9 +385,25 @@ impl PlantState {
|
|||||||
} else {
|
} else {
|
||||||
None
|
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_a_firmware_build_minutes: self.sensor_a_firmware_build_minutes,
|
||||||
sensor_b_firmware_build_minutes: self.sensor_b_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<DateTime<Tz>>,
|
last_pump: Option<DateTime<Tz>>,
|
||||||
/// next time when pump should activate
|
/// next time when pump should activate
|
||||||
next_pump: Option<DateTime<Tz>>,
|
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
|
/// firmware build timestamp of sensor A (minutes since Unix epoch); None if unknown
|
||||||
sensor_a_firmware_build_minutes: Option<u32>,
|
sensor_a_firmware_build_minutes: Option<u32>,
|
||||||
/// firmware build timestamp of sensor B (minutes since Unix epoch); None if unknown
|
/// firmware build timestamp of sensor B (minutes since Unix epoch); None if unknown
|
||||||
sensor_b_firmware_build_minutes: Option<u32>,
|
sensor_b_firmware_build_minutes: Option<u32>,
|
||||||
/// last time when fertilizer was applied
|
|
||||||
last_fertilizer_time: i64,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export interface PlantConfig {
|
|||||||
min_pump_current_ma: number,
|
min_pump_current_ma: number,
|
||||||
max_pump_current_ma: number,
|
max_pump_current_ma: number,
|
||||||
ignore_current_error: boolean,
|
ignore_current_error: boolean,
|
||||||
|
sensor_combine_mode: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PumpTestResult {
|
export interface PumpTestResult {
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
.plantSensorEnabledOnly_ ${plantId} {
|
.plantSensorEnabledOnly_ ${plantId} {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plantBothSensorsOnly_ ${plantId} {
|
||||||
|
}
|
||||||
|
|
||||||
.plantHidden_ ${plantId} {
|
.plantHidden_ ${plantId} {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -48,6 +51,14 @@
|
|||||||
<div class="plantkey">Sensor B installed:</div>
|
<div class="plantkey">Sensor B installed:</div>
|
||||||
<input class="plantcheckbox" id="plant_${plantId}_sensor_b" type="checkbox">
|
<input class="plantcheckbox" id="plant_${plantId}_sensor_b" type="checkbox">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flexcontainer plantBothSensorsOnly_${plantId}">
|
||||||
|
<div class="plantkey">Sensor Combine Mode:</div>
|
||||||
|
<select class="plantvalue" id="plant_${plantId}_sensor_combine_mode">
|
||||||
|
<option value="Min">Min</option>
|
||||||
|
<option value="Max">Max</option>
|
||||||
|
<option value="Avg">Average</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="flexcontainer">
|
<div class="flexcontainer">
|
||||||
<div class="plantkey">
|
<div class="plantkey">
|
||||||
Mode:
|
Mode:
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export class PlantView {
|
|||||||
private readonly pumpHourEnd: HTMLSelectElement;
|
private readonly pumpHourEnd: HTMLSelectElement;
|
||||||
private readonly sensorAInstalled: HTMLInputElement;
|
private readonly sensorAInstalled: HTMLInputElement;
|
||||||
private readonly sensorBInstalled: HTMLInputElement;
|
private readonly sensorBInstalled: HTMLInputElement;
|
||||||
|
private readonly sensorCombineMode: HTMLSelectElement;
|
||||||
private readonly mode: HTMLSelectElement;
|
private readonly mode: HTMLSelectElement;
|
||||||
private readonly moistureA: HTMLElement;
|
private readonly moistureA: HTMLElement;
|
||||||
private readonly moistureB: HTMLElement;
|
private readonly moistureB: HTMLElement;
|
||||||
@@ -236,6 +237,14 @@ export class PlantView {
|
|||||||
controller.configChanged()
|
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 = document.getElementById("plant_" + plantId + "_min_pump_current_ma") as HTMLInputElement;
|
||||||
this.minPumpCurrentMa.onchange = function () {
|
this.minPumpCurrentMa.onchange = function () {
|
||||||
controller.configChanged()
|
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) {
|
updateVisibility(plantConfig: PlantConfig) {
|
||||||
let sensorOnly = document.getElementsByClassName("plantSensorEnabledOnly_" + this.plantId)
|
let sensorOnly = document.getElementsByClassName("plantSensorEnabledOnly_" + this.plantId)
|
||||||
let pumpOnly = document.getElementsByClassName("plantPumpEnabledOnly_" + this.plantId)
|
let pumpOnly = document.getElementsByClassName("plantPumpEnabledOnly_" + this.plantId)
|
||||||
@@ -324,6 +346,9 @@ export class PlantView {
|
|||||||
// element.classList.add("plantHidden_" + this.plantId)
|
// element.classList.add("plantHidden_" + this.plantId)
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
// Update sensor combine mode visibility based on whether both sensors are active
|
||||||
|
this.updateSensorCombineModeState();
|
||||||
}
|
}
|
||||||
|
|
||||||
setTestResult(result: PumpTestResult) {
|
setTestResult(result: PumpTestResult) {
|
||||||
@@ -354,6 +379,7 @@ export class PlantView {
|
|||||||
this.pumpHourEnd.value = plantConfig.pump_hour_end.toString();
|
this.pumpHourEnd.value = plantConfig.pump_hour_end.toString();
|
||||||
this.sensorBInstalled.checked = plantConfig.sensor_b;
|
this.sensorBInstalled.checked = plantConfig.sensor_b;
|
||||||
this.sensorAInstalled.checked = plantConfig.sensor_a;
|
this.sensorAInstalled.checked = plantConfig.sensor_a;
|
||||||
|
this.sensorCombineMode.value = plantConfig.sensor_combine_mode || "Min";
|
||||||
this.maxConsecutivePumpCount.value = plantConfig.max_consecutive_pump_count.toString();
|
this.maxConsecutivePumpCount.value = plantConfig.max_consecutive_pump_count.toString();
|
||||||
this.minPumpCurrentMa.value = plantConfig.min_pump_current_ma.toString();
|
this.minPumpCurrentMa.value = plantConfig.min_pump_current_ma.toString();
|
||||||
this.maxPumpCurrentMa.value = plantConfig.max_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,
|
pump_hour_end: +this.pumpHourEnd.value,
|
||||||
sensor_b: this.sensorBInstalled.checked,
|
sensor_b: this.sensorBInstalled.checked,
|
||||||
sensor_a: this.sensorAInstalled.checked,
|
sensor_a: this.sensorAInstalled.checked,
|
||||||
|
sensor_combine_mode: this.sensorCombineMode.value,
|
||||||
max_consecutive_pump_count: this.maxConsecutivePumpCount.valueAsNumber,
|
max_consecutive_pump_count: this.maxConsecutivePumpCount.valueAsNumber,
|
||||||
moisture_sensor_min_frequency: this.moistureSensorMinFrequency.valueAsNumber || null,
|
moisture_sensor_min_frequency: this.moistureSensorMinFrequency.valueAsNumber || null,
|
||||||
moisture_sensor_max_frequency: this.moistureSensorMaxFrequency.valueAsNumber || null,
|
moisture_sensor_max_frequency: this.moistureSensorMaxFrequency.valueAsNumber || null,
|
||||||
|
|||||||
Reference in New Issue
Block a user