20 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
30 changed files with 1389 additions and 462 deletions
+8 -8
View File
@@ -893,7 +893,7 @@
(uuid "f5c71e09-1c64-4699-a872-7959060ff54c")
)
(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
(xyz 0 0 0)
)
@@ -2381,7 +2381,7 @@
(uuid "2619df3e-b807-4d9d-86fd-1890ae6a2950")
)
(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
(xyz 0 0 0)
)
@@ -3089,7 +3089,7 @@
(uuid "29bb50c2-8c3f-4ba0-bb86-3c75e758d317")
)
(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
(xyz 0 0 0)
)
@@ -3622,7 +3622,7 @@
(uuid "ac7efe3b-be6c-4633-baa1-c7a44a15dc73")
)
(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
(xyz 0 0 0)
)
@@ -6371,7 +6371,7 @@
(uuid "f844f701-66c7-484d-a7cf-cff3b6cdd615")
)
(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
(xyz 0 0 0)
)
@@ -8173,7 +8173,7 @@
(uuid "09bb897b-8ad7-4832-82c0-09ce6f9fd4df")
)
(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
(xyz 0 0 0)
)
@@ -10055,7 +10055,7 @@
(uuid "3e5e9acf-dab8-4180-8dd7-e2e038a511be")
)
(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
(xyz 0 0 0)
)
@@ -13870,7 +13870,7 @@
(uuid "a9590be1-1db5-48eb-8721-a2ce5049db3f")
)
(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
(xyz 0 0 0)
)
+17 -1
View File
@@ -50,7 +50,23 @@
"silk_text_thickness": 0.1,
"silk_text_upright": false,
"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": [
+1
View File
@@ -2,5 +2,6 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../../.." vcs="Git" />
<mapping directory="$PROJECT_DIR$/../../../website/themes/blowfish" vcs="Git" />
</component>
</project>
+12
View File
@@ -14,6 +14,7 @@ pub struct NetworkConfig {
pub mqtt_user: Option<String>,
pub mqtt_password: Option<String>,
pub max_wait: u32,
pub retry_count: u32,
}
impl Default for NetworkConfig {
fn default() -> Self {
@@ -26,6 +27,7 @@ impl Default for NetworkConfig {
mqtt_user: None,
mqtt_password: None,
max_wait: 10000,
retry_count: 3,
}
}
}
@@ -110,6 +112,14 @@ pub struct PlantControllerConfig {
pub timezone: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub enum SensorCombineMode {
Min,
Max,
#[default]
Avg,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(default)]
pub struct PlantConfig {
@@ -131,6 +141,7 @@ pub struct PlantConfig {
pub ignore_current_error: bool,
pub fertilizer_s: u16,
pub fertilizer_cooldown_min: u16,
pub sensor_combine_mode: SensorCombineMode,
}
impl Default for PlantConfig {
@@ -154,6 +165,7 @@ impl Default for PlantConfig {
ignore_current_error: true,
fertilizer_s: 0,
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 {
fn from(value: BmsProtocolError) -> Self {
match value {
BmsProtocolError::I2cCommunicationError =>FatError::String {
BmsProtocolError::I2cCommunicationError => FatError::String {
error: "I2C communication error".to_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::fat_error::{FatError, FatResult};
use alloc::string::String;
use async_trait::async_trait;
use embassy_embedded_hal::shared_bus::blocking::i2c::I2cDevice;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
@@ -18,15 +19,23 @@ pub trait BatteryInteraction {
async fn reset(&mut self) -> FatResult<()>;
}
#[derive(Debug, Serialize, Copy, Clone)]
#[derive(Debug, Serialize, Clone)]
pub struct BatteryInfo {
pub voltage_milli_volt: u32,
pub average_current_milli_ampere: i32,
pub design_milli_ampere_hour: u32,
pub remaining_milli_ampere_hour: u32,
pub state_of_charge: u8,
pub state_of_health: u32,
pub temperature: i32,
pub voltage_mv: Option<u32>,
pub avg_current_ma: Option<i32>,
pub design_mah: Option<u32>,
pub remaining_mah: Option<u32>,
pub soc_pct: Option<f32>,
pub soh_pct: Option<f32>,
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)]
@@ -71,17 +80,19 @@ impl BatteryInteraction for WCHI2CSlave<'_> {
let config = Config::read_from_i2c(&mut self.i2c)?;
let state_of_charge =
(state.remaining_capacity_mah * 100 / state.lifetime_capacity_mah) as u8;
let state_of_health = state.lifetime_capacity_mah / config.capacity_mah * 100;
state.remaining_capacity_mah as f32 * 100. / state.lifetime_capacity_mah as f32;
let state_of_health =
state.lifetime_capacity_mah as f32 / config.capacity_mah as f32 * 100.;
Ok(BatteryState::Info(BatteryInfo {
voltage_milli_volt: state.current_mv,
average_current_milli_ampere: 1337,
design_milli_ampere_hour: config.capacity_mah,
remaining_milli_ampere_hour: state.remaining_capacity_mah,
state_of_charge,
state_of_health,
temperature: state.temperature_celcius,
voltage_mv: Some(state.current_mv),
avg_current_ma: Some(1337),
design_mah: Some(config.capacity_mah),
remaining_mah: Some(state.remaining_capacity_mah),
soc_pct: Some(state_of_charge),
soh_pct: Some(state_of_health),
temperature_c: Some(state.temperature_celcius),
error: None,
}))
}
+54 -18
View File
@@ -1,23 +1,17 @@
use crate::bail;
use crate::config::{NetworkConfig, PlantControllerConfig};
use crate::config::PlantControllerConfig;
use crate::hal::savegame_manager::SavegameManager;
use crate::hal::PLANT_COUNT;
use crate::log::{log, LogMessage};
use chrono::{DateTime, Utc};
use crate::fat_error::{ContextExt, FatError, FatResult};
use crate::fat_error::{FatError, FatResult};
use crate::hal::shared_flash::MutexFlashStorage;
use alloc::string::ToString;
use alloc::string::{String, ToString};
use alloc::sync::Arc;
use alloc::{format, string::String, 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 alloc::{format, vec, vec::Vec};
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
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 esp_bootloader_esp_idf::ota::OtaImageState::Valid;
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::uart::Uart;
use esp_hal::Blocking;
use esp_println::println;
use esp_radio::wifi::ap::{AccessPointConfig, AccessPointInfo};
use esp_radio::wifi::ap::AccessPointInfo;
use esp_radio::wifi::scan::{ScanConfig, ScanTypeConfig};
use esp_radio::wifi::sta::StationConfig;
use esp_radio::wifi::{AuthenticationMethod, Config, Interface, WifiController};
use log::{error, info, warn};
use portable_atomic::AtomicBool;
use esp_radio::wifi::{Interface, WifiController};
use log::{error, info};
use serde::{Deserialize, Serialize};
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))]
static mut LAST_WATERING_TIMESTAMP: [i64; PLANT_COUNT] = [0; PLANT_COUNT];
@@ -202,6 +215,25 @@ impl Esp<'_> {
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>> {
let ts = unsafe { LAST_WATERING_TIMESTAMP }[plant];
DateTime::from_timestamp_millis(ts)
@@ -378,8 +410,12 @@ impl Esp<'_> {
for (i, item) in CONSECUTIVE_WATERING_PLANT.iter().enumerate() {
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)]
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_config(&mut self) -> &PlantControllerConfig;
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)]
impl<'a> BoardInteraction<'a> for V4<'a> {
fn get_tank_sensor(&mut self) -> Result<&mut TankSensor<'a>, FatError> {
Ok(&mut self.tank_sensor)
fn get_tank_sensor(&mut self) -> &mut TankSensor<'a> {
&mut self.tank_sensor
}
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::peripherals::GPIO5;
use esp_hal::Async;
use log::info;
use log::{error, info};
use onewire::{ds18b20, Device, DeviceSearch, OneWire, DS18B20};
use portable_atomic::{AtomicUsize, Ordering};
@@ -61,7 +61,12 @@ impl<'a> TankSensor<'a> {
info!("tank: pcnt high limit ok");
// 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.
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;
ch0.set_edge_signal(flow_sensor.peripheral_input());
@@ -140,7 +145,11 @@ impl<'a> TankSensor<'a> {
water_temp_sensor = Some(device);
break;
} 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 {
@@ -32,7 +32,8 @@ impl LiveLogBuffer {
match after {
None => (self.entries.clone(), false, next_seq),
Some(after_seq) => {
let result: Vec<_> = self.entries
let result: Vec<_> = self
.entries
.iter()
.filter(|(seq, _)| *seq > after_seq)
.cloned()
+61 -154
View File
@@ -14,24 +14,24 @@
esp_bootloader_esp_idf::esp_app_desc!();
use esp_backtrace as _;
use crate::hal::PROGRESS_ACTIVE;
use crate::config::{NetworkConfig, PlantConfig, PlantControllerConfig};
use crate::config::{PlantConfig, PlantControllerConfig};
use crate::fat_error::{ContextExt, FatResult};
use crate::hal::PROGRESS_ACTIVE;
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::{
config::BoardVersion::Initial,
hal::{PlantHal, HAL, PLANT_COUNT},
};
use ::log::{error, info, warn};
use ::log::{error, info};
use alloc::borrow::ToOwned;
use alloc::string::{String, ToString};
use alloc::sync::Arc;
use alloc::vec::Vec;
use alloc::{format, vec};
use chrono::{DateTime, Datelike, Timelike, Utc};
use chrono::{DateTime, Datelike, Timelike};
use chrono_tz::Tz::{self, UTC};
use core::sync::atomic::{AtomicBool, Ordering};
use embassy_executor::Spawner;
@@ -123,8 +123,6 @@ pub struct PumpResult {
overcurrent_ma: Option<u16>,
}
async fn safe_main(spawner: Spawner) -> FatResult<()> {
info!("Startup Rust");
@@ -208,10 +206,15 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
info!("No wifi configured, starting initial config mode");
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())
.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 reboot_now = Arc::new(AtomicBool::new(false));
@@ -228,7 +231,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
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;
Timer::after_millis(100).await;
network::NetworkMode::OFFLINE
network::NetworkMode::OFFLINE
};
if matches!(network_mode, network::NetworkMode::OFFLINE) && to_config {
@@ -236,14 +239,18 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
let res = {
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())
.unwrap_or_else(|_| String::from("PlantCtrl Emergency Mode"));
let device = match esp.interface_ap.take() {
Some(d) => d,
None => {
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
@@ -275,11 +282,14 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
);
if let network::NetworkMode::WIFI { ref ip_address, .. } = network_mode {
publish_firmware_info(&mut board, version, ip_address, &timezone_time.to_rfc3339()).await;
publish_battery_state(&mut board).await.unwrap_or_else(|e| {
error!("Error publishing battery state {e}");
});
let _ = publish_mppt_state(&mut board).await;
mqtt::publish_firmware_info(version, ip_address, &timezone_time.to_rfc3339()).await;
mqtt::publish_battery_state(&mut board)
.await
.unwrap_or_else(|e| {
error!("Error publishing battery state {e}");
});
let _ = mqtt::publish_mppt_state(&mut board).await;
let _ = mqtt::publish_wifi_scan(&mut board).await;
}
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) {
match err {
TankError::SensorDisabled => { /* unreachable */ }
TankError::SensorMissing(raw_value_mv) => log(
TankError::SensorMissing {
raw_mv: raw_value_mv,
} => log(
LogMessage::TankSensorMissing,
raw_value_mv as u32,
0,
@@ -338,8 +350,8 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
&format!("{value}"),
"",
),
TankError::BoardError(err) => {
log(LogMessage::TankSensorBoardError, 0, 0, "", &err.to_string())
TankError::BoardError { message: err } => {
log(LogMessage::TankSensorBoardError, 0, 0, "", &err)
}
}
// 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 water_temp: FatResult<f32> = match board.board_hal.get_tank_sensor() {
Ok(sensor) => sensor.water_temperature_c().await,
Err(e) => Err(e),
};
let water_temp: FatResult<f32> = board
.board_hal
.get_tank_sensor()
.water_temperature_c()
.await;
if let Ok(res) = water_temp {
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.));
publish_tank_state(&mut board, &tank_state, water_temp)
mqtt::publish_tank_state(&mut board, &tank_state, water_temp)
.await
.unwrap_or_else(|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,
];
publish_plant_states(&mut board, &timezone_time.clone(), &plantstate)
mqtt::publish_plant_states(&mut board, &timezone_time.clone(), &plantstate)
.await
.unwrap_or_else(|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);
//state.active = true;
pump_info(
&mut board,
mqtt::pump_info(
plant_id,
true,
pump_ineffective,
@@ -454,8 +466,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
match result {
Ok(state) => {
overcurrent_results[plant_id] = state.overcurrent_ma;
pump_info(
&mut board,
mqtt::pump_info(
plant_id,
false,
pump_ineffective,
@@ -469,8 +480,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
.await;
}
Err(err) => {
pump_info(
&mut board,
mqtt::pump_info(
plant_id,
false,
pump_ineffective,
@@ -500,7 +510,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
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
.unwrap_or_else(|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,
);
match battery_state {
match &battery_state {
BatteryState::Unknown => {
light_state.battery_low = false;
}
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();
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();
info!("Clear low voltage in cycle");
}
@@ -639,7 +653,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
let deep_sleep_duration_minutes: u32 =
// 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;
12 * 60
} else if is_day {
@@ -709,8 +723,9 @@ pub async fn do_secure_pump(
if plant_config.fertilizer_s > 0 {
let current_time = board.board_hal.get_time().await;
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 {
info!(
"Starting fertilizer pump for {} seconds (last fertilizer was {} minutes ago)",
@@ -723,6 +738,7 @@ pub async fn do_secure_pump(
&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?;
Timer::after_millis(plant_config.fertilizer_s as u64 * 1000).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()?.start_flow_meter();
board.board_hal.get_tank_sensor().reset_flow_meter();
board.board_hal.get_tank_sensor().start_flow_meter();
board.board_hal.pump(plant_id, true).await?;
for step in 0..steps_in_50ms {
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;
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
pump_time_ms = 1337;
}
board.board_hal.get_tank_sensor()?.stop_flow_meter();
let final_flow_value = board.board_hal.get_tank_sensor()?.get_full_flow_count();
board.board_hal.get_tank_sensor().stop_flow_meter();
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;
info!("Final flow value is {final_flow_value} with {flow_value_ml} ml");
current_collector.sort();
@@ -890,114 +906,6 @@ async fn update_charge_indicator(
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(
board: MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
wait_type: WaitType,
@@ -1099,8 +1007,7 @@ async fn wait_infinity(
let timezone_time = cur.with_timezone(&timezone);
mqtt::publish("/state", "config").await;
mqtt::publish("/firmware/last_online", &timezone_time.to_rfc3339())
.await;
mqtt::publish("/firmware/last_online", &timezone_time.to_rfc3339()).await;
last_mqtt_update = Some(now);
}
+191 -24
View File
@@ -1,16 +1,24 @@
use crate::bail;
use crate::config::NetworkConfig;
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 alloc::string::String;
use alloc::{format, string::ToString};
use crate::plant_state::PlantState;
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 embassy_executor::Spawner;
use embassy_net::Stack;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::MutexGuard;
use embassy_sync::once_lock::OnceLock;
use embassy_time::{Duration, Timer, WithTimeout};
use log::info;
use log::{error, info, warn};
use mcutie::{
Error, McutieBuilder, McutieReceiver, McutieTask, MqttMessage, PublishDisplay, Publishable,
QoS, Topic,
@@ -18,25 +26,6 @@ use mcutie::{
use portable_atomic::AtomicBool;
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_ROUND_TRIP_RECEIVED: 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::config::NetworkConfig;
use crate::fat_error::{ContextExt, FatError, FatResult};
use crate::hal::{PlantHal, HAL};
use crate::hal::HAL;
use crate::mqtt;
use crate::util::mk_static;
use alloc::string::{String, ToString};
use alloc::sync::Arc;
use chrono::{DateTime, Utc};
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_net::dns::DnsQueryType;
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::mutex::{Mutex, MutexGuard};
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_println::println;
use esp_radio::wifi::ap::AccessPointConfig;
use esp_radio::wifi::sta::StationConfig;
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 sntpc::{NtpContext, NtpTimestampGenerator, NtpUdpSocket, get_time};
use sntpc::{get_time, NtpContext, NtpTimestampGenerator, NtpUdpSocket};
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)]
#[allow(clippy::upper_case_acronyms)]
pub enum SntpMode {
OFFLINE,
SYNC { current: DateTime<Utc> },
}
#[derive(Serialize, Debug, PartialEq)]
#[allow(clippy::upper_case_acronyms)]
pub enum NetworkMode {
WIFI {
sntp: SntpMode,
@@ -223,8 +225,7 @@ pub async fn wifi_ap(
);
let stack = mk_static!(Stack, stack);
let client_config =
Config::AccessPoint(AccessPointConfig::default().with_ssid(ssid.clone()));
let client_config = Config::AccessPoint(AccessPointConfig::default().with_ssid(ssid.clone()));
controller.lock().await.set_config(&client_config)?;
println!("start net task");
@@ -268,12 +269,12 @@ pub async fn wifi(
bail!("Wifi ssid was empty")
}
};
info!("attempting to connect wifi {ssid}");
let password = match network_config.password {
Some(ref password) => password.as_str().to_string(),
None => "".to_string(),
};
let max_wait = network_config.max_wait;
let retry_count = network_config.retry_count;
let config = embassy_net::Config::dhcpv4(DhcpConfig::default());
@@ -292,56 +293,99 @@ pub async fn wifi(
} else {
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
.lock()
.await
.set_config(&Config::Station(client_config))?;
// Spawn the network task once
spawner.spawn(net_task(runner)?);
controller
.lock()
.await
.connect_async()
let mut attempts = 0;
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))
.await
.context("Timeout waiting for wifi sta connected")??;
.await;
let res = async {
while !stack.is_link_up() {
if res.is_err() {
warn!(
"WiFi connection attempt {} failed: link up timeout",
attempts + 1
);
attempts += 1;
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() {
bail!("Timeout waiting for wifi link up")
}
let res = async {
while !stack.is_config_up() {
Timer::after(Duration::from_millis(100)).await
let res = async {
while !stack.is_config_up() {
Timer::after(Duration::from_millis(100)).await
}
Ok::<(), FatError>(())
}
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() {
bail!("Timeout waiting for wifi config up")
if res.is_err() {
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());
Ok(*stack)
// All retries exhausted
bail!("WiFi connection failed after all retries");
}
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::plant_state::PlantWateringMode::TargetMoisture;
use crate::{config::PlantConfig, hal::HAL, in_time_range};
use chrono::{DateTime, TimeDelta, Utc};
use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
const MOIST_SENSOR_MAX_FREQUENCY: f32 = 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
#[derive(Debug, PartialEq, Serialize)]
#[derive(Debug, PartialEq, Clone, Serialize)]
#[serde(tag = "kind")]
pub enum MoistureSensorError {
MissingMessage,
NotExpectedMessage { hz: f32 },
@@ -46,6 +49,14 @@ impl MoistureSensorState {
impl MoistureSensorState {}
#[derive(Debug, PartialEq, Serialize)]
pub struct SensorTelemetry {
pub moisture_pct: Option<f32>,
pub raw_hz: Option<f32>,
pub error: Option<MoistureSensorError>,
}
#[derive(Debug, PartialEq, Serialize)]
#[serde(tag = "kind")]
pub enum PumpError {
PumpNotWorking {
failed_attempts: usize,
@@ -100,10 +111,31 @@ pub struct PlantState {
pub sensor_a_firmware_build_minutes: Option<u32>,
/// Last known firmware build timestamp for sensor B.
pub sensor_b_firmware_build_minutes: Option<u32>,
/// Last time fertilizer was applied (Unix timestamp in seconds).
pub last_fertilizer_time: i64,
/// Last time fertilizer was applied.
pub last_fertilizer_time: Option<DateTime<Utc>>,
}
/// Map sensor frequency to moisture percentage using inverse power-law scaling (quadratic).
///
/// For resistive probes with 555 timer oscillator:
/// - Dry soil has high resistance → low oscillation frequency
/// - Wet soil has low resistance → high oscillation frequency
///
/// The relationship is non-linear: most frequency change occurs in the wet range.
/// Using inverse power-law to give better discrimination at high moisture levels.
///
/// Formula: moisture = (1 - (f_max - f) / (f_max - f_min))^2 * 100
/// = ((f - f_min) / (f_max - f_min))^2 * 100
///
/// But with k=0.5 (square root) for better high-end discrimination:
/// Formula: moisture = sqrt((f - f_min) / (f_max - f_min)) * 100
///
/// Examples with default range (400-160000 Hz) using k=0.5:
/// 400 Hz → 0% (bone dry)
/// 10,240 Hz → 25% (dry soil)
/// 40,600 Hz → 50% (moist soil)
/// 91,710 Hz → 75% (wet soil) - matches your observation!
/// 160,000 Hz → 100% (saturated)
fn map_range_moisture(
s: f32,
min_frequency: Option<f32>,
@@ -125,9 +157,28 @@ fn map_range_moisture(
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 {
@@ -175,8 +226,12 @@ impl PlantState {
let previous_pump = board.board_hal.get_esp().last_pump_time(plant_id);
let consecutive_pump_count = board.board_hal.get_esp().consecutive_pump_count(plant_id);
let last_fertilizer_time = board.board_hal.get_esp().last_fertilizer_time(plant_id);
let last_fertilizer_timestamp = board.board_hal.get_esp().last_fertilizer_time(plant_id);
let (a_builds, b_builds) = board.board_hal.get_sensor_build_minutes();
let last_fertilizer_time = DateTime::from_timestamp_millis(last_fertilizer_timestamp);
// Create plant state first, then check for warnings
let state = Self {
sensor_a,
sensor_b,
@@ -189,7 +244,20 @@ impl PlantState {
sensor_b_firmware_build_minutes: b_builds[plant_id],
last_fertilizer_time,
};
if state.is_err() {
// Check for sensor warning condition (expected 2 sensors, only 1 responding)
let has_a =
state.sensor_a.moisture_percent().is_some() && state.sensor_a.is_err().is_none();
let has_b =
state.sensor_b.moisture_percent().is_some() && state.sensor_b.is_err().is_none();
// Check if we expected two sensors but only got one
let has_sensor_warning =
expected_a && expected_b && ((has_a && !has_b) || (!has_a && has_b));
// Set fault LED for both errors AND sensor warnings
let has_issue = state.is_err() || has_sensor_warning;
if has_issue {
let _ = board.board_hal.fault(plant_id, true).await;
}
state
@@ -212,26 +280,25 @@ impl PlantState {
self.sensor_a.is_err().is_some() || self.sensor_b.is_err().is_some()
}
pub fn plant_moisture(
&self,
) -> (
Option<u8>,
(Option<&MoistureSensorError>, Option<&MoistureSensorError>),
) {
/// Get combined moisture value with configurable combination mode and sensor warning.
///
/// Returns:
/// - Combined moisture percentage (or None if no valid readings)
/// - Tuple of errors from sensor A and B
/// - Sensor warning indicating if warning LED should be lit (MissingSecondSensor)
pub fn plant_moisture_with_warning(&self, plant_conf: &PlantConfig) -> Option<f32> {
match (
self.sensor_a.moisture_percent(),
self.sensor_b.moisture_percent(),
) {
(Some(moisture_a), Some(moisture_b)) => {
(Some(((moisture_a + moisture_b) / 2.) as u8), (None, None))
}
(Some(moisture_percent), _) => {
(Some(moisture_percent as u8), (None, self.sensor_b.is_err()))
}
(_, Some(moisture_percent)) => {
(Some(moisture_percent as u8), (self.sensor_a.is_err(), None))
}
_ => (None, (self.sensor_a.is_err(), self.sensor_b.is_err())),
(Some(moisture_a), Some(moisture_b)) => match plant_conf.sensor_combine_mode {
SensorCombineMode::Min => Some(moisture_a.min(moisture_b)),
SensorCombineMode::Max => Some(moisture_a.max(moisture_b)),
SensorCombineMode::Avg => Some((moisture_a + moisture_b) / 2.0),
},
(Some(moisture), _) => Some(moisture),
(_, Some(moisture)) => Some(moisture),
_ => None,
}
}
@@ -243,11 +310,11 @@ impl PlantState {
match plant_conf.mode {
PlantWateringMode::Off => false,
PlantWateringMode::TargetMoisture => {
let (moisture_percent, _) = self.plant_moisture();
let moisture_percent = self.plant_moisture_with_warning(plant_conf);
if let Some(moisture_percent) = moisture_percent {
if self.pump_in_timeout(plant_conf, current_time) {
false
} else if moisture_percent < plant_conf.target_moisture {
} else if moisture_percent < plant_conf.target_moisture.into() {
in_time_range(
current_time,
plant_conf.pump_hour_start,
@@ -269,23 +336,26 @@ impl PlantState {
}
}
pub fn to_mqtt_info(
&self,
plant_conf: &PlantConfig,
current_time: &DateTime<Tz>,
) -> PlantInfo<'_> {
pub fn to_mqtt_info(&self, plant_conf: &PlantConfig, current_time: &DateTime<Tz>) -> PlantInfo {
let moisture_pct = self.plant_moisture_with_warning(plant_conf);
PlantInfo {
sensor_a: &self.sensor_a,
sensor_b: &self.sensor_b,
moisture_pct,
sensor_a: Self::sensor_to_telemetry(&self.sensor_a),
sensor_b: Self::sensor_to_telemetry(&self.sensor_b),
mode: plant_conf.mode,
target_pct: if plant_conf.mode == TargetMoisture {
Some(plant_conf.target_moisture as f32)
} else {
None
},
do_water: self.needs_to_be_watered(plant_conf, current_time),
dry: if let Some(moisture_percent) = self.plant_moisture().0 {
moisture_percent < plant_conf.target_moisture
dry: if let Some(moisture_percent) = moisture_pct {
moisture_percent < plant_conf.target_moisture.into()
} else {
false
},
cooldown: self.pump_in_timeout(plant_conf, current_time),
out_of_work_hour: in_time_range(
out_of_work_hour: !in_time_range(
current_time,
plant_conf.pump_hour_start,
plant_conf.pump_hour_end,
@@ -310,20 +380,67 @@ impl PlantState {
} else {
None
},
last_fertilizer: self
.last_fertilizer_time
.map(|t| t.with_timezone(&current_time.timezone())),
next_fertilizer: if matches!(
plant_conf.mode,
PlantWateringMode::TimerOnly
| PlantWateringMode::TargetMoisture
| PlantWateringMode::MinMoisture
) {
self.last_fertilizer_time.and_then(|last_fert| {
// Convert to Tz for calculation, then back
let tz_last_fert = last_fert.with_timezone(&current_time.timezone());
tz_last_fert
.checked_add_signed(TimeDelta::minutes(
plant_conf.fertilizer_cooldown_min.into(),
))
.map(|t| t.with_timezone(&current_time.timezone()))
})
} else {
None
},
sensor_a_firmware_build_minutes: self.sensor_a_firmware_build_minutes,
sensor_b_firmware_build_minutes: self.sensor_b_firmware_build_minutes,
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)]
/// 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
sensor_a: &'a MoistureSensorState,
sensor_a: SensorTelemetry,
/// state of humidity sensor on bank b
sensor_b: &'a MoistureSensorState,
sensor_b: SensorTelemetry,
/// configured plant watering mode
mode: PlantWateringMode,
/// the plant needs to be watered
@@ -341,10 +458,12 @@ pub struct PlantInfo<'a> {
last_pump: Option<DateTime<Tz>>,
/// next time when pump should activate
next_pump: Option<DateTime<Tz>>,
/// last time when fertilizer was applied
last_fertilizer: Option<DateTime<Tz>>,
/// next time when fertilizer should be applied
next_fertilizer: Option<DateTime<Tz>>,
/// firmware build timestamp of sensor A (minutes since Unix epoch); None if unknown
sensor_a_firmware_build_minutes: Option<u32>,
/// firmware build timestamp of sensor B (minutes since Unix epoch); None if unknown
sensor_b_firmware_build_minutes: Option<u32>,
/// 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;
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind")]
pub enum TankError {
SensorDisabled,
SensorMissing(f32),
SensorMissing { raw_mv: f32 },
SensorValueError { value: f32, min: f32, max: f32 },
BoardError(String),
BoardError { message: String },
}
pub enum TankState {
@@ -25,7 +26,9 @@ pub enum TankState {
fn raw_voltage_to_divider_percent(raw_value_mv: f32) -> Result<f32, TankError> {
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);
@@ -141,15 +144,15 @@ impl TankState {
TankInfo {
enough_water,
warn_level,
left_ml,
volume_ml: left_ml,
sensor_error: tank_err,
raw,
fill_raw_v: raw,
water_frozen: water_temp
.as_ref()
.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()),
percent,
fill_pct: percent,
}
}
}
@@ -158,12 +161,16 @@ pub async fn determine_tank_state(
board: &mut MutexGuard<'static, CriticalSectionRawMutex, HAL<'static>>,
) -> TankState {
if board.board_hal.get_config().tank.tank_sensor_enabled {
match board.board_hal.get_tank_sensor() {
Ok(sensor) => match sensor.tank_sensor_voltage().await {
Ok(raw_sensor_value_mv) => TankState::Present(raw_sensor_value_mv),
Err(err) => TankState::Error(TankError::BoardError(err.to_string())),
},
Err(err) => TankState::Error(TankError::BoardError(err.to_string())),
match board
.board_hal
.get_tank_sensor()
.tank_sensor_voltage()
.await
{
Ok(raw_sensor_value_mv) => TankState::Present(raw_sensor_value_mv),
Err(err) => TankState::Error(TankError::BoardError {
message: err.to_string(),
}),
}
} else {
TankState::Disabled
@@ -178,16 +185,16 @@ pub struct TankInfo {
/// warning that water needs to be refilled soon
pub(crate) warn_level: bool,
/// 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
pub(crate) sensor_error: Option<TankError>,
/// raw water sensor value
pub(crate) raw: Option<f32>,
pub(crate) fill_raw_v: Option<f32>,
/// percent value
pub(crate) percent: Option<f32>,
pub(crate) fill_pct: Option<f32>,
/// water in the tank might be frozen
pub(crate) water_frozen: bool,
/// water temperature
pub(crate) water_temp: Option<f32>,
pub(crate) water_temp_c: Option<f32>,
pub(crate) temp_sensor_error: Option<String>,
}
+1 -1
View File
@@ -2,7 +2,7 @@ macro_rules! mk_static {
($t:ty,$val:expr) => {{
static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new();
#[deny(unused_attributes)]
let x = STATIC_CELL.uninit().write(($val));
let x = STATIC_CELL.uninit().write($val);
x
}};
}
@@ -23,6 +23,8 @@ struct LoadData<'a> {
struct Moistures {
moisture_a: Vec<String>,
moisture_b: Vec<String>,
sensor_a_build_minutes: Vec<Option<u32>>,
sensor_b_build_minutes: Vec<Option<u32>>,
}
#[derive(Serialize, Debug)]
struct SolarState {
@@ -63,9 +65,20 @@ where
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 {
moisture_a: a,
moisture_b: b,
sensor_a_build_minutes,
sensor_b_build_minutes,
};
let json = serde_json::to_string(&data)?;
@@ -80,10 +93,11 @@ where
{
let mut board = BOARD_ACCESS.get().await.lock().await;
let tank_state = determine_tank_state(&mut board).await;
//should be multisampled
let sensor = board.board_hal.get_tank_sensor()?;
let water_temp: FatResult<f32> = sensor.water_temperature_c().await;
let water_temp: FatResult<f32> = board
.board_hal
.get_tank_sensor()
.water_temperature_c()
.await;
Ok(Some(serde_json::to_string(&tank_state.as_mqtt_info(
&board.board_hal.get_config().tank,
&water_temp,
@@ -204,3 +218,12 @@ pub(crate) async fn get_log_localization_config<T, const N: usize>(
&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::webserver::backup_manager::{backup_config, backup_info, get_backup_config};
use crate::webserver::get_json::{
delete_save, get_battery_state, get_config, get_live_moisture, get_log_localization_config,
get_firmware_info_web, get_solar_state, get_time, get_timezones, list_saves, tank_info,
delete_save, get_battery_state, get_config, get_firmware_info_web, get_live_moisture,
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_static::{serve_bundle, serve_favicon, serve_index};
@@ -83,6 +84,7 @@ impl Handler for HTTPRequestRouter {
"/timezones" => Some(get_timezones().await),
"/moisture" => Some(get_live_moisture(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
p if p == "/live_log" || p.starts_with("/live_log?") => {
let after: Option<u64> = p
@@ -1,5 +1,6 @@
use crate::config::PlantControllerConfig;
use crate::fat_error::FatResult;
use crate::hal::savegame_manager::SAVEGAME_SLOT_SIZE;
use crate::hal::DetectionRequest;
use crate::webserver::read_up_to_bytes_from_request;
use crate::{do_secure_pump, BOARD_ACCESS};
@@ -135,7 +136,8 @@ pub(crate) async fn set_config<T, const N: usize>(
where
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 config: PlantControllerConfig = serde_json::from_slice(&all)?;
+20 -8
View File
@@ -141,6 +141,7 @@ export interface PlantConfig {
min_pump_current_ma: number,
max_pump_current_ma: number,
ignore_current_error: boolean,
sensor_combine_mode: string,
}
export interface PumpTestResult {
@@ -177,6 +178,8 @@ export interface GetTime {
export interface Moistures {
moisture_a: [string],
moisture_b: [string],
sensor_a_build_minutes: Array<number | null>,
sensor_b_build_minutes: Array<number | null>,
}
export interface VersionInfo {
@@ -223,23 +226,32 @@ export interface Detection {
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 {
/// is there enough water in the tank
/// there is enough water in the tank
enough_water: boolean,
/// warning that water needs to be refilled soon
warn_level: boolean,
/// estimation how many ml are still in tank
left_ml: number | null,
/// if there is was an issue with the water level sensor
/// estimation how many ml are still in the tank
volume_ml: number | null,
/// if there is an issue with the water level sensor
sensor_error: string | null,
/// raw water sensor value
raw: number | null,
fill_raw_v: number | null,
/// percent value
percent: number | null,
/// water in tank might be frozen
fill_pct: number | null,
/// water in the tank might be frozen
water_frozen: boolean,
/// water temperature
water_temp: number | null,
water_temp_c: number | null,
temp_sensor_error: string | null
}
@@ -37,7 +37,7 @@
}
#logpanel {
display: none;
}
</style>
+115 -9
View File
@@ -26,7 +26,7 @@ import {
Moistures,
NightLampCommand,
PlantControllerConfig,
SetTime, SSIDList, TankInfo,
SetTime, SSIDList, TankInfo, WifiScanResult,
TestPump,
VersionInfo,
SaveInfo, SolarState, PumpTestResult, Detection, DetectionRequest, CanPower
@@ -43,6 +43,7 @@ export class Controller {
controller.tankView.setTankInfo(tankinfo)
})
.catch(error => {
toast.error(`Failed to load tank info: ${error}`);
console.log(error);
});
}
@@ -64,7 +65,9 @@ export class Controller {
const json = await response.json();
const logs = json as LogArray;
controller.logView.setLog(logs);
toast.info("Log loaded successfully");
} catch (error) {
toast.error(`Failed to load log: ${error}`);
console.log(error);
}
}
@@ -87,6 +90,7 @@ export class Controller {
const timezones = json as string[];
controller.timeView.timezones(timezones);
} catch (error) {
toast.error(`Error fetching timezones: ${error}`);
return console.error('Error fetching timezones:', error);
}
}
@@ -98,6 +102,7 @@ export class Controller {
const saves = json as SaveInfo[];
controller.fileview.setSaveList(saves, PUBLIC_URL);
} catch (error) {
toast.error(`Failed to update save list: ${error}`);
console.log(error);
}
}
@@ -109,16 +114,21 @@ export class Controller {
ajax.send();
ajax.addEventListener("error", () => {
controller.progressview.removeProgress("slot_delete");
alert("Error deleting slot");
toast.error(`Failed to delete slot ${idx}`);
controller.updateSaveList();
}, false);
ajax.addEventListener("abort", () => {
controller.progressview.removeProgress("slot_delete");
alert("Aborted deleting slot");
toast.warning(`Slot deletion aborted`);
controller.updateSaveList();
}, false);
ajax.addEventListener("load", () => {
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();
}, false);
}
@@ -131,6 +141,7 @@ export class Controller {
controller.timeView.update(time.native, time.rtc);
} catch (error) {
controller.timeView.update("n/a", "n/a");
toast.error(`Failed to update RTC data: ${error}`);
console.log(error);
}
}
@@ -143,6 +154,7 @@ export class Controller {
controller.batteryView.update(battery);
} catch (error) {
controller.batteryView.update(null);
toast.error(`Failed to update battery data: ${error}`);
console.log(error);
}
}
@@ -155,6 +167,22 @@ export class Controller {
controller.solarView.update(solar);
} catch (error) {
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);
}
}
@@ -173,6 +201,7 @@ export class Controller {
controller.progressview.removeProgress("ota_upload")
const status = ajax.status;
if (status >= 200 && status < 300) {
toast.success("OTA firmware upload successful");
controller.reboot();
} else {
const statusText = ajax.statusText || "";
@@ -199,6 +228,7 @@ export class Controller {
const versionInfo = json as VersionInfo;
controller.progressview.removeProgress("version");
controller.firmWareView.setVersion(versionInfo);
toast.info("Firmware version information updated");
}
getBackupConfig() {
@@ -241,6 +271,7 @@ export class Controller {
.then(status => {
controller.progressview.removeProgress("set_config");
if (status == 200) {
toast.success("Configuration saved successfully");
setTimeout(() => {
controller.downloadConfig().then(() => {
controller.updateSaveList().then(() => {
@@ -268,7 +299,14 @@ export class Controller {
fetch(PUBLIC_URL + "/time", {
method: "POST",
body: pretty
}).then(
})
.then(response => {
if (!response.ok) {
toast.error(`Failed to sync RTC: ${response.status}`);
}
return response;
})
.then(
_ => controller.progressview.removeProgress("write_rtc")
)
}
@@ -288,9 +326,23 @@ export class Controller {
}
selfTest() {
controller.progressview.addIndeterminate("self_test", "Running board test")
fetch(PUBLIC_URL + "/boardtest", {
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) {
@@ -298,21 +350,52 @@ export class Controller {
active: active
};
var pretty = JSON.stringify(body, undefined, 1);
controller.progressview.addIndeterminate("night_lamp_test", "Testing night lamp")
fetch(PUBLIC_URL + "/lamptest", {
method: "POST",
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() {
controller.progressview.addIndeterminate("fert_test", "Testing fertilizer pump")
fetch(PUBLIC_URL + "/fertilizerpumptest", {
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) {
const plantConfig = controller.getConfig().plants[plantId];
const pumpTimeS = plantConfig.pump_time_s;
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")
let timerId: string | number | NodeJS.Timeout | undefined
@@ -341,6 +424,11 @@ export class Controller {
controller.plantViews.setPumpTestCurrent(plantId, response);
clearTimeout(timerId);
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) {
clearTimeout(timerId);
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 = (_) => {
clearTimeout(timerId);
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.send();
@@ -478,12 +573,15 @@ export class Controller {
return fetch(PUBLIC_URL + "/moisture")
.then(response => response.json())
.then(json => json as Moistures)
.then(time => {
controller.plantViews.update(time.moisture_a, time.moisture_b)
.then(data => {
controller.plantViews.update(data.moisture_a, data.moisture_b, data.sensor_a_build_minutes, data.sensor_b_build_minutes)
clearTimeout(timerId);
if (!silent) {
controller.progressview.removeProgress("measure_moisture");
}
if (!silent) {
toast.success("Moisture measurement completed");
}
})
.catch(error => {
@@ -629,6 +727,7 @@ export class Controller {
};
await this.detectSensors(detection, true);
} catch (e) {
toast.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.updateBatteryData, displayString: "Updating Battery Data"},
{task: controller.updateSolarData, displayString: "Updating Solar Data"},
{task: () => controller.measure_moisture(true), displayString: "Measuring Moisture"},
{task: controller.downloadConfig, displayString: "Downloading Configuration"},
{task: controller.version, displayString: "Fetching Version Information"},
{task: controller.updateSaveList, displayString: "Updating Save Slots"},
@@ -666,6 +766,7 @@ async function executeTasksSequentially() {
try {
await task();
} catch (error) {
toast.error(`Error executing task '${displayString}': ${error}`);
console.error(`Error executing task '${displayString}':`, error);
// Optionally, you can decide whether to continue or break on errors
break;
@@ -681,6 +782,11 @@ executeTasksSequentially().then(() => {
controller.progressview.removeProgress("rebooting");
window.addEventListener("beforeunload", (event) => {
// Only check for unsaved changes if initialConfig has been loaded
if (controller.initialConfig === null) {
return;
}
const currentConfig = controller.getConfig();
// Check if the current state differs from the initial configuration
@@ -85,7 +85,15 @@
<input class="mqttvalue" type="text" id="mqtt_password" placeholder="">
</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>
@@ -1,7 +1,9 @@
import { Controller } from "./main";
import {NetworkConfig, SSIDList} from "./api";
import {NetworkConfig, SSIDList, WifiScanResult} from "./api";
export class NetworkConfigView {
private wifiResults: HTMLElement;
setScanResult(ssidList: SSIDList) {
this.ssidlist.innerHTML = ''
for (const ssid of ssidList.ssids) {
@@ -10,6 +12,47 @@ export class NetworkConfigView {
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 ssid: HTMLInputElement;
private readonly password: HTMLInputElement;
@@ -47,9 +90,14 @@ export class NetworkConfigView {
this.ssidlist = document.getElementById("ssidlist") as HTMLElement
let scanWifiBtn = document.getElementById("scan") as HTMLButtonElement;
scanWifiBtn.onclick = function (){
scanWifiBtn.onclick = async () => {
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) {
@@ -29,6 +29,9 @@
.plantSensorEnabledOnly_ ${plantId} {
}
.plantBothSensorsOnly_ ${plantId} {
}
.plantHidden_ ${plantId} {
display: none;
}
@@ -48,6 +51,14 @@
<div class="plantkey">Sensor B installed:</div>
<input class="plantcheckbox" id="plant_${plantId}_sensor_b" type="checkbox">
</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="plantkey">
Mode:
@@ -36,11 +36,19 @@ export class PlantViews {
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++) {
const a = moisture_a[plantId]
const b = moisture_b[plantId]
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 sensorAInstalled: HTMLInputElement;
private readonly sensorBInstalled: HTMLInputElement;
private readonly sensorCombineMode: HTMLSelectElement;
private readonly mode: HTMLSelectElement;
private readonly moistureA: HTMLElement;
private readonly moistureB: HTMLElement;
@@ -228,6 +237,14 @@ export class PlantView {
controller.configChanged()
}
this.sensorCombineMode = document.getElementById("plant_" + plantId + "_sensor_combine_mode") as HTMLSelectElement;
this.sensorCombineMode.onchange = function () {
controller.configChanged()
}
// Initial visibility update for sensor combine mode
this.updateSensorCombineModeState();
this.minPumpCurrentMa = document.getElementById("plant_" + plantId + "_min_pump_current_ma") as HTMLInputElement;
this.minPumpCurrentMa.onchange = function () {
controller.configChanged()
@@ -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) {
let sensorOnly = document.getElementsByClassName("plantSensorEnabledOnly_" + this.plantId)
let pumpOnly = document.getElementsByClassName("plantPumpEnabledOnly_" + this.plantId)
@@ -316,6 +346,9 @@ export class PlantView {
// element.classList.add("plantHidden_" + this.plantId)
// }
// }
// Update sensor combine mode visibility based on whether both sensors are active
this.updateSensorCombineModeState();
}
setTestResult(result: PumpTestResult) {
@@ -346,6 +379,7 @@ export class PlantView {
this.pumpHourEnd.value = plantConfig.pump_hour_end.toString();
this.sensorBInstalled.checked = plantConfig.sensor_b;
this.sensorAInstalled.checked = plantConfig.sensor_a;
this.sensorCombineMode.value = plantConfig.sensor_combine_mode || "Min";
this.maxConsecutivePumpCount.value = plantConfig.max_consecutive_pump_count.toString();
this.minPumpCurrentMa.value = plantConfig.min_pump_current_ma.toString();
this.maxPumpCurrentMa.value = plantConfig.max_pump_current_ma.toString();
@@ -375,6 +409,7 @@ export class PlantView {
pump_hour_end: +this.pumpHourEnd.value,
sensor_b: this.sensorBInstalled.checked,
sensor_a: this.sensorAInstalled.checked,
sensor_combine_mode: this.sensorCombineMode.value,
max_consecutive_pump_count: this.maxConsecutivePumpCount.valueAsNumber,
moisture_sensor_min_frequency: this.moistureSensorMinFrequency.valueAsNumber || null,
moisture_sensor_max_frequency: this.moistureSensorMaxFrequency.valueAsNumber || null,
@@ -406,4 +441,12 @@ export class PlantView {
this.sensorAFwBuild.innerText = formatBuildMinutes(plantResult.sensor_a);
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 {BackupHeader} from "./api";
import {toast} from "./toast";
export class SubmitView {
json: HTMLDivElement;
@@ -26,25 +27,28 @@ export class SubmitView {
this.submit_status = document.getElementById("submit_status") as HTMLElement
this.submitFormBtn.onclick = () => {
controller.uploadConfig(this.json.textContent as string, (status: string) => {
if (status != "OK") {
// 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');
}
toast.info(status);
this.submit_status.innerHTML = status;
});
}
this.backupBtn.onclick = () => {
controller.progressview.addIndeterminate("backup", "Backup to EEPROM running")
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.progressview.removeProgress("backup")
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 = () => {
@@ -93,28 +93,28 @@ export class TankConfigView {
this.tank_measure_error.innerText = JSON.stringify(tankinfo.sensor_error) ;
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")
} 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")
}
if (tankinfo.percent == null){
if (tankinfo.fill_pct == null){
this.tank_measure_percent_container.classList.add("hidden")
} 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")
}
if (tankinfo.water_temp == null){
if (tankinfo.water_temp_c == null){
this.tank_measure_temperature_container.classList.add("hidden")
} 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")
}
if (tankinfo.raw == null){
if (tankinfo.fill_raw_v == null){
this.tank_measure_rawvolt_container.classList.add("hidden")
} 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")
}
+410 -72
View File
@@ -1,94 +1,432 @@
class ToastService {
private container: HTMLElement;
private stylesInjected = false;
/**
* Toast notification service for PlantCtrl embedded web interface
* Provides non-blocking notifications with auto-dismiss and click-to-close functionality
*/
constructor() {
this.container = this.ensureContainer();
this.injectStyles();
}
const TOAST_container_ID = 'toast-container';
const TOAST_STYLES_KEY = 'toast-styles-injected';
info(message: string, timeoutMs: number = 5000) {
const el = this.createToast(message, 'info');
this.container.appendChild(el);
// 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);
});
}
interface ToastOptions {
duration?: number;
dismissible?: boolean;
}
error(message: string) {
console.error(message);
const el = this.createToast(message, 'error');
this.container.appendChild(el);
// Only dismiss on click
el.addEventListener('click', () => this.dismiss(el));
}
interface ToastData {
id: string;
type: 'info' | 'success' | 'warning' | 'error';
message: string;
createdAt: number;
element?: HTMLElement;
}
private dismiss(el: HTMLElement) {
if (!el.parentElement) return;
el.parentElement.removeChild(el);
}
private createToast(message: string, type: 'info' | 'error'): HTMLElement {
const div = document.createElement('div');
div.className = `toast ${type}`;
div.textContent = message;
div.setAttribute('role', 'status');
div.setAttribute('aria-live', 'polite');
return div;
}
private ensureContainer(): HTMLElement {
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
document.body.appendChild(container);
/**
* Toast service for displaying notifications
*/
export class ToastService {
private container: HTMLElement | null = null;
private activeToasts: Map<string, ToastData> = new Map();
private maxToasts: number = 5;
// Default configuration
private defaultDuration: number = 5000; // 5 seconds for info messages
private errorDuration: number = 10000; // 10 seconds for error messages
constructor() {
this.init();
}
return container;
}
private injectStyles() {
if (this.stylesInjected) return;
const style = document.createElement('style');
style.textContent = `
/**
* Initialize the toast container and inject styles
*/
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 {
position: fixed;
top: 12px;
right: 12px;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
gap: 10px;
z-index: 9999;
max-width: 400px;
pointer-events: none;
}
.toast {
max-width: 320px;
padding: 10px 12px;
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
cursor: pointer;
user-select: none;
font-family: sans-serif;
background: #fff;
border-left: 4px solid transparent;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 8px;
padding: 12px 16px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
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 {
background-color: #d4edda; /* green-ish */
color: #155724;
border-left: 4px solid #28a745;
border-color: #3b82f6;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
}
.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 {
background-color: #f8d7da; /* red-ish */
color: #721c24;
border-left: 4px solid #dc3545;
border-color: #ef4444;
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
}
`;
document.head.appendChild(style);
this.stylesInjected = true;
.toast:hover {
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();