22 Commits

Author SHA1 Message Date
EmpirePhoenix c112d133db Add missing Box import in battery module 2026-05-30 21:01:29 +02:00
EmpirePhoenix 95281d617f clippy fixed 2026-05-30 20:59:58 +02:00
EmpirePhoenix a2abc99275 Refactor formatting and remove unused imports in mqtt and plant_state modules 2026-05-30 20:57:39 +02:00
EmpirePhoenix 4b3c003996 Add target_pct field to PlantInfo for target moisture mode 2026-05-30 20:57:31 +02:00
EmpirePhoenix bba959f2a2 Update POST JSON size limit to use SAVEGAME_SLOT_SIZE minus header space 2026-05-30 20:55:51 +02:00
EmpirePhoenix c9a96f37f0 feat: add sensor combine mode with Min, Max, and Avg options, update web UI and configuration for multi-sensor support 2026-05-29 11:22:12 +02:00
EmpirePhoenix fbf97732a4 refactor: update KiCad 3D model paths and zone settings for compatibility with updated libraries 2026-05-28 20:14:51 +02:00
EmpirePhoenix 6b419dba6c Add Wi-Fi scan details display and MQTT publish
- HTML: Add Wi-Fi scan results container to network.html
- Rust: Implement `wifi_scan_details()` with RSSI, channel, auth method
- API & UI: Fetch and display scan results in table format
- MQTT: Publish top 10 networks sorted by RSSI to `/wifi_scan`
2026-05-28 00:46:14 +02:00
EmpirePhoenix 3618b3329c refactor tank info field names and improve null checks in UI 2026-05-27 15:18:46 +02:00
EmpirePhoenix f5f73723d1 retry wifi connection, show canbus FW version, adjust measurement formular 2026-05-27 03:36:39 +02:00
EmpirePhoenix be98380ba4 new toast impl, wip 2026-05-26 13:27:35 +02:00
EmpirePhoenix fe2d227c67 Fix fertilizer calculation and logging 2026-05-26 02:03:19 +02:00
EmpirePhoenix bd5b687430 fix pump test progress bar 2026-05-25 23:22:21 +02:00
EmpirePhoenix 7679fa09dc clippy: fix clippy warnings 2026-05-25 20:11:58 +02:00
EmpirePhoenix 7078af5713 Merge pull request 'refactor/mqtt-data-serialization' (#23) from refactor/mqtt-data-serialization into develop
Reviewed-on: #23
2026-05-25 19:42:14 +02:00
judge 32256d0c91 move all mqtt publishing functions to mqtt module 2026-05-24 18:32:10 +02:00
judge d4a4c1b573 refctor: TankInfo structure (consistent layout)
- fix: use tagged enum serialization for TankError
- fix: rename TankInfo fields for consistent naming (volume_ml, pct, water_temp_c)
- renamed some fields for better clarity on contained value
2026-05-24 17:51:17 +02:00
judge 6bf7a04024 refactor: PlantInfo structure (consistent layout)
- fix: use tagged enum serialization for MoistureSensorError and PumpError
- fix: flatten PlantInfo sensors to SensorTelemetry with top-level moisture_pct
2026-05-24 17:50:38 +02:00
judge df3159aa16 refactor: BatteryInfo structure (consistent layout)
- use tagged enum serialization for BatteryError
- flatten BatteryInfo telemetry with consistent field names and typed error
2026-05-24 14:49:35 +02:00
judge 7866604a40 fix: serialize firmware/state as JSON instead of Debug format 2026-05-24 14:06:39 +02:00
EmpirePhoenix d989b41bdd wip website docu 2026-05-21 07:16:39 +02:00
EmpirePhoenix ac8305953a Merge pull request 'refactor/mkstatic-util' (#22) from refactor/mkstatic-util into develop
Reviewed-on: #22
2026-05-18 00:29:52 +02:00
33 changed files with 1725 additions and 535 deletions
+8 -8
View File
@@ -893,7 +893,7 @@
(uuid "f5c71e09-1c64-4699-a872-7959060ff54c") (uuid "f5c71e09-1c64-4699-a872-7959060ff54c")
) )
(embedded_fonts no) (embedded_fonts no)
(model "${KICAD6_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.wrl" (model "${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.step"
(offset (offset
(xyz 0 0 0) (xyz 0 0 0)
) )
@@ -2381,7 +2381,7 @@
(uuid "2619df3e-b807-4d9d-86fd-1890ae6a2950") (uuid "2619df3e-b807-4d9d-86fd-1890ae6a2950")
) )
(embedded_fonts no) (embedded_fonts no)
(model "${KICAD6_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.wrl" (model "${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.step"
(offset (offset
(xyz 0 0 0) (xyz 0 0 0)
) )
@@ -3089,7 +3089,7 @@
(uuid "29bb50c2-8c3f-4ba0-bb86-3c75e758d317") (uuid "29bb50c2-8c3f-4ba0-bb86-3c75e758d317")
) )
(embedded_fonts no) (embedded_fonts no)
(model "${KICAD6_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.wrl" (model "${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.step"
(offset (offset
(xyz 0 0 0) (xyz 0 0 0)
) )
@@ -3622,7 +3622,7 @@
(uuid "ac7efe3b-be6c-4633-baa1-c7a44a15dc73") (uuid "ac7efe3b-be6c-4633-baa1-c7a44a15dc73")
) )
(embedded_fonts no) (embedded_fonts no)
(model "${KICAD6_3DMODEL_DIR}/Package_TO_SOT_SMD.3dshapes/SOT-23.wrl" (model "${KICAD10_3DMODEL_DIR}/Package_TO_SOT_SMD.3dshapes/SOT-23.step"
(offset (offset
(xyz 0 0 0) (xyz 0 0 0)
) )
@@ -6371,7 +6371,7 @@
(uuid "f844f701-66c7-484d-a7cf-cff3b6cdd615") (uuid "f844f701-66c7-484d-a7cf-cff3b6cdd615")
) )
(embedded_fonts no) (embedded_fonts no)
(model "${KICAD6_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_1206_3216Metric.wrl" (model "${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_1206_3216Metric.step"
(offset (offset
(xyz 0 0 0) (xyz 0 0 0)
) )
@@ -8173,7 +8173,7 @@
(uuid "09bb897b-8ad7-4832-82c0-09ce6f9fd4df") (uuid "09bb897b-8ad7-4832-82c0-09ce6f9fd4df")
) )
(embedded_fonts no) (embedded_fonts no)
(model "${KICAD6_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.wrl" (model "${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.step"
(offset (offset
(xyz 0 0 0) (xyz 0 0 0)
) )
@@ -10055,7 +10055,7 @@
(uuid "3e5e9acf-dab8-4180-8dd7-e2e038a511be") (uuid "3e5e9acf-dab8-4180-8dd7-e2e038a511be")
) )
(embedded_fonts no) (embedded_fonts no)
(model "${KICAD6_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.wrl" (model "${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.step"
(offset (offset
(xyz 0 0 0) (xyz 0 0 0)
) )
@@ -13870,7 +13870,7 @@
(uuid "a9590be1-1db5-48eb-8721-a2ce5049db3f") (uuid "a9590be1-1db5-48eb-8721-a2ce5049db3f")
) )
(embedded_fonts no) (embedded_fonts no)
(model "${KICAD6_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.wrl" (model "${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.step"
(offset (offset
(xyz 0 0 0) (xyz 0 0 0)
) )
+17 -1
View File
@@ -50,7 +50,23 @@
"silk_text_thickness": 0.1, "silk_text_thickness": 0.1,
"silk_text_upright": false, "silk_text_upright": false,
"zones": { "zones": {
"min_clearance": 0.5 "border_display_style": 2,
"border_hatch_pitch": 0.5,
"corner_radius": 0.0,
"corner_smoothing": 0,
"fill_mode": 0,
"hatch_gap": 1.5,
"hatch_orientation": 0.0,
"hatch_smoothing_level": 0,
"hatch_smoothing_value": 0.1,
"hatch_thickness": 1.0,
"min_clearance": 0.5,
"min_island_area": 10.0,
"min_thickness": 0.25,
"pad_connection": 1,
"remove_islands": 0,
"thermal_relief_gap": 0.5,
"thermal_relief_spoke_width": 0.5
} }
}, },
"diff_pair_dimensions": [ "diff_pair_dimensions": [
+1
View File
@@ -2,5 +2,6 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../../.." vcs="Git" /> <mapping directory="$PROJECT_DIR$/../../.." vcs="Git" />
<mapping directory="$PROJECT_DIR$/../../../website/themes/blowfish" vcs="Git" />
</component> </component>
</project> </project>
+12
View File
@@ -14,6 +14,7 @@ pub struct NetworkConfig {
pub mqtt_user: Option<String>, pub mqtt_user: Option<String>,
pub mqtt_password: Option<String>, pub mqtt_password: Option<String>,
pub max_wait: u32, pub max_wait: u32,
pub retry_count: u32,
} }
impl Default for NetworkConfig { impl Default for NetworkConfig {
fn default() -> Self { fn default() -> Self {
@@ -26,6 +27,7 @@ impl Default for NetworkConfig {
mqtt_user: None, mqtt_user: None,
mqtt_password: None, mqtt_password: None,
max_wait: 10000, max_wait: 10000,
retry_count: 3,
} }
} }
} }
@@ -110,6 +112,14 @@ pub struct PlantControllerConfig {
pub timezone: Option<String>, pub timezone: Option<String>,
} }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub enum SensorCombineMode {
Min,
Max,
#[default]
Avg,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(default)] #[serde(default)]
pub struct PlantConfig { pub struct PlantConfig {
@@ -131,6 +141,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 {
@@ -154,6 +165,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,
} }
} }
} }
+1 -1
View File
@@ -316,7 +316,7 @@ impl From<sntpc::Error> for FatError {
impl From<BmsProtocolError> for FatError { impl From<BmsProtocolError> for FatError {
fn from(value: BmsProtocolError) -> Self { fn from(value: BmsProtocolError) -> Self {
match value { match value {
BmsProtocolError::I2cCommunicationError =>FatError::String { BmsProtocolError::I2cCommunicationError => FatError::String {
error: "I2C communication error".to_string(), error: "I2C communication error".to_string(),
}, },
BmsProtocolError::ChecksumError => FatError::String { BmsProtocolError::ChecksumError => FatError::String {
+29 -18
View File
@@ -1,5 +1,6 @@
use crate::fat_error::{FatError, FatResult};
use crate::hal::Box; use crate::hal::Box;
use crate::fat_error::{FatError, FatResult};
use alloc::string::String;
use async_trait::async_trait; use async_trait::async_trait;
use embassy_embedded_hal::shared_bus::blocking::i2c::I2cDevice; use embassy_embedded_hal::shared_bus::blocking::i2c::I2cDevice;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
@@ -18,15 +19,23 @@ pub trait BatteryInteraction {
async fn reset(&mut self) -> FatResult<()>; async fn reset(&mut self) -> FatResult<()>;
} }
#[derive(Debug, Serialize, Copy, Clone)] #[derive(Debug, Serialize, Clone)]
pub struct BatteryInfo { pub struct BatteryInfo {
pub voltage_milli_volt: u32, pub voltage_mv: Option<u32>,
pub average_current_milli_ampere: i32, pub avg_current_ma: Option<i32>,
pub design_milli_ampere_hour: u32, pub design_mah: Option<u32>,
pub remaining_milli_ampere_hour: u32, pub remaining_mah: Option<u32>,
pub state_of_charge: u8, pub soc_pct: Option<f32>,
pub state_of_health: u32, pub soh_pct: Option<f32>,
pub temperature: i32, pub temperature_c: Option<i32>,
pub error: Option<BatteryError>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(tag = "kind")]
pub enum BatteryError {
NoBatteryMonitor,
CommunicationError { message: String },
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -71,17 +80,19 @@ impl BatteryInteraction for WCHI2CSlave<'_> {
let config = Config::read_from_i2c(&mut self.i2c)?; let config = Config::read_from_i2c(&mut self.i2c)?;
let state_of_charge = let state_of_charge =
(state.remaining_capacity_mah * 100 / state.lifetime_capacity_mah) as u8; state.remaining_capacity_mah as f32 * 100. / state.lifetime_capacity_mah as f32;
let state_of_health = state.lifetime_capacity_mah / config.capacity_mah * 100; let state_of_health =
state.lifetime_capacity_mah as f32 / config.capacity_mah as f32 * 100.;
Ok(BatteryState::Info(BatteryInfo { Ok(BatteryState::Info(BatteryInfo {
voltage_milli_volt: state.current_mv, voltage_mv: Some(state.current_mv),
average_current_milli_ampere: 1337, avg_current_ma: Some(1337),
design_milli_ampere_hour: config.capacity_mah, design_mah: Some(config.capacity_mah),
remaining_milli_ampere_hour: state.remaining_capacity_mah, remaining_mah: Some(state.remaining_capacity_mah),
state_of_charge, soc_pct: Some(state_of_charge),
state_of_health, soh_pct: Some(state_of_health),
temperature: state.temperature_celcius, temperature_c: Some(state.temperature_celcius),
error: None,
})) }))
} }
+54 -18
View File
@@ -1,23 +1,17 @@
use crate::bail; use crate::bail;
use crate::config::{NetworkConfig, PlantControllerConfig}; use crate::config::PlantControllerConfig;
use crate::hal::savegame_manager::SavegameManager; use crate::hal::savegame_manager::SavegameManager;
use crate::hal::PLANT_COUNT; use crate::hal::PLANT_COUNT;
use crate::log::{log, LogMessage}; use crate::log::{log, LogMessage};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use crate::fat_error::{ContextExt, FatError, FatResult}; use crate::fat_error::{FatError, FatResult};
use crate::hal::shared_flash::MutexFlashStorage; use crate::hal::shared_flash::MutexFlashStorage;
use alloc::string::ToString; use alloc::string::{String, ToString};
use alloc::sync::Arc; use alloc::sync::Arc;
use alloc::{format, string::String, vec, vec::Vec}; use alloc::{format, vec, vec::Vec};
use core::net::{IpAddr, Ipv4Addr, SocketAddr};
use core::sync::atomic::Ordering;
use embassy_executor::Spawner;
use embassy_net::{DhcpConfig, IpAddress, Ipv4Cidr, Runner, Stack, StackResources, StaticConfigV4};
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::Mutex; use embassy_sync::mutex::Mutex;
use embassy_sync::once_lock::OnceLock;
use embassy_time::{Duration, Timer, WithTimeout};
use embedded_storage::nor_flash::{check_erase, NorFlash, ReadNorFlash, RmwNorFlashStorage}; use embedded_storage::nor_flash::{check_erase, NorFlash, ReadNorFlash, RmwNorFlashStorage};
use esp_bootloader_esp_idf::ota::OtaImageState::Valid; use esp_bootloader_esp_idf::ota::OtaImageState::Valid;
use esp_bootloader_esp_idf::ota::{Ota, OtaImageState}; use esp_bootloader_esp_idf::ota::{Ota, OtaImageState};
@@ -31,15 +25,34 @@ use esp_hal::rtc_cntl::{
use esp_hal::system::software_reset; use esp_hal::system::software_reset;
use esp_hal::uart::Uart; use esp_hal::uart::Uart;
use esp_hal::Blocking; use esp_hal::Blocking;
use esp_println::println; use esp_radio::wifi::ap::AccessPointInfo;
use esp_radio::wifi::ap::{AccessPointConfig, AccessPointInfo};
use esp_radio::wifi::scan::{ScanConfig, ScanTypeConfig}; use esp_radio::wifi::scan::{ScanConfig, ScanTypeConfig};
use esp_radio::wifi::sta::StationConfig; use esp_radio::wifi::{Interface, WifiController};
use esp_radio::wifi::{AuthenticationMethod, Config, Interface, WifiController}; use log::{error, info};
use log::{error, info, warn}; use serde::{Deserialize, Serialize};
use portable_atomic::AtomicBool;
use crate::network::{net_task, run_dhcp}; /// Detailed Wi-Fi scan information including signal strength
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct WifiScanDetails {
pub ssid: String,
pub bssid: String,
pub rssi: i32,
pub channel: u8,
pub auth_method: String,
}
// Helper function to format BSSID as MAC address string
fn format_bssid(bssid: &[u8; 6]) -> String {
alloc::format!(
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
bssid[0],
bssid[1],
bssid[2],
bssid[3],
bssid[4],
bssid[5]
)
}
#[esp_hal::ram(unstable(rtc_fast), unstable(persistent))] #[esp_hal::ram(unstable(rtc_fast), unstable(persistent))]
static mut LAST_WATERING_TIMESTAMP: [i64; PLANT_COUNT] = [0; PLANT_COUNT]; static mut LAST_WATERING_TIMESTAMP: [i64; PLANT_COUNT] = [0; PLANT_COUNT];
@@ -202,6 +215,25 @@ impl Esp<'_> {
Ok(rv) Ok(rv)
} }
/// Return detailed Wi-Fi scan information including signal strength
pub(crate) async fn wifi_scan_details(&mut self) -> FatResult<Vec<WifiScanDetails>> {
let ap_infos = self.wifi_scan().await?;
// Convert AccessPointInfo to WifiScanDetails
let details: Vec<WifiScanDetails> = ap_infos
.iter()
.map(|ap| WifiScanDetails {
ssid: ap.ssid.as_str().to_string(),
bssid: format_bssid(&ap.bssid),
rssi: ap.signal_strength as i32,
channel: ap.channel,
auth_method: format!("{:?}", ap.auth_method),
})
.collect();
Ok(details)
}
pub(crate) fn last_pump_time(&self, plant: usize) -> Option<DateTime<Utc>> { pub(crate) fn last_pump_time(&self, plant: usize) -> Option<DateTime<Utc>> {
let ts = unsafe { LAST_WATERING_TIMESTAMP }[plant]; let ts = unsafe { LAST_WATERING_TIMESTAMP }[plant];
DateTime::from_timestamp_millis(ts) DateTime::from_timestamp_millis(ts)
@@ -378,8 +410,12 @@ impl Esp<'_> {
for (i, item) in CONSECUTIVE_WATERING_PLANT.iter().enumerate() { for (i, item) in CONSECUTIVE_WATERING_PLANT.iter().enumerate() {
info!("CONSECUTIVE_WATERING_PLANT[{i}] = {item}"); info!("CONSECUTIVE_WATERING_PLANT[{i}] = {item}");
} }
// is executed before main, no other code will alter these values during printing
#[allow(static_mut_refs)]
for (i, item) in LAST_FERTILIZER_TIMESTAMP.iter().enumerate() {
info!("LAST_FERTILIZER_TIMESTAMP[{i}] = {item}");
}
} }
} }
} }
} }
+1 -1
View File
@@ -141,7 +141,7 @@ pub struct HAL<'a> {
#[async_trait(?Send)] #[async_trait(?Send)]
pub trait BoardInteraction<'a> { pub trait BoardInteraction<'a> {
fn get_tank_sensor(&mut self) -> Result<&mut TankSensor<'a>, FatError>; fn get_tank_sensor(&mut self) -> &mut TankSensor<'a>;
fn get_esp(&mut self) -> &mut Esp<'a>; fn get_esp(&mut self) -> &mut Esp<'a>;
fn get_config(&mut self) -> &PlantControllerConfig; fn get_config(&mut self) -> &PlantControllerConfig;
fn get_battery_monitor(&mut self) -> &mut Box<dyn BatteryInteraction + Send>; fn get_battery_monitor(&mut self) -> &mut Box<dyn BatteryInteraction + Send>;
+2 -2
View File
@@ -300,8 +300,8 @@ pub(crate) async fn create_v4(
#[async_trait(?Send)] #[async_trait(?Send)]
impl<'a> BoardInteraction<'a> for V4<'a> { impl<'a> BoardInteraction<'a> for V4<'a> {
fn get_tank_sensor(&mut self) -> Result<&mut TankSensor<'a>, FatError> { fn get_tank_sensor(&mut self) -> &mut TankSensor<'a> {
Ok(&mut self.tank_sensor) &mut self.tank_sensor
} }
fn get_esp(&mut self) -> &mut Esp<'a> { fn get_esp(&mut self) -> &mut Esp<'a> {
+12 -3
View File
@@ -10,7 +10,7 @@ use esp_hal::pcnt::channel::EdgeMode::{Hold, Increment};
use esp_hal::pcnt::unit::Unit; use esp_hal::pcnt::unit::Unit;
use esp_hal::peripherals::GPIO5; use esp_hal::peripherals::GPIO5;
use esp_hal::Async; use esp_hal::Async;
use log::info; use log::{error, info};
use onewire::{ds18b20, Device, DeviceSearch, OneWire, DS18B20}; use onewire::{ds18b20, Device, DeviceSearch, OneWire, DS18B20};
use portable_atomic::{AtomicUsize, Ordering}; use portable_atomic::{AtomicUsize, Ordering};
@@ -61,7 +61,12 @@ impl<'a> TankSensor<'a> {
info!("tank: pcnt high limit ok"); info!("tank: pcnt high limit ok");
// Reject pulses shorter than ~12.8 µs (1023 APB cycles @ 80 MHz) to suppress EMI noise // Reject pulses shorter than ~12.8 µs (1023 APB cycles @ 80 MHz) to suppress EMI noise
// on the sensor cable. Real flow pulses are in the millisecond range. // on the sensor cable. Real flow pulses are in the millisecond range.
pcnt1.set_filter(Some(1023)).unwrap(); match pcnt1.set_filter(Some(1023)) {
Ok(_) => {}
Err(err) => {
error!("tank: failed to set pcnt filter: {:?}", err);
}
}
let ch0 = &pcnt1.channel0; let ch0 = &pcnt1.channel0;
ch0.set_edge_signal(flow_sensor.peripheral_input()); ch0.set_edge_signal(flow_sensor.peripheral_input());
@@ -140,7 +145,11 @@ impl<'a> TankSensor<'a> {
water_temp_sensor = Some(device); water_temp_sensor = Some(device);
break; break;
} else { } else {
info!("OneWire: skipping device — not a DS18B20 (family 0x{:02X} != 0x{:02X})", device.address[0], ds18b20::FAMILY_CODE); info!(
"OneWire: skipping device — not a DS18B20 (family 0x{:02X} != 0x{:02X})",
device.address[0],
ds18b20::FAMILY_CODE
);
} }
} }
if devices_found == 0 { if devices_found == 0 {
@@ -32,7 +32,8 @@ impl LiveLogBuffer {
match after { match after {
None => (self.entries.clone(), false, next_seq), None => (self.entries.clone(), false, next_seq),
Some(after_seq) => { Some(after_seq) => {
let result: Vec<_> = self.entries let result: Vec<_> = self
.entries
.iter() .iter()
.filter(|(seq, _)| *seq > after_seq) .filter(|(seq, _)| *seq > after_seq)
.cloned() .cloned()
+61 -154
View File
@@ -14,24 +14,24 @@
esp_bootloader_esp_idf::esp_app_desc!(); esp_bootloader_esp_idf::esp_app_desc!();
use esp_backtrace as _; use esp_backtrace as _;
use crate::hal::PROGRESS_ACTIVE; use crate::config::{PlantConfig, PlantControllerConfig};
use crate::config::{NetworkConfig, PlantConfig, PlantControllerConfig};
use crate::fat_error::{ContextExt, FatResult}; use crate::fat_error::{ContextExt, FatResult};
use crate::hal::PROGRESS_ACTIVE;
use crate::log::log; use crate::log::log;
use crate::tank::{determine_tank_state, TankError, TankState, WATER_FROZEN_THRESH}; use crate::tank::{determine_tank_state, TankError, WATER_FROZEN_THRESH};
use crate::webserver::http_server; use crate::webserver::http_server;
use crate::{ use crate::{
config::BoardVersion::Initial, config::BoardVersion::Initial,
hal::{PlantHal, HAL, PLANT_COUNT}, hal::{PlantHal, HAL, PLANT_COUNT},
}; };
use ::log::{error, info, warn}; use ::log::{error, info};
use alloc::borrow::ToOwned; use alloc::borrow::ToOwned;
use alloc::string::{String, ToString}; use alloc::string::{String, ToString};
use alloc::sync::Arc; use alloc::sync::Arc;
use alloc::vec::Vec; use alloc::vec::Vec;
use alloc::{format, vec}; use alloc::{format, vec};
use chrono::{DateTime, Datelike, Timelike, Utc}; use chrono::{DateTime, Datelike, Timelike};
use chrono_tz::Tz::{self, UTC}; use chrono_tz::Tz::{self, UTC};
use core::sync::atomic::{AtomicBool, Ordering}; use core::sync::atomic::{AtomicBool, Ordering};
use embassy_executor::Spawner; use embassy_executor::Spawner;
@@ -123,8 +123,6 @@ pub struct PumpResult {
overcurrent_ma: Option<u16>, overcurrent_ma: Option<u16>,
} }
async fn safe_main(spawner: Spawner) -> FatResult<()> { async fn safe_main(spawner: Spawner) -> FatResult<()> {
info!("Startup Rust"); info!("Startup Rust");
@@ -208,10 +206,15 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
info!("No wifi configured, starting initial config mode"); info!("No wifi configured, starting initial config mode");
let esp = board.board_hal.get_esp(); let esp = board.board_hal.get_esp();
let ssid = esp.load_config().await let ssid = esp
.load_config()
.await
.map(|config| config.network.ap_ssid.to_string()) .map(|config| config.network.ap_ssid.to_string())
.unwrap_or_else(|_| String::from("PlantCtrl Emergency Mode")); .unwrap_or_else(|_| String::from("PlantCtrl Emergency Mode"));
let device = esp.interface_ap.take().context("AP interface already taken")?; let device = esp
.interface_ap
.take()
.context("AP interface already taken")?;
let stack = network::wifi_ap(ssid, device, &esp.controller, &mut esp.rng, spawner).await?; let stack = network::wifi_ap(ssid, device, &esp.controller, &mut esp.rng, spawner).await?;
let reboot_now = Arc::new(AtomicBool::new(false)); let reboot_now = Arc::new(AtomicBool::new(false));
@@ -228,7 +231,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
info!("No wifi configured"); info!("No wifi configured");
//the current sensors require this amount to stabilize, in the case of Wi-Fi this is already handled due to connect timings; //the current sensors require this amount to stabilize, in the case of Wi-Fi this is already handled due to connect timings;
Timer::after_millis(100).await; Timer::after_millis(100).await;
network::NetworkMode::OFFLINE network::NetworkMode::OFFLINE
}; };
if matches!(network_mode, network::NetworkMode::OFFLINE) && to_config { if matches!(network_mode, network::NetworkMode::OFFLINE) && to_config {
@@ -236,14 +239,18 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
let res = { let res = {
let esp = board.board_hal.get_esp(); let esp = board.board_hal.get_esp();
let ssid = esp.load_config().await let ssid = esp
.load_config()
.await
.map(|config| config.network.ap_ssid.to_string()) .map(|config| config.network.ap_ssid.to_string())
.unwrap_or_else(|_| String::from("PlantCtrl Emergency Mode")); .unwrap_or_else(|_| String::from("PlantCtrl Emergency Mode"));
let device = match esp.interface_ap.take() { let device = match esp.interface_ap.take() {
Some(d) => d, Some(d) => d,
None => { None => {
use crate::fat_error::FatError; use crate::fat_error::FatError;
return Err(FatError::String { error: "AP interface already taken".to_string() }); return Err(FatError::String {
error: "AP interface already taken".to_string(),
});
} }
}; };
network::wifi_ap(ssid, device, &esp.controller, &mut esp.rng, spawner).await network::wifi_ap(ssid, device, &esp.controller, &mut esp.rng, spawner).await
@@ -275,11 +282,14 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
); );
if let network::NetworkMode::WIFI { ref ip_address, .. } = network_mode { if let network::NetworkMode::WIFI { ref ip_address, .. } = network_mode {
publish_firmware_info(&mut board, version, ip_address, &timezone_time.to_rfc3339()).await; mqtt::publish_firmware_info(version, ip_address, &timezone_time.to_rfc3339()).await;
publish_battery_state(&mut board).await.unwrap_or_else(|e| { mqtt::publish_battery_state(&mut board)
error!("Error publishing battery state {e}"); .await
}); .unwrap_or_else(|e| {
let _ = publish_mppt_state(&mut board).await; error!("Error publishing battery state {e}");
});
let _ = mqtt::publish_mppt_state(&mut board).await;
let _ = mqtt::publish_wifi_scan(&mut board).await;
} }
log( log(
@@ -324,7 +334,9 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
if let Some(err) = tank_state.got_error(&board.board_hal.get_config().tank) { if let Some(err) = tank_state.got_error(&board.board_hal.get_config().tank) {
match err { match err {
TankError::SensorDisabled => { /* unreachable */ } TankError::SensorDisabled => { /* unreachable */ }
TankError::SensorMissing(raw_value_mv) => log( TankError::SensorMissing {
raw_mv: raw_value_mv,
} => log(
LogMessage::TankSensorMissing, LogMessage::TankSensorMissing,
raw_value_mv as u32, raw_value_mv as u32,
0, 0,
@@ -338,8 +350,8 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
&format!("{value}"), &format!("{value}"),
"", "",
), ),
TankError::BoardError(err) => { TankError::BoardError { message: err } => {
log(LogMessage::TankSensorBoardError, 0, 0, "", &err.to_string()) log(LogMessage::TankSensorBoardError, 0, 0, "", &err)
} }
} }
// disabled cannot trigger this because of wrapping if is_enabled // disabled cannot trigger this because of wrapping if is_enabled
@@ -354,10 +366,11 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
} }
let mut water_frozen = false; let mut water_frozen = false;
let water_temp: FatResult<f32> = match board.board_hal.get_tank_sensor() { let water_temp: FatResult<f32> = board
Ok(sensor) => sensor.water_temperature_c().await, .board_hal
Err(e) => Err(e), .get_tank_sensor()
}; .water_temperature_c()
.await;
if let Ok(res) = water_temp { if let Ok(res) = water_temp {
if res < WATER_FROZEN_THRESH { if res < WATER_FROZEN_THRESH {
@@ -366,7 +379,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
} }
info!("Water temp is {}", water_temp.as_ref().unwrap_or(&0.)); info!("Water temp is {}", water_temp.as_ref().unwrap_or(&0.));
publish_tank_state(&mut board, &tank_state, water_temp) mqtt::publish_tank_state(&mut board, &tank_state, water_temp)
.await .await
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
error!("Error publishing tank state {e}"); error!("Error publishing tank state {e}");
@@ -385,7 +398,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
PlantState::interpret_raw_values(moisture, 7, &mut board).await, PlantState::interpret_raw_values(moisture, 7, &mut board).await,
]; ];
publish_plant_states(&mut board, &timezone_time.clone(), &plantstate) mqtt::publish_plant_states(&mut board, &timezone_time.clone(), &plantstate)
.await .await
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
error!("Error publishing plant states {e}"); error!("Error publishing plant states {e}");
@@ -436,8 +449,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
board.board_hal.get_esp().last_pump_time(plant_id); board.board_hal.get_esp().last_pump_time(plant_id);
//state.active = true; //state.active = true;
pump_info( mqtt::pump_info(
&mut board,
plant_id, plant_id,
true, true,
pump_ineffective, pump_ineffective,
@@ -454,8 +466,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
match result { match result {
Ok(state) => { Ok(state) => {
overcurrent_results[plant_id] = state.overcurrent_ma; overcurrent_results[plant_id] = state.overcurrent_ma;
pump_info( mqtt::pump_info(
&mut board,
plant_id, plant_id,
false, false,
pump_ineffective, pump_ineffective,
@@ -469,8 +480,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
.await; .await;
} }
Err(err) => { Err(err) => {
pump_info( mqtt::pump_info(
&mut board,
plant_id, plant_id,
false, false,
pump_ineffective, pump_ineffective,
@@ -500,7 +510,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
plantstate[plant_id].pump.overcurrent_error = Some(current_ma); plantstate[plant_id].pump.overcurrent_error = Some(current_ma);
} }
} }
publish_plant_states(&mut board, &timezone_time.clone(), &plantstate) mqtt::publish_plant_states(&mut board, &timezone_time.clone(), &plantstate)
.await .await
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
error!("Error publishing plant states after pumping {e}"); error!("Error publishing plant states after pumping {e}");
@@ -581,16 +591,20 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
board.board_hal.get_config().night_lamp.night_lamp_hour_end, board.board_hal.get_config().night_lamp.night_lamp_hour_end,
); );
match battery_state { match &battery_state {
BatteryState::Unknown => { BatteryState::Unknown => {
light_state.battery_low = false; light_state.battery_low = false;
} }
BatteryState::Info(data) => { BatteryState::Info(data) => {
if data.state_of_charge < board.board_hal.get_config().night_lamp.low_soc_cutoff { if data.soc_pct.is_some_and(|soc| {
soc < board.board_hal.get_config().night_lamp.low_soc_cutoff as f32
}) {
board.board_hal.get_esp().set_low_voltage_in_cycle(); board.board_hal.get_esp().set_low_voltage_in_cycle();
info!("Set low voltage in cycle"); info!("Set low voltage in cycle");
} }
if data.state_of_charge > board.board_hal.get_config().night_lamp.low_soc_restore { if data.soc_pct.is_some_and(|soc| {
soc > board.board_hal.get_config().night_lamp.low_soc_restore as f32
}) {
board.board_hal.get_esp().clear_low_voltage_in_cycle(); board.board_hal.get_esp().clear_low_voltage_in_cycle();
info!("Clear low voltage in cycle"); info!("Clear low voltage in cycle");
} }
@@ -639,7 +653,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
let deep_sleep_duration_minutes: u32 = let deep_sleep_duration_minutes: u32 =
// if battery soc is unknown assume battery has enough change // if battery soc is unknown assume battery has enough change
if matches!(battery_state, BatteryState::Info(data) if data.state_of_charge < 10) { if matches!(battery_state, BatteryState::Info(data) if data.soc_pct.is_some_and(|soc| soc < 10.)) {
let _ = mqtt::publish("/deepsleep", "low Volt 12h").await; let _ = mqtt::publish("/deepsleep", "low Volt 12h").await;
12 * 60 12 * 60
} else if is_day { } else if is_day {
@@ -709,8 +723,9 @@ pub async fn do_secure_pump(
if plant_config.fertilizer_s > 0 { if plant_config.fertilizer_s > 0 {
let current_time = board.board_hal.get_time().await; let current_time = board.board_hal.get_time().await;
let last_fertilizer = board.board_hal.get_esp().last_fertilizer_time(plant_id); let last_fertilizer = board.board_hal.get_esp().last_fertilizer_time(plant_id);
let elapsed_minutes = (current_time.timestamp() - last_fertilizer) / 60; // Convert last_fertilizer from milliseconds to seconds for correct subtraction
let elapsed_minutes = ((current_time.timestamp_millis() - last_fertilizer) / 1000) / 60;
info!("Fertilizer pump cooldown check - Current time: {}, Last fertilizer: {}, Elapsed minutes: {}", current_time, last_fertilizer, elapsed_minutes);
if elapsed_minutes >= plant_config.fertilizer_cooldown_min as i64 { if elapsed_minutes >= plant_config.fertilizer_cooldown_min as i64 {
info!( info!(
"Starting fertilizer pump for {} seconds (last fertilizer was {} minutes ago)", "Starting fertilizer pump for {} seconds (last fertilizer was {} minutes ago)",
@@ -723,6 +738,7 @@ pub async fn do_secure_pump(
&elapsed_minutes.to_string(), &elapsed_minutes.to_string(),
"", "",
); );
info!("Fertilizer pump applied - Current time: {}, Last fertilizer: {}, Elapsed minutes: {}", current_time, last_fertilizer, elapsed_minutes);
board.board_hal.fertilizer_pump(true).await?; board.board_hal.fertilizer_pump(true).await?;
Timer::after_millis(plant_config.fertilizer_s as u64 * 1000).await; Timer::after_millis(plant_config.fertilizer_s as u64 * 1000).await;
board.board_hal.fertilizer_pump(false).await?; board.board_hal.fertilizer_pump(false).await?;
@@ -743,13 +759,13 @@ pub async fn do_secure_pump(
} }
} }
board.board_hal.get_tank_sensor()?.reset_flow_meter(); board.board_hal.get_tank_sensor().reset_flow_meter();
board.board_hal.get_tank_sensor()?.start_flow_meter(); board.board_hal.get_tank_sensor().start_flow_meter();
board.board_hal.pump(plant_id, true).await?; board.board_hal.pump(plant_id, true).await?;
for step in 0..steps_in_50ms { for step in 0..steps_in_50ms {
let step_start = Instant::now(); let step_start = Instant::now();
let flow_value = board.board_hal.get_tank_sensor()?.get_full_flow_count(); let flow_value = board.board_hal.get_tank_sensor().get_full_flow_count();
flow_collector[step] = flow_value; flow_collector[step] = flow_value;
let flow_value_ml = flow_value as f32 * board.board_hal.get_config().tank.ml_per_pulse; let flow_value_ml = flow_value as f32 * board.board_hal.get_config().tank.ml_per_pulse;
@@ -860,8 +876,8 @@ pub async fn do_secure_pump(
//noticable dummy value //noticable dummy value
pump_time_ms = 1337; pump_time_ms = 1337;
} }
board.board_hal.get_tank_sensor()?.stop_flow_meter(); board.board_hal.get_tank_sensor().stop_flow_meter();
let final_flow_value = board.board_hal.get_tank_sensor()?.get_full_flow_count(); let final_flow_value = board.board_hal.get_tank_sensor().get_full_flow_count();
let flow_value_ml = final_flow_value as f32 * board.board_hal.get_config().tank.ml_per_pulse; let flow_value_ml = final_flow_value as f32 * board.board_hal.get_config().tank.ml_per_pulse;
info!("Final flow value is {final_flow_value} with {flow_value_ml} ml"); info!("Final flow value is {final_flow_value} with {flow_value_ml} ml");
current_collector.sort(); current_collector.sort();
@@ -890,114 +906,6 @@ async fn update_charge_indicator(
Ok(()) Ok(())
} }
async fn publish_tank_state(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
tank_state: &TankState,
water_temp: FatResult<f32>,
) -> FatResult<()> {
let state = serde_json::to_string(
&tank_state.as_mqtt_info(&board.board_hal.get_config().tank, &water_temp),
)?;
let _ = mqtt::publish("/water", &*state).await;
Ok(())
}
async fn publish_plant_states(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
timezone_time: &DateTime<Tz>,
plantstate: &[PlantState; 8],
) -> FatResult<()> {
for (plant_id, (plant_state, plant_conf)) in plantstate
.iter()
.zip(&board.board_hal.get_config().plants.clone())
.enumerate()
{
let state = serde_json::to_string(&plant_state.to_mqtt_info(plant_conf, timezone_time))?;
let plant_topic = format!("/plant{}", plant_id + 1);
let _ = mqtt::publish(&plant_topic, &state).await;
}
Ok(())
}
async fn publish_firmware_info(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
version: VersionInfo,
ip_address: &str,
timezone_time: &str,
) {
mqtt::publish("/firmware/address", ip_address).await;
mqtt::publish("/firmware/state", format!("{:?}", &version).as_str())
.await;
mqtt::publish("/firmware/last_online", timezone_time)
.await;
mqtt::publish("/state", "online").await;
}
async fn pump_info(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
plant_id: usize,
pump_active: bool,
pump_ineffective: bool,
median_current_ma: u16,
max_current_ma: u16,
min_current_ma: u16,
error: String,
flow_raw: u32,
flow_ml: f32,
) {
let pump_info = mqtt::PumpInfo {
enabled: pump_active,
pump_ineffective,
median_current_ma,
max_current_ma,
min_current_ma,
error,
flow_raw,
flow_ml,
};
let pump_topic = format!("/pump{}", plant_id + 1);
match serde_json::to_string(&pump_info) {
Ok(state) => {
let _ = mqtt::publish(&pump_topic, &state).await;
}
Err(err) => {
warn!("Error publishing pump state {err}");
}
};
}
async fn publish_mppt_state(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
) -> FatResult<()> {
let current = board.board_hal.get_mptt_current().await?;
let voltage = board.board_hal.get_mptt_voltage().await?;
let solar_state = mqtt::Solar {
current_ma: current.as_milliamperes() as u32,
voltage_ma: voltage.as_millivolts() as u32,
};
if let Ok(serialized_solar_state_bytes) = serde_json::to_string(&solar_state) {
let _ = mqtt::publish("/mppt", &serialized_solar_state_bytes).await;
}
Ok(())
}
async fn publish_battery_state(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
) -> FatResult<()> {
let state = board.board_hal.get_battery_monitor().get_state().await;
let value = match state {
Ok(state) => {
let json = serde_json::to_string(&state)?.to_owned();
json.to_owned()
}
Err(_) => "error".to_owned(),
};
{
let _ = mqtt::publish("/battery", &*value).await;
}
Ok(())
}
async fn wait_infinity( async fn wait_infinity(
board: MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>, board: MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
wait_type: WaitType, wait_type: WaitType,
@@ -1099,8 +1007,7 @@ async fn wait_infinity(
let timezone_time = cur.with_timezone(&timezone); let timezone_time = cur.with_timezone(&timezone);
mqtt::publish("/state", "config").await; mqtt::publish("/state", "config").await;
mqtt::publish("/firmware/last_online", &timezone_time.to_rfc3339()) mqtt::publish("/firmware/last_online", &timezone_time.to_rfc3339()).await;
.await;
last_mqtt_update = Some(now); last_mqtt_update = Some(now);
} }
+191 -24
View File
@@ -1,16 +1,24 @@
use crate::bail;
use crate::config::NetworkConfig; use crate::config::NetworkConfig;
use crate::fat_error::{ContextExt, FatError, FatResult}; use crate::fat_error::{ContextExt, FatError, FatResult};
use crate::hal::PlantHal; use crate::hal::battery::{BatteryError, BatteryInfo, BatteryState};
use crate::hal::{PlantHal, HAL};
use crate::log::{log, LogMessage}; use crate::log::{log, LogMessage};
use alloc::string::String; use crate::plant_state::PlantState;
use alloc::{format, string::ToString}; use crate::tank::TankState;
use crate::{bail, VersionInfo};
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use chrono::DateTime;
use chrono_tz::Tz;
use core::sync::atomic::Ordering; use core::sync::atomic::Ordering;
use embassy_executor::Spawner; use embassy_executor::Spawner;
use embassy_net::Stack; use embassy_net::Stack;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::MutexGuard;
use embassy_sync::once_lock::OnceLock; use embassy_sync::once_lock::OnceLock;
use embassy_time::{Duration, Timer, WithTimeout}; use embassy_time::{Duration, Timer, WithTimeout};
use log::info; use log::{error, info, warn};
use mcutie::{ use mcutie::{
Error, McutieBuilder, McutieReceiver, McutieTask, MqttMessage, PublishDisplay, Publishable, Error, McutieBuilder, McutieReceiver, McutieTask, MqttMessage, PublishDisplay, Publishable,
QoS, Topic, QoS, Topic,
@@ -18,25 +26,6 @@ use mcutie::{
use portable_atomic::AtomicBool; use portable_atomic::AtomicBool;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
///mqtt struct to track pump activities
pub struct PumpInfo {
pub enabled: bool,
pub pump_ineffective: bool,
pub median_current_ma: u16,
pub max_current_ma: u16,
pub min_current_ma: u16,
pub error: String,
pub flow_raw: u32,
pub flow_ml: f32,
}
#[derive(Serialize, Debug, PartialEq)]
pub struct Solar {
pub current_ma: u32,
pub voltage_ma: u32,
}
static MQTT_CONNECTED_EVENT_RECEIVED: AtomicBool = AtomicBool::new(false); static MQTT_CONNECTED_EVENT_RECEIVED: AtomicBool = AtomicBool::new(false);
static MQTT_ROUND_TRIP_RECEIVED: AtomicBool = AtomicBool::new(false); static MQTT_ROUND_TRIP_RECEIVED: AtomicBool = AtomicBool::new(false);
pub static MQTT_STAY_ALIVE: AtomicBool = AtomicBool::new(false); pub static MQTT_STAY_ALIVE: AtomicBool = AtomicBool::new(false);
@@ -269,3 +258,181 @@ async fn mqtt_incoming_task(
} }
} }
} }
pub async fn publish_tank_state(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
tank_state: &TankState,
water_temp: FatResult<f32>,
) -> FatResult<()> {
let state = serde_json::to_string(
&tank_state.as_mqtt_info(&board.board_hal.get_config().tank, &water_temp),
)?;
let _ = publish("/water", &state).await;
Ok(())
}
pub async fn publish_plant_states(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
timezone_time: &DateTime<Tz>,
plantstate: &[PlantState; 8],
) -> FatResult<()> {
for (plant_id, (plant_state, plant_conf)) in plantstate
.iter()
.zip(&board.board_hal.get_config().plants.clone())
.enumerate()
{
let state = serde_json::to_string(&plant_state.to_mqtt_info(plant_conf, timezone_time))?;
let plant_topic = format!("/plant{}", plant_id + 1);
let _ = publish(&plant_topic, &state).await;
}
Ok(())
}
pub async fn publish_firmware_info(version: VersionInfo, ip_address: &str, timezone_time: &str) {
publish("/firmware/address", ip_address).await;
let version = &serde_json::to_string(&version);
match version {
Ok(version_str) => publish("/firmware/state", version_str).await,
Err(e) => error!("Failed to serialize version info: {}", e),
}
publish("/firmware/last_online", timezone_time).await;
publish("/state", "online").await;
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
///mqtt struct to track pump activities
pub struct PumpInfo {
pub enabled: bool,
pub pump_ineffective: bool,
pub median_current_ma: u16,
pub max_current_ma: u16,
pub min_current_ma: u16,
pub error: String,
pub flow_raw: u32,
pub flow_ml: f32,
}
#[allow(clippy::too_many_arguments)]
pub async fn pump_info(
plant_id: usize,
pump_active: bool,
pump_ineffective: bool,
median_current_ma: u16,
max_current_ma: u16,
min_current_ma: u16,
error: String,
flow_raw: u32,
flow_ml: f32,
) {
let pump_info = PumpInfo {
enabled: pump_active,
pump_ineffective,
median_current_ma,
max_current_ma,
min_current_ma,
error,
flow_raw,
flow_ml,
};
let pump_topic = format!("/pump{}", plant_id + 1);
match serde_json::to_string(&pump_info) {
Ok(state) => {
let _ = publish(&pump_topic, &state).await;
}
Err(err) => {
warn!("Error publishing pump state {err}");
}
};
}
/// Wi-Fi scan result details for MQTT
#[derive(Serialize, Debug, PartialEq)]
pub struct WifiScanResult {
pub ssid: String,
pub bssid: String,
pub rssi: i32,
pub channel: u8,
pub auth_method: String,
}
#[derive(Serialize, Debug, PartialEq)]
pub struct Solar {
pub current_ma: u32,
pub voltage_ma: u32,
}
pub async fn publish_mppt_state(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
) -> FatResult<()> {
let current = board.board_hal.get_mptt_current().await?;
let voltage = board.board_hal.get_mptt_voltage().await?;
let solar_state = Solar {
current_ma: current.as_milliamperes() as u32,
voltage_ma: voltage.as_millivolts() as u32,
};
if let Ok(serialized_solar_state_bytes) = serde_json::to_string(&solar_state) {
let _ = publish("/mppt", &serialized_solar_state_bytes).await;
}
Ok(())
}
pub async fn publish_battery_state(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
) -> FatResult<()> {
let telemetry = match board.board_hal.get_battery_monitor().get_state().await {
Ok(BatteryState::Info(info)) => info,
Ok(BatteryState::Unknown) => BatteryInfo {
voltage_mv: None,
avg_current_ma: None,
soc_pct: None,
soh_pct: None,
temperature_c: None,
remaining_mah: None,
design_mah: None,
error: Some(BatteryError::NoBatteryMonitor),
},
Err(e) => BatteryInfo {
voltage_mv: None,
avg_current_ma: None,
soc_pct: None,
soh_pct: None,
temperature_c: None,
remaining_mah: None,
design_mah: None,
error: Some(BatteryError::CommunicationError {
message: alloc::format!("{:?}", e),
}),
},
};
let json = serde_json::to_string(&telemetry)?;
publish("/battery", &json).await;
Ok(())
}
/// Publish Wi-Fi scan details to MQTT
pub async fn publish_wifi_scan(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
) -> FatResult<()> {
let mut wifi_details = board.board_hal.get_esp().wifi_scan_details().await?;
// Sort by RSSI in descending order (strongest first)
wifi_details.sort_by(|a, b| b.rssi.cmp(&a.rssi));
// Take only the strongest 10 results
let wifi_results: Vec<WifiScanResult> = wifi_details
.iter()
.take(10)
.map(|d| WifiScanResult {
ssid: d.ssid.clone(),
bssid: d.bssid.clone(),
rssi: d.rssi,
channel: d.channel,
auth_method: d.auth_method.clone(),
})
.collect();
let json = serde_json::to_string(&wifi_results)?;
publish("/wifi_scan", &json).await;
Ok(())
}
+96 -52
View File
@@ -1,13 +1,19 @@
use crate::bail; use crate::bail;
use crate::config::NetworkConfig; use crate::config::NetworkConfig;
use crate::fat_error::{ContextExt, FatError, FatResult}; use crate::fat_error::{ContextExt, FatError, FatResult};
use crate::hal::{PlantHal, HAL}; use crate::hal::HAL;
use crate::mqtt; use crate::mqtt;
use crate::util::mk_static; use crate::util::mk_static;
use alloc::string::{String, ToString}; use alloc::string::{String, ToString};
use alloc::sync::Arc; use alloc::sync::Arc;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use core::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; use core::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use edge_dhcp::{
io::{self, DEFAULT_SERVER_PORT},
server::{Server, ServerOptions},
};
use edge_nal::UdpBind;
use edge_nal_embassy::{Udp, UdpBuffers};
use embassy_executor::Spawner; use embassy_executor::Spawner;
use embassy_net::dns::DnsQueryType; use embassy_net::dns::DnsQueryType;
use embassy_net::udp::{PacketMetadata, UdpSocket}; use embassy_net::udp::{PacketMetadata, UdpSocket};
@@ -15,21 +21,15 @@ use embassy_net::{DhcpConfig, Runner, Stack, StackResources, StaticConfigV4};
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::{Mutex, MutexGuard}; use embassy_sync::mutex::{Mutex, MutexGuard};
use embassy_time::{Duration, Timer, WithTimeout}; use embassy_time::{Duration, Timer, WithTimeout};
use option_lock::OptionLock;
use edge_dhcp::{
io::{self, DEFAULT_SERVER_PORT},
server::{Server, ServerOptions},
};
use edge_nal::UdpBind;
use edge_nal_embassy::{Udp, UdpBuffers};
use esp_hal::rng::Rng; use esp_hal::rng::Rng;
use esp_println::println; use esp_println::println;
use esp_radio::wifi::ap::AccessPointConfig; use esp_radio::wifi::ap::AccessPointConfig;
use esp_radio::wifi::sta::StationConfig; use esp_radio::wifi::sta::StationConfig;
use esp_radio::wifi::{AuthenticationMethod, Config, Interface}; use esp_radio::wifi::{AuthenticationMethod, Config, Interface};
use log::{info, warn, error}; use log::{error, info, warn};
use option_lock::OptionLock;
use serde::Serialize; use serde::Serialize;
use sntpc::{NtpContext, NtpTimestampGenerator, NtpUdpSocket, get_time}; use sntpc::{get_time, NtpContext, NtpTimestampGenerator, NtpUdpSocket};
const NTP_SERVER: &str = "pool.ntp.org"; const NTP_SERVER: &str = "pool.ntp.org";
@@ -142,12 +142,14 @@ pub async fn sntp(max_wait_ms: u32, stack: Stack<'_>) -> FatResult<DateTime<Utc>
} }
#[derive(Serialize, Debug, PartialEq)] #[derive(Serialize, Debug, PartialEq)]
#[allow(clippy::upper_case_acronyms)]
pub enum SntpMode { pub enum SntpMode {
OFFLINE, OFFLINE,
SYNC { current: DateTime<Utc> }, SYNC { current: DateTime<Utc> },
} }
#[derive(Serialize, Debug, PartialEq)] #[derive(Serialize, Debug, PartialEq)]
#[allow(clippy::upper_case_acronyms)]
pub enum NetworkMode { pub enum NetworkMode {
WIFI { WIFI {
sntp: SntpMode, sntp: SntpMode,
@@ -223,8 +225,7 @@ pub async fn wifi_ap(
); );
let stack = mk_static!(Stack, stack); let stack = mk_static!(Stack, stack);
let client_config = let client_config = Config::AccessPoint(AccessPointConfig::default().with_ssid(ssid.clone()));
Config::AccessPoint(AccessPointConfig::default().with_ssid(ssid.clone()));
controller.lock().await.set_config(&client_config)?; controller.lock().await.set_config(&client_config)?;
println!("start net task"); println!("start net task");
@@ -268,12 +269,12 @@ pub async fn wifi(
bail!("Wifi ssid was empty") bail!("Wifi ssid was empty")
} }
}; };
info!("attempting to connect wifi {ssid}");
let password = match network_config.password { let password = match network_config.password {
Some(ref password) => password.as_str().to_string(), Some(ref password) => password.as_str().to_string(),
None => "".to_string(), None => "".to_string(),
}; };
let max_wait = network_config.max_wait; let max_wait = network_config.max_wait;
let retry_count = network_config.retry_count;
let config = embassy_net::Config::dhcpv4(DhcpConfig::default()); let config = embassy_net::Config::dhcpv4(DhcpConfig::default());
@@ -292,56 +293,99 @@ pub async fn wifi(
} else { } else {
AuthenticationMethod::Wpa2Personal AuthenticationMethod::Wpa2Personal
}; };
let client_config = StationConfig::default()
.with_ssid(ssid)
.with_auth_method(auth_method)
.with_scan_method(esp_radio::wifi::sta::ScanMethod::AllChannels)
.with_listen_interval(10)
.with_beacon_timeout(10)
.with_failure_retry_cnt(3)
.with_password(password);
controller // Spawn the network task once
.lock()
.await
.set_config(&Config::Station(client_config))?;
spawner.spawn(net_task(runner)?); spawner.spawn(net_task(runner)?);
controller
.lock() let mut attempts = 0;
.await
.connect_async() while attempts <= retry_count {
if attempts > 0 {
info!("WiFi connection retry {}/{}", attempts, retry_count);
} else {
info!("attempting to connect wifi {}", ssid);
}
let client_config = StationConfig::default()
.with_ssid(ssid.clone())
.with_auth_method(auth_method)
.with_scan_method(esp_radio::wifi::sta::ScanMethod::AllChannels)
.with_listen_interval(10)
.with_beacon_timeout(10)
.with_failure_retry_cnt(3)
.with_password(password.clone());
// Set config and attempt connection
controller
.lock()
.await
.set_config(&Config::Station(client_config))?;
match controller
.lock()
.await
.connect_async()
.with_timeout(Duration::from_millis(max_wait as u64 * 1000))
.await
{
Ok(result) => {
result?;
}
Err(e) => {
let disconnect_info = controller.lock().await.disconnect_async().await;
warn!("Wifi disconnect info {:?}", disconnect_info);
warn!("WiFi connection attempt {} failed: Timeout waiting for wifi sta connected: {:?}", attempts + 1, e);
attempts += 1;
Timer::after(Duration::from_millis(500)).await;
continue;
}
}
let res = async {
while !stack.is_link_up() {
Timer::after(Duration::from_millis(500)).await;
}
Ok::<(), FatError>(())
}
.with_timeout(Duration::from_millis(max_wait as u64 * 1000)) .with_timeout(Duration::from_millis(max_wait as u64 * 1000))
.await .await;
.context("Timeout waiting for wifi sta connected")??;
let res = async { if res.is_err() {
while !stack.is_link_up() { warn!(
"WiFi connection attempt {} failed: link up timeout",
attempts + 1
);
attempts += 1;
Timer::after(Duration::from_millis(500)).await; Timer::after(Duration::from_millis(500)).await;
continue;
} }
Ok::<(), FatError>(())
}
.with_timeout(Duration::from_millis(max_wait as u64 * 1000))
.await;
if res.is_err() { let res = async {
bail!("Timeout waiting for wifi link up") while !stack.is_config_up() {
} Timer::after(Duration::from_millis(100)).await
}
let res = async { Ok::<(), FatError>(())
while !stack.is_config_up() {
Timer::after(Duration::from_millis(100)).await
} }
Ok::<(), FatError>(()) .with_timeout(Duration::from_millis(max_wait as u64 * 1000))
} .await;
.with_timeout(Duration::from_millis(max_wait as u64 * 1000))
.await;
if res.is_err() { if res.is_err() {
bail!("Timeout waiting for wifi config up") warn!(
"WiFi connection attempt {} failed: config up timeout",
attempts + 1
);
attempts += 1;
Timer::after(Duration::from_millis(500)).await;
continue;
}
// Success!
info!("Connected WIFI, dhcp: {:?}", stack.config_v4());
return Ok(*stack);
} }
info!("Connected WIFI, dhcp: {:?}", stack.config_v4()); // All retries exhausted
Ok(*stack) bail!("WiFi connection failed after all retries");
} }
pub async fn try_connect_wifi_sntp_mqtt( pub async fn try_connect_wifi_sntp_mqtt(
+161 -42
View File
@@ -1,13 +1,16 @@
use crate::config::SensorCombineMode;
use crate::hal::Moistures; use crate::hal::Moistures;
use crate::plant_state::PlantWateringMode::TargetMoisture;
use crate::{config::PlantConfig, hal::HAL, in_time_range}; 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};
const MOIST_SENSOR_MAX_FREQUENCY: f32 = 70000.; // 70kHz 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
#[derive(Debug, PartialEq, Serialize)] #[derive(Debug, PartialEq, Clone, Serialize)]
#[serde(tag = "kind")]
pub enum MoistureSensorError { pub enum MoistureSensorError {
MissingMessage, MissingMessage,
NotExpectedMessage { hz: f32 }, NotExpectedMessage { hz: f32 },
@@ -46,6 +49,14 @@ impl MoistureSensorState {
impl MoistureSensorState {} impl MoistureSensorState {}
#[derive(Debug, PartialEq, Serialize)] #[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 { pub enum PumpError {
PumpNotWorking { PumpNotWorking {
failed_attempts: usize, failed_attempts: usize,
@@ -100,10 +111,31 @@ 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).
///
/// 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( fn map_range_moisture(
s: f32, s: f32,
min_frequency: Option<f32>, min_frequency: Option<f32>,
@@ -125,9 +157,28 @@ fn map_range_moisture(
max: max_freq, max: max_freq,
}); });
} }
let moisture_percent = (s - min_freq) * 100.0 / (max_freq - min_freq);
Ok(moisture_percent) // 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 { impl PlantState {
@@ -175,8 +226,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,
@@ -189,7 +244,20 @@ 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
@@ -212,26 +280,25 @@ 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.
&self, ///
) -> ( /// Returns:
Option<u8>, /// - Combined moisture percentage (or None if no valid readings)
(Option<&MoistureSensorError>, Option<&MoistureSensorError>), /// - 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 ( 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)) => match plant_conf.sensor_combine_mode {
(Some(((moisture_a + moisture_b) / 2.) as u8), (None, None)) SensorCombineMode::Min => Some(moisture_a.min(moisture_b)),
} SensorCombineMode::Max => Some(moisture_a.max(moisture_b)),
(Some(moisture_percent), _) => { SensorCombineMode::Avg => Some((moisture_a + moisture_b) / 2.0),
(Some(moisture_percent as u8), (None, self.sensor_b.is_err())) },
} (Some(moisture), _) => Some(moisture),
(_, Some(moisture_percent)) => { (_, Some(moisture)) => Some(moisture),
(Some(moisture_percent as u8), (self.sensor_a.is_err(), None)) _ => None,
}
_ => (None, (self.sensor_a.is_err(), self.sensor_b.is_err())),
} }
} }
@@ -243,11 +310,11 @@ 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
} else if moisture_percent < plant_conf.target_moisture { } else if moisture_percent < plant_conf.target_moisture.into() {
in_time_range( in_time_range(
current_time, current_time,
plant_conf.pump_hour_start, plant_conf.pump_hour_start,
@@ -269,23 +336,26 @@ impl PlantState {
} }
} }
pub fn to_mqtt_info( pub fn to_mqtt_info(&self, plant_conf: &PlantConfig, current_time: &DateTime<Tz>) -> PlantInfo {
&self, let moisture_pct = self.plant_moisture_with_warning(plant_conf);
plant_conf: &PlantConfig,
current_time: &DateTime<Tz>,
) -> PlantInfo<'_> {
PlantInfo { PlantInfo {
sensor_a: &self.sensor_a, moisture_pct,
sensor_b: &self.sensor_b, sensor_a: Self::sensor_to_telemetry(&self.sensor_a),
sensor_b: Self::sensor_to_telemetry(&self.sensor_b),
mode: plant_conf.mode, 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), do_water: self.needs_to_be_watered(plant_conf, current_time),
dry: if let Some(moisture_percent) = self.plant_moisture().0 { dry: if let Some(moisture_percent) = moisture_pct {
moisture_percent < plant_conf.target_moisture moisture_percent < plant_conf.target_moisture.into()
} else { } else {
false false
}, },
cooldown: self.pump_in_timeout(plant_conf, current_time), cooldown: self.pump_in_timeout(plant_conf, current_time),
out_of_work_hour: in_time_range( out_of_work_hour: !in_time_range(
current_time, current_time,
plant_conf.pump_hour_start, plant_conf.pump_hour_start,
plant_conf.pump_hour_end, plant_conf.pump_hour_end,
@@ -310,20 +380,67 @@ impl PlantState {
} else { } else {
None 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_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, }
}
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)] #[derive(Debug, PartialEq, Serialize)]
/// State of a single plant to be tracked /// State of a single plant to be tracked
pub struct PlantInfo<'a> { 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 /// state of humidity sensor on bank a
sensor_a: &'a MoistureSensorState, sensor_a: SensorTelemetry,
/// state of humidity sensor on bank b /// state of humidity sensor on bank b
sensor_b: &'a MoistureSensorState, sensor_b: SensorTelemetry,
/// configured plant watering mode /// configured plant watering mode
mode: PlantWateringMode, mode: PlantWateringMode,
/// the plant needs to be watered /// the plant needs to be watered
@@ -341,10 +458,12 @@ pub struct PlantInfo<'a> {
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,
} }
+24 -17
View File
@@ -10,11 +10,12 @@ const OPEN_TANK_VOLTAGE: f32 = 3.0;
pub const WATER_FROZEN_THRESH: f32 = 4.0; pub const WATER_FROZEN_THRESH: f32 = 4.0;
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind")]
pub enum TankError { pub enum TankError {
SensorDisabled, SensorDisabled,
SensorMissing(f32), SensorMissing { raw_mv: f32 },
SensorValueError { value: f32, min: f32, max: f32 }, SensorValueError { value: f32, min: f32, max: f32 },
BoardError(String), BoardError { message: String },
} }
pub enum TankState { pub enum TankState {
@@ -25,7 +26,9 @@ pub enum TankState {
fn raw_voltage_to_divider_percent(raw_value_mv: f32) -> Result<f32, TankError> { fn raw_voltage_to_divider_percent(raw_value_mv: f32) -> Result<f32, TankError> {
if raw_value_mv > OPEN_TANK_VOLTAGE { if raw_value_mv > OPEN_TANK_VOLTAGE {
return Err(TankError::SensorMissing(raw_value_mv)); return Err(TankError::SensorMissing {
raw_mv: raw_value_mv,
});
} }
let r2 = raw_value_mv * 50.0 / (3.3 - raw_value_mv); let r2 = raw_value_mv * 50.0 / (3.3 - raw_value_mv);
@@ -141,15 +144,15 @@ impl TankState {
TankInfo { TankInfo {
enough_water, enough_water,
warn_level, warn_level,
left_ml, volume_ml: left_ml,
sensor_error: tank_err, sensor_error: tank_err,
raw, fill_raw_v: raw,
water_frozen: water_temp water_frozen: water_temp
.as_ref() .as_ref()
.is_ok_and(|temp| *temp < WATER_FROZEN_THRESH), .is_ok_and(|temp| *temp < WATER_FROZEN_THRESH),
water_temp: water_temp.as_ref().copied().ok(), water_temp_c: water_temp.as_ref().copied().ok(),
temp_sensor_error: water_temp.as_ref().err().map(|err| err.to_string()), temp_sensor_error: water_temp.as_ref().err().map(|err| err.to_string()),
percent, fill_pct: percent,
} }
} }
} }
@@ -158,12 +161,16 @@ pub async fn determine_tank_state(
board: &mut MutexGuard<'static, CriticalSectionRawMutex, HAL<'static>>, board: &mut MutexGuard<'static, CriticalSectionRawMutex, HAL<'static>>,
) -> TankState { ) -> TankState {
if board.board_hal.get_config().tank.tank_sensor_enabled { if board.board_hal.get_config().tank.tank_sensor_enabled {
match board.board_hal.get_tank_sensor() { match board
Ok(sensor) => match sensor.tank_sensor_voltage().await { .board_hal
Ok(raw_sensor_value_mv) => TankState::Present(raw_sensor_value_mv), .get_tank_sensor()
Err(err) => TankState::Error(TankError::BoardError(err.to_string())), .tank_sensor_voltage()
}, .await
Err(err) => TankState::Error(TankError::BoardError(err.to_string())), {
Ok(raw_sensor_value_mv) => TankState::Present(raw_sensor_value_mv),
Err(err) => TankState::Error(TankError::BoardError {
message: err.to_string(),
}),
} }
} else { } else {
TankState::Disabled TankState::Disabled
@@ -178,16 +185,16 @@ pub struct TankInfo {
/// warning that water needs to be refilled soon /// warning that water needs to be refilled soon
pub(crate) warn_level: bool, pub(crate) warn_level: bool,
/// estimation how many ml are still in the tank /// estimation how many ml are still in the tank
pub(crate) left_ml: Option<f32>, pub(crate) volume_ml: Option<f32>,
/// if there is an issue with the water level sensor /// if there is an issue with the water level sensor
pub(crate) sensor_error: Option<TankError>, pub(crate) sensor_error: Option<TankError>,
/// raw water sensor value /// raw water sensor value
pub(crate) raw: Option<f32>, pub(crate) fill_raw_v: Option<f32>,
/// percent value /// percent value
pub(crate) percent: Option<f32>, pub(crate) fill_pct: Option<f32>,
/// water in the tank might be frozen /// water in the tank might be frozen
pub(crate) water_frozen: bool, pub(crate) water_frozen: bool,
/// water temperature /// water temperature
pub(crate) water_temp: Option<f32>, pub(crate) water_temp_c: Option<f32>,
pub(crate) temp_sensor_error: Option<String>, pub(crate) temp_sensor_error: Option<String>,
} }
+1 -1
View File
@@ -2,7 +2,7 @@ macro_rules! mk_static {
($t:ty,$val:expr) => {{ ($t:ty,$val:expr) => {{
static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new(); static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new();
#[deny(unused_attributes)] #[deny(unused_attributes)]
let x = STATIC_CELL.uninit().write(($val)); let x = STATIC_CELL.uninit().write($val);
x x
}}; }};
} }
@@ -23,6 +23,8 @@ struct LoadData<'a> {
struct Moistures { struct Moistures {
moisture_a: Vec<String>, moisture_a: Vec<String>,
moisture_b: Vec<String>, moisture_b: Vec<String>,
sensor_a_build_minutes: Vec<Option<u32>>,
sensor_b_build_minutes: Vec<Option<u32>>,
} }
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
struct SolarState { struct SolarState {
@@ -63,9 +65,20 @@ where
MoistureSensorState::NoMessage => "No Message".to_string(), MoistureSensorState::NoMessage => "No Message".to_string(),
})); }));
let sensor_a_build_minutes: Vec<Option<u32>> = plant_state
.iter()
.map(|s| s.sensor_a_firmware_build_minutes)
.collect();
let sensor_b_build_minutes: Vec<Option<u32>> = plant_state
.iter()
.map(|s| s.sensor_b_firmware_build_minutes)
.collect();
let data = Moistures { let data = Moistures {
moisture_a: a, moisture_a: a,
moisture_b: b, moisture_b: b,
sensor_a_build_minutes,
sensor_b_build_minutes,
}; };
let json = serde_json::to_string(&data)?; let json = serde_json::to_string(&data)?;
@@ -80,10 +93,11 @@ where
{ {
let mut board = BOARD_ACCESS.get().await.lock().await; let mut board = BOARD_ACCESS.get().await.lock().await;
let tank_state = determine_tank_state(&mut board).await; let tank_state = determine_tank_state(&mut board).await;
//should be multisampled let water_temp: FatResult<f32> = board
let sensor = board.board_hal.get_tank_sensor()?; .board_hal
.get_tank_sensor()
let water_temp: FatResult<f32> = sensor.water_temperature_c().await; .water_temperature_c()
.await;
Ok(Some(serde_json::to_string(&tank_state.as_mqtt_info( Ok(Some(serde_json::to_string(&tank_state.as_mqtt_info(
&board.board_hal.get_config().tank, &board.board_hal.get_config().tank,
&water_temp, &water_temp,
@@ -204,3 +218,12 @@ pub(crate) async fn get_log_localization_config<T, const N: usize>(
&LogMessage::log_localisation_config(), &LogMessage::log_localisation_config(),
)?)) )?))
} }
/// Return Wi-Fi scan details including signal strength (RSSI)
pub(crate) async fn get_wifi_details<T, const N: usize>(
_request: &mut Connection<'_, T, N>,
) -> FatResult<Option<String>> {
let mut board = BOARD_ACCESS.get().await.lock().await;
let wifi_details = board.board_hal.get_esp().wifi_scan_details().await?;
Ok(Some(serde_json::to_string(&wifi_details)?))
}
+4 -2
View File
@@ -10,8 +10,9 @@ mod post_json;
use crate::fat_error::{FatError, FatResult}; use crate::fat_error::{FatError, FatResult};
use crate::webserver::backup_manager::{backup_config, backup_info, get_backup_config}; use crate::webserver::backup_manager::{backup_config, backup_info, get_backup_config};
use crate::webserver::get_json::{ use crate::webserver::get_json::{
delete_save, get_battery_state, get_config, get_live_moisture, get_log_localization_config, delete_save, get_battery_state, get_config, get_firmware_info_web, get_live_moisture,
get_firmware_info_web, get_solar_state, get_time, get_timezones, list_saves, tank_info, get_log_localization_config, get_solar_state, get_time, get_timezones, get_wifi_details,
list_saves, tank_info,
}; };
use crate::webserver::get_log::{get_live_log, get_log}; use crate::webserver::get_log::{get_live_log, get_log};
use crate::webserver::get_static::{serve_bundle, serve_favicon, serve_index}; use crate::webserver::get_static::{serve_bundle, serve_favicon, serve_index};
@@ -83,6 +84,7 @@ impl Handler for HTTPRequestRouter {
"/timezones" => Some(get_timezones().await), "/timezones" => Some(get_timezones().await),
"/moisture" => Some(get_live_moisture(conn).await), "/moisture" => Some(get_live_moisture(conn).await),
"/list_saves" => Some(list_saves(conn).await), "/list_saves" => Some(list_saves(conn).await),
"/wifi_details" => Some(get_wifi_details(conn).await),
// /live_log accepts an optional ?after=N query parameter // /live_log accepts an optional ?after=N query parameter
p if p == "/live_log" || p.starts_with("/live_log?") => { p if p == "/live_log" || p.starts_with("/live_log?") => {
let after: Option<u64> = p let after: Option<u64> = p
@@ -1,5 +1,6 @@
use crate::config::PlantControllerConfig; use crate::config::PlantControllerConfig;
use crate::fat_error::FatResult; use crate::fat_error::FatResult;
use crate::hal::savegame_manager::SAVEGAME_SLOT_SIZE;
use crate::hal::DetectionRequest; use crate::hal::DetectionRequest;
use crate::webserver::read_up_to_bytes_from_request; use crate::webserver::read_up_to_bytes_from_request;
use crate::{do_secure_pump, BOARD_ACCESS}; use crate::{do_secure_pump, BOARD_ACCESS};
@@ -135,7 +136,8 @@ pub(crate) async fn set_config<T, const N: usize>(
where where
T: Read + Write, T: Read + Write,
{ {
let all = read_up_to_bytes_from_request(request, Some(4096)).await?; //accept nearly full slotsize leave some space for header
let all = read_up_to_bytes_from_request(request, Some(SAVEGAME_SLOT_SIZE - 512)).await?;
let length = all.len(); let length = all.len();
let config: PlantControllerConfig = serde_json::from_slice(&all)?; let config: PlantControllerConfig = serde_json::from_slice(&all)?;
+20 -8
View File
@@ -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 {
@@ -177,6 +178,8 @@ export interface GetTime {
export interface Moistures { export interface Moistures {
moisture_a: [string], moisture_a: [string],
moisture_b: [string], moisture_b: [string],
sensor_a_build_minutes: Array<number | null>,
sensor_b_build_minutes: Array<number | null>,
} }
export interface VersionInfo { export interface VersionInfo {
@@ -223,23 +226,32 @@ export interface Detection {
plant: DetectionPlant[] plant: DetectionPlant[]
} }
/// Wi-Fi scan result details for UI display
export interface WifiScanResult {
ssid: string,
bssid: string,
rssi: number, // signal strength in dBm
channel: number,
auth_method: string
}
export interface TankInfo { export interface TankInfo {
/// is there enough water in the tank /// there is enough water in the tank
enough_water: boolean, enough_water: boolean,
/// warning that water needs to be refilled soon /// warning that water needs to be refilled soon
warn_level: boolean, warn_level: boolean,
/// estimation how many ml are still in tank /// estimation how many ml are still in the tank
left_ml: number | null, volume_ml: number | null,
/// if there is was an issue with the water level sensor /// if there is an issue with the water level sensor
sensor_error: string | null, sensor_error: string | null,
/// raw water sensor value /// raw water sensor value
raw: number | null, fill_raw_v: number | null,
/// percent value /// percent value
percent: number | null, fill_pct: number | null,
/// water in tank might be frozen /// water in the tank might be frozen
water_frozen: boolean, water_frozen: boolean,
/// water temperature /// water temperature
water_temp: number | null, water_temp_c: number | null,
temp_sensor_error: string | null temp_sensor_error: string | null
} }
@@ -37,7 +37,7 @@
} }
#logpanel { #logpanel {
display: none;
} }
</style> </style>
+115 -9
View File
@@ -26,7 +26,7 @@ import {
Moistures, Moistures,
NightLampCommand, NightLampCommand,
PlantControllerConfig, PlantControllerConfig,
SetTime, SSIDList, TankInfo, SetTime, SSIDList, TankInfo, WifiScanResult,
TestPump, TestPump,
VersionInfo, VersionInfo,
SaveInfo, SolarState, PumpTestResult, Detection, DetectionRequest, CanPower SaveInfo, SolarState, PumpTestResult, Detection, DetectionRequest, CanPower
@@ -43,6 +43,7 @@ export class Controller {
controller.tankView.setTankInfo(tankinfo) controller.tankView.setTankInfo(tankinfo)
}) })
.catch(error => { .catch(error => {
toast.error(`Failed to load tank info: ${error}`);
console.log(error); console.log(error);
}); });
} }
@@ -64,7 +65,9 @@ export class Controller {
const json = await response.json(); const json = await response.json();
const logs = json as LogArray; const logs = json as LogArray;
controller.logView.setLog(logs); controller.logView.setLog(logs);
toast.info("Log loaded successfully");
} catch (error) { } catch (error) {
toast.error(`Failed to load log: ${error}`);
console.log(error); console.log(error);
} }
} }
@@ -87,6 +90,7 @@ export class Controller {
const timezones = json as string[]; const timezones = json as string[];
controller.timeView.timezones(timezones); controller.timeView.timezones(timezones);
} catch (error) { } catch (error) {
toast.error(`Error fetching timezones: ${error}`);
return console.error('Error fetching timezones:', error); return console.error('Error fetching timezones:', error);
} }
} }
@@ -98,6 +102,7 @@ export class Controller {
const saves = json as SaveInfo[]; const saves = json as SaveInfo[];
controller.fileview.setSaveList(saves, PUBLIC_URL); controller.fileview.setSaveList(saves, PUBLIC_URL);
} catch (error) { } catch (error) {
toast.error(`Failed to update save list: ${error}`);
console.log(error); console.log(error);
} }
} }
@@ -109,16 +114,21 @@ export class Controller {
ajax.send(); ajax.send();
ajax.addEventListener("error", () => { ajax.addEventListener("error", () => {
controller.progressview.removeProgress("slot_delete"); controller.progressview.removeProgress("slot_delete");
alert("Error deleting slot"); toast.error(`Failed to delete slot ${idx}`);
controller.updateSaveList(); controller.updateSaveList();
}, false); }, false);
ajax.addEventListener("abort", () => { ajax.addEventListener("abort", () => {
controller.progressview.removeProgress("slot_delete"); controller.progressview.removeProgress("slot_delete");
alert("Aborted deleting slot"); toast.warning(`Slot deletion aborted`);
controller.updateSaveList(); controller.updateSaveList();
}, false); }, false);
ajax.addEventListener("load", () => { ajax.addEventListener("load", () => {
controller.progressview.removeProgress("slot_delete"); controller.progressview.removeProgress("slot_delete");
if (ajax.status >= 200 && ajax.status < 300) {
toast.success("Slot deleted successfully");
} else {
toast.error(`Failed to delete slot: ${ajax.status}`);
}
controller.updateSaveList(); controller.updateSaveList();
}, false); }, false);
} }
@@ -131,6 +141,7 @@ export class Controller {
controller.timeView.update(time.native, time.rtc); controller.timeView.update(time.native, time.rtc);
} catch (error) { } catch (error) {
controller.timeView.update("n/a", "n/a"); controller.timeView.update("n/a", "n/a");
toast.error(`Failed to update RTC data: ${error}`);
console.log(error); console.log(error);
} }
} }
@@ -143,6 +154,7 @@ export class Controller {
controller.batteryView.update(battery); controller.batteryView.update(battery);
} catch (error) { } catch (error) {
controller.batteryView.update(null); controller.batteryView.update(null);
toast.error(`Failed to update battery data: ${error}`);
console.log(error); console.log(error);
} }
} }
@@ -155,6 +167,22 @@ export class Controller {
controller.solarView.update(solar); controller.solarView.update(solar);
} catch (error) { } catch (error) {
controller.solarView.update(null); controller.solarView.update(null);
toast.error(`Failed to update solar data: ${error}`);
console.log(error);
}
}
async scanWifiDetails(): Promise<void> {
try {
const response = await fetch(PUBLIC_URL + "/wifi_details");
if (response.ok) {
const wifiDetails = await response.json();
controller.networkView.displayWifiResults(wifiDetails as WifiScanResult[]);
} else {
toast.error(`Failed to fetch Wi-Fi details: ${response.status}`);
}
} catch (error) {
toast.error(`Wi-Fi details error: ${error}`);
console.log(error); console.log(error);
} }
} }
@@ -173,6 +201,7 @@ export class Controller {
controller.progressview.removeProgress("ota_upload") controller.progressview.removeProgress("ota_upload")
const status = ajax.status; const status = ajax.status;
if (status >= 200 && status < 300) { if (status >= 200 && status < 300) {
toast.success("OTA firmware upload successful");
controller.reboot(); controller.reboot();
} else { } else {
const statusText = ajax.statusText || ""; const statusText = ajax.statusText || "";
@@ -199,6 +228,7 @@ export class Controller {
const versionInfo = json as VersionInfo; const versionInfo = json as VersionInfo;
controller.progressview.removeProgress("version"); controller.progressview.removeProgress("version");
controller.firmWareView.setVersion(versionInfo); controller.firmWareView.setVersion(versionInfo);
toast.info("Firmware version information updated");
} }
getBackupConfig() { getBackupConfig() {
@@ -241,6 +271,7 @@ export class Controller {
.then(status => { .then(status => {
controller.progressview.removeProgress("set_config"); controller.progressview.removeProgress("set_config");
if (status == 200) { if (status == 200) {
toast.success("Configuration saved successfully");
setTimeout(() => { setTimeout(() => {
controller.downloadConfig().then(() => { controller.downloadConfig().then(() => {
controller.updateSaveList().then(() => { controller.updateSaveList().then(() => {
@@ -268,7 +299,14 @@ export class Controller {
fetch(PUBLIC_URL + "/time", { fetch(PUBLIC_URL + "/time", {
method: "POST", method: "POST",
body: pretty body: pretty
}).then( })
.then(response => {
if (!response.ok) {
toast.error(`Failed to sync RTC: ${response.status}`);
}
return response;
})
.then(
_ => controller.progressview.removeProgress("write_rtc") _ => controller.progressview.removeProgress("write_rtc")
) )
} }
@@ -288,9 +326,23 @@ export class Controller {
} }
selfTest() { selfTest() {
controller.progressview.addIndeterminate("self_test", "Running board test")
fetch(PUBLIC_URL + "/boardtest", { fetch(PUBLIC_URL + "/boardtest", {
method: "POST" method: "POST"
}) })
.then(response => {
if (response.ok) {
toast.success("Board test completed");
} else {
toast.error(`Board test failed: ${response.status}`);
}
})
.catch(error => {
toast.error(`Board test error: ${error}`);
})
.finally(() => {
controller.progressview.removeProgress("self_test");
});
} }
testNightLamp(active: boolean) { testNightLamp(active: boolean) {
@@ -298,21 +350,52 @@ export class Controller {
active: active active: active
}; };
var pretty = JSON.stringify(body, undefined, 1); var pretty = JSON.stringify(body, undefined, 1);
controller.progressview.addIndeterminate("night_lamp_test", "Testing night lamp")
fetch(PUBLIC_URL + "/lamptest", { fetch(PUBLIC_URL + "/lamptest", {
method: "POST", method: "POST",
body: pretty body: pretty
}) })
.then(response => {
if (response.ok) {
toast.success(`Night lamp ${active ? "enabled" : "disabled"} successfully`);
} else {
toast.error(`Night lamp test failed: ${response.status}`);
}
})
.catch(error => {
toast.error(`Night lamp test error: ${error}`);
})
.finally(() => {
controller.progressview.removeProgress("night_lamp_test");
});
} }
testFertilizerPump() { testFertilizerPump() {
controller.progressview.addIndeterminate("fert_test", "Testing fertilizer pump")
fetch(PUBLIC_URL + "/fertilizerpumptest", { fetch(PUBLIC_URL + "/fertilizerpumptest", {
method: "POST" method: "POST"
}) })
.then(response => {
if (response.ok) {
toast.success("Fertilizer pump test completed");
} else {
toast.error(`Fertilizer pump test failed: ${response.status}`);
}
})
.catch(error => {
toast.error(`Fertilizer pump test error: ${error}`);
})
.finally(() => {
controller.progressview.removeProgress("fert_test");
});
} }
testPlant(plantId: number) { testPlant(plantId: number) {
const plantConfig = controller.getConfig().plants[plantId];
const pumpTimeS = plantConfig.pump_time_s;
let counter = 0 let counter = 0
let limit = 30 let limit = pumpTimeS > 0 ? Math.ceil(pumpTimeS) : 30
controller.progressview.addProgress("test_pump", counter / limit * 100, "Testing pump " + (plantId + 1) + " for " + (limit - counter) + "s") controller.progressview.addProgress("test_pump", counter / limit * 100, "Testing pump " + (plantId + 1) + " for " + (limit - counter) + "s")
let timerId: string | number | NodeJS.Timeout | undefined let timerId: string | number | NodeJS.Timeout | undefined
@@ -341,6 +424,11 @@ export class Controller {
controller.plantViews.setPumpTestCurrent(plantId, response); controller.plantViews.setPumpTestCurrent(plantId, response);
clearTimeout(timerId); clearTimeout(timerId);
controller.progressview.removeProgress("test_pump"); controller.progressview.removeProgress("test_pump");
if (!response.error) {
toast.success(`Pump ${plantId + 1} test completed successfully`);
} else {
toast.error(`Pump ${plantId + 1} test reported an error`);
}
} }
) )
} }
@@ -425,13 +513,20 @@ export class Controller {
if (ajax.readyState === 4) { if (ajax.readyState === 4) {
clearTimeout(timerId); clearTimeout(timerId);
controller.progressview.removeProgress("scan_ssid"); controller.progressview.removeProgress("scan_ssid");
this.networkView.setScanResult(ajax.response as SSIDList) if (ajax.status >= 200 && ajax.status < 300) {
this.networkView.setScanResult(ajax.response as SSIDList);
toast.success("WiFi scan completed");
// Also fetch detailed Wi-Fi information
this.scanWifiDetails();
} else {
toast.error(`WiFi scan failed: ${ajax.status}`);
}
} }
}; };
ajax.onerror = (_) => { ajax.onerror = (_) => {
clearTimeout(timerId); clearTimeout(timerId);
controller.progressview.removeProgress("scan_ssid"); controller.progressview.removeProgress("scan_ssid");
alert("Failed to start see console") toast.error("Failed to start WiFi scan");
} }
ajax.open("POST", PUBLIC_URL + "/wifiscan"); ajax.open("POST", PUBLIC_URL + "/wifiscan");
ajax.send(); ajax.send();
@@ -478,12 +573,15 @@ export class Controller {
return fetch(PUBLIC_URL + "/moisture") return fetch(PUBLIC_URL + "/moisture")
.then(response => response.json()) .then(response => response.json())
.then(json => json as Moistures) .then(json => json as Moistures)
.then(time => { .then(data => {
controller.plantViews.update(time.moisture_a, time.moisture_b) controller.plantViews.update(data.moisture_a, data.moisture_b, data.sensor_a_build_minutes, data.sensor_b_build_minutes)
clearTimeout(timerId); clearTimeout(timerId);
if (!silent) { if (!silent) {
controller.progressview.removeProgress("measure_moisture"); controller.progressview.removeProgress("measure_moisture");
} }
if (!silent) {
toast.success("Moisture measurement completed");
}
}) })
.catch(error => { .catch(error => {
@@ -629,6 +727,7 @@ export class Controller {
}; };
await this.detectSensors(detection, true); await this.detectSensors(detection, true);
} catch (e) { } catch (e) {
toast.error(`Auto-refresh error: ${e}`);
console.error("Auto-refresh error", e); console.error("Auto-refresh error", e);
} }
@@ -649,6 +748,7 @@ const tasks = [
{task: controller.updateRTCData, displayString: "Updating RTC Data"}, {task: controller.updateRTCData, displayString: "Updating RTC Data"},
{task: controller.updateBatteryData, displayString: "Updating Battery Data"}, {task: controller.updateBatteryData, displayString: "Updating Battery Data"},
{task: controller.updateSolarData, displayString: "Updating Solar Data"}, {task: controller.updateSolarData, displayString: "Updating Solar Data"},
{task: () => controller.measure_moisture(true), displayString: "Measuring Moisture"},
{task: controller.downloadConfig, displayString: "Downloading Configuration"}, {task: controller.downloadConfig, displayString: "Downloading Configuration"},
{task: controller.version, displayString: "Fetching Version Information"}, {task: controller.version, displayString: "Fetching Version Information"},
{task: controller.updateSaveList, displayString: "Updating Save Slots"}, {task: controller.updateSaveList, displayString: "Updating Save Slots"},
@@ -666,6 +766,7 @@ async function executeTasksSequentially() {
try { try {
await task(); await task();
} catch (error) { } catch (error) {
toast.error(`Error executing task '${displayString}': ${error}`);
console.error(`Error executing task '${displayString}':`, error); console.error(`Error executing task '${displayString}':`, error);
// Optionally, you can decide whether to continue or break on errors // Optionally, you can decide whether to continue or break on errors
break; break;
@@ -681,6 +782,11 @@ executeTasksSequentially().then(() => {
controller.progressview.removeProgress("rebooting"); controller.progressview.removeProgress("rebooting");
window.addEventListener("beforeunload", (event) => { window.addEventListener("beforeunload", (event) => {
// Only check for unsaved changes if initialConfig has been loaded
if (controller.initialConfig === null) {
return;
}
const currentConfig = controller.getConfig(); const currentConfig = controller.getConfig();
// Check if the current state differs from the initial configuration // Check if the current state differs from the initial configuration
@@ -85,7 +85,15 @@
<input class="mqttvalue" type="text" id="mqtt_password" placeholder=""> <input class="mqttvalue" type="text" id="mqtt_password" placeholder="">
</div> </div>
</div> </div>
<div class="subcontainer">
<div class="flexcontainer">
<div class="subtitle">Wi-Fi Scan Results</div>
</div>
<div id="wifi-results">
<p>Scan for available networks to see signal strength</p>
</div>
</div>
</div> </div>
</div> </div>
@@ -1,7 +1,9 @@
import { Controller } from "./main"; import { Controller } from "./main";
import {NetworkConfig, SSIDList} from "./api"; import {NetworkConfig, SSIDList, WifiScanResult} from "./api";
export class NetworkConfigView { export class NetworkConfigView {
private wifiResults: HTMLElement;
setScanResult(ssidList: SSIDList) { setScanResult(ssidList: SSIDList) {
this.ssidlist.innerHTML = '' this.ssidlist.innerHTML = ''
for (const ssid of ssidList.ssids) { for (const ssid of ssidList.ssids) {
@@ -10,6 +12,47 @@ export class NetworkConfigView {
this.ssidlist.appendChild(wi); this.ssidlist.appendChild(wi);
} }
} }
async scanAndDisplayWifiDetails() {
try {
const response = await fetch('/wifi_details');
if (response.ok) {
const data: WifiScanResult[] = await response.json();
this.displayWifiResults(data);
}
} catch (error) {
console.error('Error fetching Wi-Fi details:', error);
this.displayWifiResults([]);
}
}
displayWifiResults(results: WifiScanResult[]) {
const wifiContainer = document.getElementById('wifi-results');
if (!wifiContainer) return;
if (results.length === 0) {
wifiContainer.innerHTML = '<p>No Wi-Fi networks found</p>';
return;
}
let html = '<table style="width:100%; border-collapse: collapse;">';
html += '<tr><th style="text-align:left; padding: 8px;">SSID</th>';
html += '<th style="text-align:left; padding: 8px;">Signal (RSSI)</th>';
html += '<th style="text-align:left; padding: 8px;">Channel</th>';
html += '<th style="text-align:left; padding: 8px;">Authentication</th></tr>';
results.forEach(result => {
html += '<tr style="border-bottom: 1px solid #ddd;">';
html += `<td style="padding: 8px;">${result.ssid}</td>`;
html += `<td style="padding: 8px;">${result.rssi} dBm</td>`;
html += `<td style="padding: 8px;">${result.channel}</td>`;
html += `<td style="padding: 8px;">${result.auth_method}</td>`;
html += '</tr>';
});
html += '</table>';
wifiContainer.innerHTML = html;
}
private readonly ap_ssid: HTMLInputElement; private readonly ap_ssid: HTMLInputElement;
private readonly ssid: HTMLInputElement; private readonly ssid: HTMLInputElement;
private readonly password: HTMLInputElement; private readonly password: HTMLInputElement;
@@ -47,9 +90,14 @@ export class NetworkConfigView {
this.ssidlist = document.getElementById("ssidlist") as HTMLElement this.ssidlist = document.getElementById("ssidlist") as HTMLElement
let scanWifiBtn = document.getElementById("scan") as HTMLButtonElement; let scanWifiBtn = document.getElementById("scan") as HTMLButtonElement;
scanWifiBtn.onclick = function (){ scanWifiBtn.onclick = async () => {
controller.scanWifi(); controller.scanWifi();
} // After Wi-Fi scan, fetch and display detailed results
await this.scanAndDisplayWifiDetails();
};
// Store wifiResults reference for later use
this.wifiResults = document.getElementById('wifi-results') as HTMLElement;
} }
setConfig(network: NetworkConfig) { setConfig(network: NetworkConfig) {
@@ -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:
@@ -36,11 +36,19 @@ export class PlantViews {
return rv return rv
} }
update(moisture_a: [string], moisture_b: [string]) { update(moisture_a: [string], moisture_b: [string], sensor_a_build_minutes?: Array<number | null>, sensor_b_build_minutes?: Array<number | null>) {
for (let plantId = 0; plantId < PLANT_COUNT; plantId++) { for (let plantId = 0; plantId < PLANT_COUNT; plantId++) {
const a = moisture_a[plantId] const a = moisture_a[plantId]
const b = moisture_b[plantId] const b = moisture_b[plantId]
this.plants[plantId].setMeasurementResult(a, b) this.plants[plantId].setMeasurementResult(a, b)
// Update firmware build timestamps if provided
if (sensor_a_build_minutes && sensor_a_build_minutes[plantId] !== undefined) {
this.plants[plantId].setFirmwareBuild("sensor_a", sensor_a_build_minutes[plantId])
}
if (sensor_b_build_minutes && sensor_b_build_minutes[plantId] !== undefined) {
this.plants[plantId].setFirmwareBuild("sensor_b", sensor_b_build_minutes[plantId])
}
} }
} }
@@ -85,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;
@@ -228,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()
@@ -263,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)
@@ -316,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) {
@@ -346,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();
@@ -375,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,
@@ -406,4 +441,12 @@ export class PlantView {
this.sensorAFwBuild.innerText = formatBuildMinutes(plantResult.sensor_a); this.sensorAFwBuild.innerText = formatBuildMinutes(plantResult.sensor_a);
this.sensorBFwBuild.innerText = formatBuildMinutes(plantResult.sensor_b); this.sensorBFwBuild.innerText = formatBuildMinutes(plantResult.sensor_b);
} }
setFirmwareBuild(sensor: "sensor_a" | "sensor_b", buildMinutes: number | null) {
if (sensor === "sensor_a") {
this.sensorAFwBuild.innerText = formatBuildMinutes(buildMinutes);
} else {
this.sensorBFwBuild.innerText = formatBuildMinutes(buildMinutes);
}
}
} }
@@ -1,5 +1,6 @@
import {Controller} from "./main"; import {Controller} from "./main";
import {BackupHeader} from "./api"; import {BackupHeader} from "./api";
import {toast} from "./toast";
export class SubmitView { export class SubmitView {
json: HTMLDivElement; json: HTMLDivElement;
@@ -26,25 +27,28 @@ export class SubmitView {
this.submit_status = document.getElementById("submit_status") as HTMLElement this.submit_status = document.getElementById("submit_status") as HTMLElement
this.submitFormBtn.onclick = () => { this.submitFormBtn.onclick = () => {
controller.uploadConfig(this.json.textContent as string, (status: string) => { controller.uploadConfig(this.json.textContent as string, (status: string) => {
if (status != "OK") { toast.info(status);
// Show error toast (click to dismiss only)
const {toast} = require('./toast');
toast.error(status);
} else {
// Show info toast (auto hides after 5s, or click to dismiss sooner)
const {toast} = require('./toast');
toast.info('Config uploaded successfully');
}
this.submit_status.innerHTML = status; this.submit_status.innerHTML = status;
}); });
} }
this.backupBtn.onclick = () => { this.backupBtn.onclick = () => {
controller.progressview.addIndeterminate("backup", "Backup to EEPROM running") controller.progressview.addIndeterminate("backup", "Backup to EEPROM running")
controller.backupConfig(this.json.textContent as string).then(saveStatus => { controller.backupConfig(this.json.textContent as string).then(saveStatus => {
if (saveStatus === "OK") {
toast.success("Configuration backup successful");
} else {
toast.error(`Backup failed: ${saveStatus}`);
}
controller.getBackupInfo().then(r => { controller.getBackupInfo().then(r => {
controller.progressview.removeProgress("backup") controller.progressview.removeProgress("backup")
this.submit_status.innerHTML = saveStatus; this.submit_status.innerHTML = saveStatus;
}); });
}).catch(error => {
toast.error(`Backup error: ${error}`);
controller.getBackupInfo().then(r => {
controller.progressview.removeProgress("backup")
this.submit_status.innerHTML = "Error";
});
}); });
} }
this.restoreBackupBtn.onclick = () => { this.restoreBackupBtn.onclick = () => {
@@ -93,28 +93,28 @@ export class TankConfigView {
this.tank_measure_error.innerText = JSON.stringify(tankinfo.sensor_error) ; this.tank_measure_error.innerText = JSON.stringify(tankinfo.sensor_error) ;
this.tank_measure_error_container.classList.remove("hidden") this.tank_measure_error_container.classList.remove("hidden")
} }
if (tankinfo.left_ml == null){ if (tankinfo.volume_ml == null){
this.tank_measure_ml_container.classList.add("hidden") this.tank_measure_ml_container.classList.add("hidden")
} else { } else {
this.tank_measure_ml.innerText = tankinfo.left_ml.toString(); this.tank_measure_ml.innerText = tankinfo.volume_ml.toString();
this.tank_measure_ml_container.classList.remove("hidden") this.tank_measure_ml_container.classList.remove("hidden")
} }
if (tankinfo.percent == null){ if (tankinfo.fill_pct == null){
this.tank_measure_percent_container.classList.add("hidden") this.tank_measure_percent_container.classList.add("hidden")
} else { } else {
this.tank_measure_percent.innerText = tankinfo.percent.toString(); this.tank_measure_percent.innerText = tankinfo.fill_pct.toString();
this.tank_measure_percent_container.classList.remove("hidden") this.tank_measure_percent_container.classList.remove("hidden")
} }
if (tankinfo.water_temp == null){ if (tankinfo.water_temp_c == null){
this.tank_measure_temperature_container.classList.add("hidden") this.tank_measure_temperature_container.classList.add("hidden")
} else { } else {
this.tank_measure_temperature.innerText = tankinfo.water_temp.toString(); this.tank_measure_temperature.innerText = tankinfo.water_temp_c.toString();
this.tank_measure_temperature_container.classList.remove("hidden") this.tank_measure_temperature_container.classList.remove("hidden")
} }
if (tankinfo.raw == null){ if (tankinfo.fill_raw_v == null){
this.tank_measure_rawvolt_container.classList.add("hidden") this.tank_measure_rawvolt_container.classList.add("hidden")
} else { } else {
this.tank_measure_rawvolt.innerText = tankinfo.raw.toString(); this.tank_measure_rawvolt.innerText = tankinfo.fill_raw_v.toString();
this.tank_measure_rawvolt_container.classList.remove("hidden") this.tank_measure_rawvolt_container.classList.remove("hidden")
} }
+407 -69
View File
@@ -1,94 +1,432 @@
class ToastService { /**
private container: HTMLElement; * Toast notification service for PlantCtrl embedded web interface
private stylesInjected = false; * Provides non-blocking notifications with auto-dismiss and click-to-close functionality
*/
constructor() { const TOAST_container_ID = 'toast-container';
this.container = this.ensureContainer(); const TOAST_STYLES_KEY = 'toast-styles-injected';
this.injectStyles();
}
info(message: string, timeoutMs: number = 5000) { interface ToastOptions {
const el = this.createToast(message, 'info'); duration?: number;
this.container.appendChild(el); dismissible?: boolean;
// Auto-dismiss after timeout }
const timer = window.setTimeout(() => this.dismiss(el), timeoutMs);
// Dismiss on click immediately
el.addEventListener('click', () => {
window.clearTimeout(timer);
this.dismiss(el);
});
}
error(message: string) { interface ToastData {
console.error(message); id: string;
const el = this.createToast(message, 'error'); type: 'info' | 'success' | 'warning' | 'error';
this.container.appendChild(el); message: string;
// Only dismiss on click createdAt: number;
el.addEventListener('click', () => this.dismiss(el)); element?: HTMLElement;
} }
private dismiss(el: HTMLElement) { /**
if (!el.parentElement) return; * Toast service for displaying notifications
el.parentElement.removeChild(el); */
} export class ToastService {
private container: HTMLElement | null = null;
private activeToasts: Map<string, ToastData> = new Map();
private maxToasts: number = 5;
private createToast(message: string, type: 'info' | 'error'): HTMLElement { // Default configuration
const div = document.createElement('div'); private defaultDuration: number = 5000; // 5 seconds for info messages
div.className = `toast ${type}`; private errorDuration: number = 10000; // 10 seconds for error messages
div.textContent = message;
div.setAttribute('role', 'status');
div.setAttribute('aria-live', 'polite');
return div;
}
private ensureContainer(): HTMLElement { constructor() {
let container = document.getElementById('toast-container'); this.init();
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
document.body.appendChild(container);
} }
return container;
}
private injectStyles() { /**
if (this.stylesInjected) return; * Initialize the toast container and inject styles
const style = document.createElement('style'); */
style.textContent = ` private init(): void {
this.ensureContainer();
this.injectStyles();
}
/**
* Get or create the toast container element
*/
private ensureContainer(): HTMLElement {
if (this.container) return this.container;
let container = document.getElementById(TOAST_container_ID);
if (!container) {
container = document.createElement('div');
container.id = TOAST_container_ID;
container.setAttribute('role', 'region');
container.setAttribute('aria-label', 'Notifications');
document.body.appendChild(container);
}
this.container = container;
return container;
}
/**
* Inject toast styles if not already injected
*/
private injectStyles(): void {
if (document.querySelector(`style[data-id="${TOAST_STYLES_KEY}"]`)) {
return;
}
const style = document.createElement('style');
style.setAttribute('data-id', TOAST_STYLES_KEY);
style.textContent = `
#toast-container { #toast-container {
position: fixed; position: fixed;
top: 12px; top: 16px;
right: 12px; right: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 10px;
z-index: 9999; z-index: 9999;
max-width: 400px;
pointer-events: none;
} }
.toast { .toast {
max-width: 320px; background: #fff;
padding: 10px 12px; border-left: 4px solid transparent;
border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: 0 2px 6px rgba(0,0,0,0.2); border-radius: 8px;
cursor: pointer; padding: 12px 16px;
user-select: none; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-family: sans-serif;
font-size: 14px; font-size: 14px;
line-height: 1.3; line-height: 1.4;
color: #333;
max-width: 100%;
pointer-events: auto;
animation: toast-slide-in 0.3s cubic-bezier(0.4, 0, 0.2, 1),
toast-fade-in 0.3s ease-out;
display: flex;
align-items: center;
gap: 12px;
} }
.toast.info { .toast.info {
background-color: #d4edda; /* green-ish */ border-color: #3b82f6;
color: #155724; background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
border-left: 4px solid #28a745;
} }
.toast.success {
border-color: #22c55e;
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
}
.toast.warning {
border-color: #f59e0b;
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
}
.toast.error { .toast.error {
background-color: #f8d7da; /* red-ish */ border-color: #ef4444;
color: #721c24; background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
border-left: 4px solid #dc3545;
} }
`;
document.head.appendChild(style); .toast:hover {
this.stylesInjected = true; transform: translateX(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.toast-icon {
flex-shrink: 0;
font-size: 18px;
}
.toast-message {
flex-grow: 1;
word-wrap: break-word;
overflow-wrap: anywhere;
}
.toast-close-btn {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
padding: 4px;
margin-left: -4px;
opacity: 0.6;
transition: opacity 0.2s;
color: inherit;
}
.toast-close-btn:hover {
opacity: 1;
}
@keyframes toast-slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
} }
} }
@keyframes toast-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes toast-dismiss {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
/**
* Create a unique ID for toast messages
*/
private generateId(): string {
return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
/**
* Create a toast element
*/
private createToast(type: 'info' | 'success' | 'warning' | 'error', message: string): HTMLElement {
const div = document.createElement('div');
div.className = 'toast';
div.classList.add(type);
// Add icon based on type
const icon = this.getIconForType(type);
div.innerHTML = `
<span class="toast-icon">${icon}</span>
<span class="toast-message">${this.escapeHtml(message)}</span>
<button class="toast-close-btn" aria-label="Dismiss notification">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
`;
return div;
}
/**
* Get icon based on toast type
*/
private getIconForType(type: 'info' | 'success' | 'warning' | 'error'): string {
const icons: Record<string, string> = {
info: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>',
success: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>',
warning: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>',
error: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>'
};
return icons[type] || icons.info;
}
/**
* Escape HTML to prevent XSS
*/
private escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, (char) => map[char] || char);
}
/**
* Display an info toast notification
*/
info(message: string, options?: ToastOptions): void {
const duration = options?.duration ?? this.defaultDuration;
this.showToast('info', message, duration);
}
/**
* Display a success toast notification
*/
success(message: string, options?: ToastOptions): void {
const duration = options?.duration ?? this.defaultDuration;
this.showToast('success', message, duration);
}
/**
* Display a warning toast notification
*/
warning(message: string, options?: ToastOptions): void {
const duration = options?.duration ?? this.defaultDuration;
this.showToast('warning', message, duration);
}
/**
* Display an error toast notification
*/
error(message: string, options?: ToastOptions): void {
console.error(`[Toast Error] ${message}`);
const duration = options?.duration ?? this.errorDuration;
this.showToast('error', message, duration);
}
/**
* Show a toast notification with the given type
*/
private showToast(type: 'info' | 'success' | 'warning' | 'error', message: string, duration: number): void {
// Limit the number of concurrent toasts
this.limitToasts();
const id = this.generateId();
const element = this.createToast(type, message);
const container = this.ensureContainer();
// Add to active toasts
this.activeToasts.set(id, { id, type, message, createdAt: Date.now() });
// Append to container
container.appendChild(element);
// Store reference
this.activeToasts.get(id)!.element = element;
// Set up auto-dismiss timer
let dismissTimer: number | undefined;
const scheduleDismiss = () => {
if (duration > 0) {
dismissTimer = window.setTimeout(() => this.dismiss(id), duration);
}
};
// Setup click to dismiss
const handleClick = () => {
if (dismissTimer !== undefined) {
window.clearTimeout(dismissTimer);
dismissTimer = undefined;
}
this.dismiss(id);
};
// Setup close button handler
const closeBtn = element.querySelector('.toast-close-btn');
if (closeBtn) {
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
handleClick();
});
}
// Setup click on toast to dismiss
element.addEventListener('click', handleClick);
// Start timer
scheduleDismiss();
}
/**
* Dismiss a toast by ID
*/
dismiss(id: string): void {
const toastData = this.activeToasts.get(id);
if (!toastData || !toastData.element) return;
const element = toastData.element;
// Add dismiss animation
element.style.animation = 'toast-dismiss 0.2s ease-in forwards';
// Remove from DOM after animation
setTimeout(() => {
if (element.parentElement) {
element.parentElement.removeChild(element);
}
this.activeToasts.delete(id);
// Ensure container exists before trying to append
if (this.container) {
this.moveToasts();
}
}, 200);
}
/**
* Remove a toast by element reference
*/
dismissElement(element: HTMLElement): void {
const entries = Array.from(this.activeToasts.entries());
for (const [id, data] of entries) {
if (data.element === element) {
this.dismiss(id);
break;
}
}
}
/**
* Limit the number of concurrent toasts
*/
private limitToasts(): void {
if (this.container && this.activeToasts.size >= this.maxToasts) {
// Dismiss the oldest toast
const oldestId = Array.from(this.activeToasts.keys())[0];
if (oldestId) {
this.dismiss(oldestId);
}
}
}
/**
* Move toasts to ensure proper stacking
*/
private moveToasts(): void {
if (!this.container) return;
// Remove any empty container
if (this.activeToasts.size === 0) {
if (this.container.parentElement) {
this.container.parentElement.removeChild(this.container);
}
this.container = null;
}
}
/**
* Clear all active toasts
*/
clear(): void {
const ids = Array.from(this.activeToasts.keys());
for (const id of ids) {
this.dismiss(id);
}
}
/**
* Get the number of active toasts
*/
getActiveCount(): number {
return this.activeToasts.size;
}
/**
* Set the maximum number of concurrent toasts
*/
setMaxToasts(count: number): void {
this.maxToasts = count;
this.limitToasts();
}
}
// Export a singleton instance
export const toast = new ToastService(); export const toast = new ToastService();
@@ -0,0 +1,194 @@
---
title: "CAN Bus Protocol"
date: 2026-05-21
draft: false
description: "Complete documentation of the CAN bus communication protocol between PlantCtrl sensor modules and main controller."
tags: ["can", "protocol", "sensor"]
---
# CAN Bus Protocol
The PlantCtrl system uses a custom **CAN bus-based communication protocol** to connect sensor modules (moisture sensors) with the MainBoard controller. This modular design allows for scalable, reliable digital communication even over long cable runs and in electrically noisy environments.
## Overview
- **Protocol**: Standard CAN 2.0A (11-bit identifier)
- **Baud Rate**: 50 kbps
- **Base Address**: `0x03E8` (decimal 1000)
- **Maximum Plants**: 16 per sensor module
- **Sensors per Plant**: 2 slots (A and B) for redundancy or larger planters
## CAN Bus IDs
All messages use the standard base address `0x03E8` with message-specific offsets. The ID structure is:
```
ID = 0x03E8 + Message_Offset + Plant_Index (+ Slot_Offset if B)
```
### Message Groups
| Group | Offset (hex) | Direction | Description |
|-------|--------------|-----------|-------------|
| Moisture Data | `0x00` | Sensor → Controller | Periodic moisture readings |
| Identify Command | `0x20` | Controller → Sensor | LED identification command |
| Firmware Build | `0x40` | Sensor → Controller | Compile-time build timestamp |
### Plant Addressing (Slots A & B)
Each plant gets two sensor slots:
- **Slot A**: Base offset + plant index (015)
- **Slot B**: Base offset + 16 + plant index (015)
#### Example ID Calculations
| Message Type | Plant | Slot | CAN ID (hex) |
|--------------|-------|------|-------------|
| Moisture Data | 0 | A | `0x03E8` |
| Moisture Data | 7 | A | `0x0415` |
| Moisture Data | 15 | A | `0x042F` |
| Identify Command | 0 | A | `0x0400` |
| Firmware Build | 3 | B | `0x0467` |
## Message Formats
All messages are serialized using **bincode v2** (fixed-size integers, no varints). Each message fits within a single CAN frame.
### Moisture Data (Sensor → Controller)
Sent periodically by each sensor module. Contains:
| Field | Type | Description |
|-------|------|-------------|
| `plant` | u8 | Plant index (015) |
| `sensor` | SensorSlot | A or B slot |
| `hz` | u16 | Measured frequency in Hz |
**Total size**: 4 bytes (fits easily in CAN frame)
### Identify Command (Controller → Sensor)
Sent by the controller to trigger an LED identification sequence on the sensor module.
| Field | Type | Description |
|-------|------|-------------|
| *(empty)* | - | No payload data |
**Purpose**: When received, the sensor blinks its status LED for a few seconds to confirm it's online and properly configured.
### Firmware Build (Sensor → Controller)
Sent immediately after receiving an Identify Command. Contains:
| Field | Type | Description |
|-------|------|-------------|
| `build_minutes` | u32 | Compile-time timestamp in minutes since Unix epoch |
**Purpose**: Allows the controller to track firmware versions and deployment history without requiring a separate request.
## Sensor Configuration
Each sensor module is configured via **hardware jumpers** on startup. The configuration is read by the CH32V203 MCU and determines:
- Whether it's Slot A or B for a plant
- The plant index (18)
### Hardware Switches
| Pin | Function |
|-----|----------|
| PA3 | **Slot selector**: Low = A, High = B |
| PA4 | Address bit 1 (value: 1) |
| PA5 | Address bit 2 (value: 2) |
| PA6 | Address bit 3 (value: 4) |
| PA7 | Address bit 4 (value: 8) |
### Valid Addresses
- **Allowed**: 18 (binary combinations of bits 1,2,4,8)
- **Invalid**: 0 or >8 (will trigger error code)
#### Example Configurations
| Address | Binary | Jumpers |
|---------|--------|----------|
| 1 | `0001` | PA4 only |
| 3 | `0011` | PA4 + PA5 |
| 7 | `0111` | PA4 + PA5 + PA6 |
| 8 | `1000` | PA7 only |
## Error Detection & Blink Codes
The sensor module performs **floating pin detection** at startup. If any configuration pin is left floating (not connected to VCC or GND), the system enters an error state and blinks a diagnostic code.
### Error Code Table
| Code | Cause | LED Pattern |
|------|-------|-------------|
| 1 | PB4 floating (bit 1) | 1 blink info, 2 blinks warning |
| 2 | PB5 floating (bit 2) | 2 blinks info, 2 blinks warning |
| 3 | PB6 floating (bit 3) | 3 blinks info, 2 blinks warning |
| 4 | PB7 floating (bit 4) | 4 blinks info, 2 blinks warning |
| 5 | PB3 floating (A/B selector) | 5 blinks info, 2 blinks warning |
| 6 | Invalid address (0 or >8) | 6 blinks info, 2 blinks warning |
### LED Indicators
- **Info LED** (PA10): Green indicates information state
- **Warning LED** (PA9): Yellow/Orange indicates error/warning state
The blink pattern repeats 5 times, then the system resets automatically.
## Address Collision Detection
The protocol includes built-in collision detection. If a sensor receives a moisture data packet addressed to itself, it triggers an error sequence:
- **Blink code**: 1 info blink, 2 warning blinks
- **Log message**: "We should never receive moisture packets addressed to ourselves"
This indicates another node is using the same jumper configuration.
## CAN Bus Robustness Features
The firmware implements several features for reliable operation:
### Automatic Retransmission (NART)
Enabled on the CH32V203 CAN controller to recover from transient errors without manual intervention.
### Resync Jump Width (SJW = 4TQ)
Increased from default (1TQ) to improve jitter tolerance over long cable runs. This allows the receiver to resynchronize with the bit stream even if timing drifts slightly.
### Error Status Monitoring
The controller monitors CAN error registers for:
- Bus-off condition (`BOFF`)
- Error warning flag (`EWGF`)
- Error passive flag (`EPVF`)
When errors are detected, warning LEDs blink and the system logs the status.
## Troubleshooting
### Sensor Not Detected
1. Check all jumpers ensure no floating pins
2. Verify address is 18 (not 0 or >8)
3. Confirm slot selector (A/B) matches expected configuration
4. Listen for CAN traffic with a CAN analyzer
5. Check error blink codes on the sensor module
### Address Collision
- Two sensors using identical jumper settings will cause collisions
- Use the collision detection feature to identify duplicate addresses
- Reconfigure one of the conflicting sensors to a different address
### Communication Errors
- **Bus-off**: Check CAN termination resistors (120Ω at each end)
- **High error rate**: Verify cable quality and shielding
- **Intermittent errors**: Check for electrical noise from pumps or motors
## Future Extensions
The protocol is designed to be extensible. New message types can be added by:
1. Defining a new offset in `canapi/src/lib.rs`
2. Implementing the corresponding message struct with bincode serialization
3. Adding receive handlers on both sensor and controller sides
4. Documenting the new ID range and format
The current design supports up to 64 distinct message types (16 plants × 2 slots × 2 directions) while maintaining a clean, plant-indexed addressing scheme.
@@ -0,0 +1,142 @@
---
title: "CAN Bus IDs and Wire Format"
date: 2026-05-21
draft: false
description: "Quick reference for CAN bus identifiers, message formats, and on-the-wire data structures."
tags: ["can", "protocol", "wire-format"]
---
# CAN Bus IDs and Wire Format
A concise technical reference for the PlantCtrl CAN bus protocol.
## Quick Reference Table
| CAN ID (hex) | Message Type | Direction | Payload |
|--------------|--------------|-----------|----------|
| `0x03E8` | Moisture Data - Plant 0, Slot A | Sensor → Controller | u8 plant + u8 slot + u16 hz |
| `0x0400` | Identify Command - Plant 0, Slot A | Controller → Sensor | *(empty)* |
| `0x042F` | Moisture Data - Plant 15, Slot A | Sensor → Controller | u8 plant + u8 slot + u16 hz |
| `0x0467` | Firmware Build - Plant 3, Slot B | Sensor → Controller | u32 build_minutes |
## ID Calculation Formula
```
ID = 0x03E8 + Message_Offset + Plant_Index (+ Slot_Offset if B)
```
### Constants
| Constant | Value (hex) | Description |
|----------|-------------|-------------|
| `SENSOR_BASE_ADDRESS` | `0x03E8` | Base address for all messages |
| `MOISTURE_DATA_OFFSET` | `0x00` | Moisture data group |
| `IDENTIFY_CMD_OFFSET` | `0x20` | Identify command group |
| `FIRMWARE_BUILD_OFFSET` | `0x40` | Firmware build group |
| `B_SLOT_OFFSET` | `0x10` | Offset for Slot B within a group |
### Message Type Offsets
```rust
pub const MOISTURE_DATA_OFFSET: u16 = 0; // sensor → controller
pub const IDENTIFY_CMD_OFFSET: u16 = 32; // controller → sensor
pub const FIRMWARE_BUILD_OFFSET: u16 = 64; // sensor → controller
```
## On-the-Wire Formats
### Moisture Data Frame (Sensor → Controller)
**CAN ID**: `0x03E8 + offset + plant_index` (or `+ 16 + plant_index` for Slot B)
| Byte | Field | Type |
|------|-------|------|
| 0 | `plant` | u8 (015) |
| 1 | `sensor` | SensorSlot (A=0, B=1) |
| 2-3 | `hz` | u16 big-endian |
**Example**: Plant 7, Slot A, frequency 45 Hz
```
CAN ID: 0x0415
Payload: [07 00 00 2D]
plant=7, sensor=A, hz=45 (0x002D)
```
### Firmware Build Frame (Sensor → Controller)
**CAN ID**: `0x03E8 + 64 + plant_index` (or `+ 80 + plant_index` for Slot B)
| Byte | Field | Type |
|------|-------|------|
| 0-3 | `build_minutes` | u32 big-endian |
**Example**: Build timestamp 1,745,239,200 minutes since epoch (May 2026)
```
CAN ID: 0x0440
Payload: [00 00 6A F8]
build_minutes = 1,745,239,200
```
### Identify Command Frame (Controller → Sensor)
**CAN ID**: `0x03E8 + 32 + plant_index` (or `+ 48 + plant_index` for Slot B)
| Byte | Field | Type |
|------|-------|------|
| *(none)* | *(empty payload)* | - |
**Example**: Identify Plant 5, Slot A
```
CAN ID: 0x0410
Payload: (empty)
```
## Addressing Scheme Details
### Plant Index Range
- **Valid**: 015 (decimal) or 18 on hardware jumpers (mapped internally as 07)
- **Slot A**: `plant_index` = jumper value - 1
- **Slot B**: Same mapping, but ID offset differs by +16
### Slot Selection
| Hardware | Internal Value |
|----------|---------------|
| Jumper on PA3 (Low) | Slot A (0) |
| Jumper on PA3 (High) | Slot B (1) |
## Error Detection IDs
The sensor module monitors for unexpected messages:
- **Moisture Data collision**: If a sensor receives moisture data addressed to itself, it triggers error code 1 (1 info blink, 2 warning blinks)
- **CAN errors**: Bus-off, EWGF, EPVF flags trigger warning LED blinking
## Protocol Extensions
To add new message types:
1. Define offset in `canapi/src/lib.rs`:
```rust
pub const NEW_MESSAGE_OFFSET: u16 = 96; // Next available slot
```
2. Implement message struct with bincode serialization
3. Add receive handler on both sides
4. Update documentation
## Binary Protocol Reference
### bincode v2 Serialization
- **u8**: Single byte, no sign extension
- **u16**: 2 bytes big-endian (network order)
- **u32**: 4 bytes big-endian (network order)
- No varints fixed size for predictable CAN frame lengths
### CAN Frame Structure
```
| Arbitration Field | Control Field | Data Field (8 bytes) |
|-------------------|---------------|----------------------|
| 11-bit ID | RTR + IDE | Payload (max 4-6 bytes)|
```
All PlantCtrl messages fit within the 8-byte data field with room for CAN overhead.
@@ -1,73 +0,0 @@
---
title: "Sensors&Pumps"
date: 2025-01-27
draft: false
description: "a description"
tags: ["sensor"]
---
# Sensors & Pumps Module
This functionality is now provided by dedicated modules that can be connected to the MainBoard.
# Sensors
The moisture sensing functionality is handled by a dedicated **CAN bus-based Sensor Module**. This modular approach allows for better scalability and reduces electrical interference by moving the measurement logic closer to the sensors and using digital communication.
## Sensor Module (CAN bus)
The standard sensor module features its own **CH32V203 RISC-V microcontroller**, which handles the measurement of soil moisture and communicates the results back to the MainBoard via the CAN bus.
* **Capacity:** Supports up to 16 sensors (typically 8 plants with an A and B sensor each).
* **Reliability:** Digital communication via CAN bus ensures data integrity even over longer cable runs and in electrically noisy environments.
* **Addressing:** The A sensor is always used; the B sensor is optional and suggested for larger planters to provide a better average of the soil moisture.
## Sensor Hardware
The sensors themselves remain simple and cost-effective:
* **Design:** Two spikes with a defined distance.
* **DIY Friendly:** Can be bought readymade or easily made with two long nails (galvanized or stainless steel suggested to prevent rusting).
## Measurement Principle
The new CAN-based sensor module uses a sophisticated measurement technique that replaces the outdated 555-oscillator and multiplexer design. By using a dedicated MCU for measurement:
* **Minimized Corrosion:** The system changes polarity between measurements, minimizing corrosion due to organic battery effects (electrolysis) and preventing errors caused by building up a DC voltage in the soil.
* **Interference Resistance:** The measurement is resistant to common failure signals, such as 50Hz hum from nearby power circuits.
* **Digital Accuracy:** The local MCU processes the analog signals and sends precise digital values to the MainBoard.
# Pumps
The Pump module contains low side switched pump outputs. The pumps are running directly from the battery without further voltage conversion, so ensure that they can survive the full voltage range of the battery.
Each output can supply up to 3A continously.
The board will never switch more than one output concurrently, so there is no need to size the battery for higher maximum load.
An additinal extra output exists, that is switched when any of the pump outputs is supposed to run.
<!-- TODO: Add photo of the new modular Pump and Sensor boards -->
This allows for multiple possible setups
## Layout Central Pump
One central pump is connected to the extra output, and multiple magnetic valves are used for the different plants
## Multi Gravity Feed Valves
Per plant a Valve that can close against pressure is used, no pump exists
## Multi Pump Setup
Multiple smaller cheaper pumps with no shared hoses, so that failures will only affect a single planter.
In any case I suggest to use a Water Filter on the Intake, as else you will get severe algae problems.
In my personal opinion small membrane pumps are a really good fit
* can be housed outside the tank
* require less maintance/cleaning
* are able to pump smaller impurities without issues.
* Can pull water 1-2meters
* Have higher output pressure -> Will blow out blockages in hoses
However
* are louder
* pump less volume per time and energy
{{< alert >}}
DO NOT DIRECTLY CONNECT TO WATER MAINS, YOU HAVE BEEN WARNED!
Software and Hardware may fail: It is your responsibility to ensure that a stuck valve or short circuit mosfet will not cause flooding and property destruction, for example by limiting the water tank to size that can drain.
{{< /alert >}}