Compare commits
11 Commits
fe2d227c67
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| c112d133db | |||
| 95281d617f | |||
| a2abc99275 | |||
| 4b3c003996 | |||
| bba959f2a2 | |||
| c9a96f37f0 | |||
| fbf97732a4 | |||
| 6b419dba6c | |||
| 3618b3329c | |||
| f5f73723d1 | |||
| be98380ba4 |
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
Generated
+1
@@ -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>
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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 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;
|
||||||
@@ -81,7 +81,8 @@ impl BatteryInteraction for WCHI2CSlave<'_> {
|
|||||||
|
|
||||||
let state_of_charge =
|
let state_of_charge =
|
||||||
state.remaining_capacity_mah as f32 * 100. / state.lifetime_capacity_mah as f32;
|
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.;
|
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_mv: Some(state.current_mv),
|
voltage_mv: Some(state.current_mv),
|
||||||
@@ -91,7 +92,7 @@ impl BatteryInteraction for WCHI2CSlave<'_> {
|
|||||||
soc_pct: Some(state_of_charge),
|
soc_pct: Some(state_of_charge),
|
||||||
soh_pct: Some(state_of_health),
|
soh_pct: Some(state_of_health),
|
||||||
temperature_c: Some(state.temperature_celcius),
|
temperature_c: Some(state.temperature_celcius),
|
||||||
error: None
|
error: None,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::bail;
|
use crate::bail;
|
||||||
use crate::config::{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};
|
||||||
@@ -7,9 +7,9 @@ use chrono::{DateTime, Utc};
|
|||||||
|
|
||||||
use crate::fat_error::{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::{string::String, vec, vec::Vec};
|
use alloc::{format, vec, vec::Vec};
|
||||||
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 embedded_storage::nor_flash::{check_erase, NorFlash, ReadNorFlash, RmwNorFlashStorage};
|
use embedded_storage::nor_flash::{check_erase, NorFlash, ReadNorFlash, RmwNorFlashStorage};
|
||||||
@@ -25,11 +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_radio::wifi::ap::{AccessPointInfo};
|
use esp_radio::wifi::ap::AccessPointInfo;
|
||||||
use esp_radio::wifi::scan::{ScanConfig, ScanTypeConfig};
|
use esp_radio::wifi::scan::{ScanConfig, ScanTypeConfig};
|
||||||
use esp_radio::wifi::{Interface, WifiController};
|
use esp_radio::wifi::{Interface, WifiController};
|
||||||
use log::{error, info};
|
use log::{error, info};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 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];
|
||||||
@@ -192,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)
|
||||||
@@ -376,5 +418,4 @@ impl Esp<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use esp_hal::delay::Delay;
|
|||||||
use esp_hal::gpio::{DriveMode, Flex, Input, InputConfig, Output, OutputConfig, Pull};
|
use esp_hal::gpio::{DriveMode, Flex, Input, InputConfig, Output, OutputConfig, Pull};
|
||||||
use esp_hal::pcnt::channel::CtrlMode::Keep;
|
use esp_hal::pcnt::channel::CtrlMode::Keep;
|
||||||
use esp_hal::pcnt::channel::EdgeMode::{Hold, Increment};
|
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::{error, info};
|
use log::{error, info};
|
||||||
@@ -145,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()
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
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::{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, WATER_FROZEN_THRESH};
|
use crate::tank::{determine_tank_state, TankError, WATER_FROZEN_THRESH};
|
||||||
@@ -42,8 +42,8 @@ use embassy_sync::once_lock::OnceLock;
|
|||||||
use embassy_time::{Duration, Instant, Timer};
|
use embassy_time::{Duration, Instant, Timer};
|
||||||
use esp_hal::rom::ets_delay_us;
|
use esp_hal::rom::ets_delay_us;
|
||||||
use esp_hal::system::software_reset;
|
use esp_hal::system::software_reset;
|
||||||
use esp_println::{println};
|
use esp_println::println;
|
||||||
use hal::battery::{BatteryState};
|
use hal::battery::BatteryState;
|
||||||
use log::LogMessage;
|
use log::LogMessage;
|
||||||
use option_lock::OptionLock;
|
use option_lock::OptionLock;
|
||||||
use plant_state::PlantState;
|
use plant_state::PlantState;
|
||||||
@@ -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));
|
||||||
@@ -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
|
||||||
@@ -276,10 +283,13 @@ 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 {
|
||||||
mqtt::publish_firmware_info(version, ip_address, &timezone_time.to_rfc3339()).await;
|
mqtt::publish_firmware_info(version, ip_address, &timezone_time.to_rfc3339()).await;
|
||||||
mqtt::publish_battery_state(&mut board).await.unwrap_or_else(|e| {
|
mqtt::publish_battery_state(&mut board)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
error!("Error publishing battery state {e}");
|
error!("Error publishing battery state {e}");
|
||||||
});
|
});
|
||||||
let _ = mqtt::publish_mppt_state(&mut board).await;
|
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_mv: 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,
|
||||||
@@ -354,7 +366,11 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut water_frozen = false;
|
let mut water_frozen = false;
|
||||||
let water_temp: FatResult<f32> = board.board_hal.get_tank_sensor().water_temperature_c().await;
|
let water_temp: FatResult<f32> = board
|
||||||
|
.board_hal
|
||||||
|
.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 {
|
||||||
@@ -580,11 +596,15 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
|
|||||||
light_state.battery_low = false;
|
light_state.battery_low = false;
|
||||||
}
|
}
|
||||||
BatteryState::Info(data) => {
|
BatteryState::Info(data) => {
|
||||||
if data.soc_pct.is_some_and(|soc| soc < board.board_hal.get_config().night_lamp.low_soc_cutoff as f32) {
|
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.soc_pct.is_some_and(|soc| soc > board.board_hal.get_config().night_lamp.low_soc_restore as f32) {
|
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");
|
||||||
}
|
}
|
||||||
@@ -987,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ use crate::log::{log, LogMessage};
|
|||||||
use crate::plant_state::PlantState;
|
use crate::plant_state::PlantState;
|
||||||
use crate::tank::TankState;
|
use crate::tank::TankState;
|
||||||
use crate::{bail, VersionInfo};
|
use crate::{bail, VersionInfo};
|
||||||
use alloc::string::String;
|
use alloc::format;
|
||||||
use alloc::{format, string::ToString};
|
use alloc::string::{String, ToString};
|
||||||
|
use alloc::vec::Vec;
|
||||||
use chrono::DateTime;
|
use chrono::DateTime;
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use core::sync::atomic::Ordering;
|
use core::sync::atomic::Ordering;
|
||||||
@@ -287,11 +288,7 @@ pub async fn publish_plant_states(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn publish_firmware_info(
|
pub async fn publish_firmware_info(version: VersionInfo, ip_address: &str, timezone_time: &str) {
|
||||||
version: VersionInfo,
|
|
||||||
ip_address: &str,
|
|
||||||
timezone_time: &str,
|
|
||||||
) {
|
|
||||||
publish("/firmware/address", ip_address).await;
|
publish("/firmware/address", ip_address).await;
|
||||||
let version = &serde_json::to_string(&version);
|
let version = &serde_json::to_string(&version);
|
||||||
match version {
|
match version {
|
||||||
@@ -349,6 +346,16 @@ pub async fn pump_info(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
#[derive(Serialize, Debug, PartialEq)]
|
||||||
pub struct Solar {
|
pub struct Solar {
|
||||||
pub current_ma: u32,
|
pub current_ma: u32,
|
||||||
@@ -373,12 +380,7 @@ pub async fn publish_mppt_state(
|
|||||||
pub async fn publish_battery_state(
|
pub async fn publish_battery_state(
|
||||||
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
|
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
|
||||||
) -> FatResult<()> {
|
) -> FatResult<()> {
|
||||||
let telemetry = match board
|
let telemetry = match board.board_hal.get_battery_monitor().get_state().await {
|
||||||
.board_hal
|
|
||||||
.get_battery_monitor()
|
|
||||||
.get_state()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(BatteryState::Info(info)) => info,
|
Ok(BatteryState::Info(info)) => info,
|
||||||
Ok(BatteryState::Unknown) => BatteryInfo {
|
Ok(BatteryState::Unknown) => BatteryInfo {
|
||||||
voltage_mv: None,
|
voltage_mv: None,
|
||||||
@@ -407,3 +409,30 @@ pub async fn publish_battery_state(
|
|||||||
publish("/battery", &json).await;
|
publish("/battery", &json).await;
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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::{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";
|
||||||
|
|
||||||
@@ -225,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");
|
||||||
@@ -270,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());
|
||||||
|
|
||||||
@@ -294,27 +293,53 @@ pub async fn wifi(
|
|||||||
} else {
|
} else {
|
||||||
AuthenticationMethod::Wpa2Personal
|
AuthenticationMethod::Wpa2Personal
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Spawn the network task once
|
||||||
|
spawner.spawn(net_task(runner)?);
|
||||||
|
|
||||||
|
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()
|
let client_config = StationConfig::default()
|
||||||
.with_ssid(ssid)
|
.with_ssid(ssid.clone())
|
||||||
.with_auth_method(auth_method)
|
.with_auth_method(auth_method)
|
||||||
.with_scan_method(esp_radio::wifi::sta::ScanMethod::AllChannels)
|
.with_scan_method(esp_radio::wifi::sta::ScanMethod::AllChannels)
|
||||||
.with_listen_interval(10)
|
.with_listen_interval(10)
|
||||||
.with_beacon_timeout(10)
|
.with_beacon_timeout(10)
|
||||||
.with_failure_retry_cnt(3)
|
.with_failure_retry_cnt(3)
|
||||||
.with_password(password);
|
.with_password(password.clone());
|
||||||
|
|
||||||
|
// Set config and attempt connection
|
||||||
controller
|
controller
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.set_config(&Config::Station(client_config))?;
|
.set_config(&Config::Station(client_config))?;
|
||||||
spawner.spawn(net_task(runner)?);
|
|
||||||
controller
|
match controller
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.connect_async()
|
.connect_async()
|
||||||
.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")??;
|
{
|
||||||
|
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 {
|
let res = async {
|
||||||
while !stack.is_link_up() {
|
while !stack.is_link_up() {
|
||||||
@@ -326,7 +351,13 @@ pub async fn wifi(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if res.is_err() {
|
if res.is_err() {
|
||||||
bail!("Timeout waiting for wifi link up")
|
warn!(
|
||||||
|
"WiFi connection attempt {} failed: link up timeout",
|
||||||
|
attempts + 1
|
||||||
|
);
|
||||||
|
attempts += 1;
|
||||||
|
Timer::after(Duration::from_millis(500)).await;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let res = async {
|
let res = async {
|
||||||
@@ -339,11 +370,22 @@ pub async fn wifi(
|
|||||||
.await;
|
.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());
|
info!("Connected WIFI, dhcp: {:?}", stack.config_v4());
|
||||||
Ok(*stack)
|
return Ok(*stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All retries exhausted
|
||||||
|
bail!("WiFi connection failed after all retries");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn try_connect_wifi_sntp_mqtt(
|
pub async fn try_connect_wifi_sntp_mqtt(
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
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, Clone, Serialize)]
|
#[derive(Debug, PartialEq, Clone, Serialize)]
|
||||||
@@ -13,7 +15,7 @@ pub enum MoistureSensorError {
|
|||||||
MissingMessage,
|
MissingMessage,
|
||||||
NotExpectedMessage { hz: f32 },
|
NotExpectedMessage { hz: f32 },
|
||||||
ShortCircuit { hz: f32, max: f32 },
|
ShortCircuit { hz: f32, max: f32 },
|
||||||
OpenLoop { hz: f32, min: f32 }
|
OpenLoop { hz: f32, min: f32 },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Serialize)]
|
#[derive(Debug, PartialEq, Serialize)]
|
||||||
@@ -109,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>,
|
||||||
@@ -134,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 {
|
||||||
@@ -184,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,
|
||||||
@@ -198,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
|
||||||
@@ -221,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<f32>,
|
/// - 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.), (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), (None, self.sensor_b.is_err()))
|
},
|
||||||
}
|
(Some(moisture), _) => Some(moisture),
|
||||||
(_, Some(moisture_percent)) => {
|
(_, Some(moisture)) => Some(moisture),
|
||||||
(Some(moisture_percent), (self.sensor_a.is_err(), None))
|
_ => None,
|
||||||
}
|
|
||||||
_ => (None, (self.sensor_a.is_err(), self.sensor_b.is_err())),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +310,7 @@ impl PlantState {
|
|||||||
match plant_conf.mode {
|
match plant_conf.mode {
|
||||||
PlantWateringMode::Off => false,
|
PlantWateringMode::Off => false,
|
||||||
PlantWateringMode::TargetMoisture => {
|
PlantWateringMode::TargetMoisture => {
|
||||||
let (moisture_percent, _) = self.plant_moisture();
|
let moisture_percent = self.plant_moisture_with_warning(plant_conf);
|
||||||
if let Some(moisture_percent) = moisture_percent {
|
if let Some(moisture_percent) = moisture_percent {
|
||||||
if self.pump_in_timeout(plant_conf, current_time) {
|
if self.pump_in_timeout(plant_conf, current_time) {
|
||||||
false
|
false
|
||||||
@@ -278,17 +336,18 @@ 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 {
|
|
||||||
let (moisture_pct, _) = self.plant_moisture();
|
|
||||||
PlantInfo {
|
PlantInfo {
|
||||||
moisture_pct,
|
moisture_pct,
|
||||||
sensor_a: Self::sensor_to_telemetry(&self.sensor_a),
|
sensor_a: Self::sensor_to_telemetry(&self.sensor_a),
|
||||||
sensor_b: Self::sensor_to_telemetry(&self.sensor_b),
|
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) = moisture_pct {
|
dry: if let Some(moisture_percent) = moisture_pct {
|
||||||
moisture_percent < plant_conf.target_moisture.into()
|
moisture_percent < plant_conf.target_moisture.into()
|
||||||
@@ -321,21 +380,39 @@ impl PlantState {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
|
last_fertilizer: self
|
||||||
|
.last_fertilizer_time
|
||||||
|
.map(|t| t.with_timezone(¤t_time.timezone())),
|
||||||
|
next_fertilizer: if matches!(
|
||||||
|
plant_conf.mode,
|
||||||
|
PlantWateringMode::TimerOnly
|
||||||
|
| PlantWateringMode::TargetMoisture
|
||||||
|
| PlantWateringMode::MinMoisture
|
||||||
|
) {
|
||||||
|
self.last_fertilizer_time.and_then(|last_fert| {
|
||||||
|
// Convert to Tz for calculation, then back
|
||||||
|
let tz_last_fert = last_fert.with_timezone(¤t_time.timezone());
|
||||||
|
tz_last_fert
|
||||||
|
.checked_add_signed(TimeDelta::minutes(
|
||||||
|
plant_conf.fertilizer_cooldown_min.into(),
|
||||||
|
))
|
||||||
|
.map(|t| t.with_timezone(¤t_time.timezone()))
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
sensor_a_firmware_build_minutes: self.sensor_a_firmware_build_minutes,
|
sensor_a_firmware_build_minutes: self.sensor_a_firmware_build_minutes,
|
||||||
sensor_b_firmware_build_minutes: self.sensor_b_firmware_build_minutes,
|
sensor_b_firmware_build_minutes: self.sensor_b_firmware_build_minutes,
|
||||||
last_fertilizer_time: self.last_fertilizer_time,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sensor_to_telemetry(sensor: &MoistureSensorState) -> SensorTelemetry {
|
fn sensor_to_telemetry(sensor: &MoistureSensorState) -> SensorTelemetry {
|
||||||
match sensor {
|
match sensor {
|
||||||
MoistureSensorState::NoMessage => {
|
MoistureSensorState::NoMessage => SensorTelemetry {
|
||||||
SensorTelemetry {
|
|
||||||
moisture_pct: None,
|
moisture_pct: None,
|
||||||
raw_hz: None,
|
raw_hz: None,
|
||||||
error: None
|
error: None,
|
||||||
}
|
},
|
||||||
}
|
|
||||||
MoistureSensorState::MoistureValue {
|
MoistureSensorState::MoistureValue {
|
||||||
hz,
|
hz,
|
||||||
moisture_percent,
|
moisture_percent,
|
||||||
@@ -358,6 +435,8 @@ impl PlantState {
|
|||||||
pub struct PlantInfo {
|
pub struct PlantInfo {
|
||||||
/// combined plant moisture from available sensors
|
/// combined plant moisture from available sensors
|
||||||
moisture_pct: Option<f32>,
|
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: SensorTelemetry,
|
sensor_a: SensorTelemetry,
|
||||||
/// state of humidity sensor on bank b
|
/// state of humidity sensor on bank b
|
||||||
@@ -379,10 +458,12 @@ pub struct PlantInfo {
|
|||||||
last_pump: Option<DateTime<Tz>>,
|
last_pump: Option<DateTime<Tz>>,
|
||||||
/// next time when pump should activate
|
/// next time when pump should activate
|
||||||
next_pump: Option<DateTime<Tz>>,
|
next_pump: Option<DateTime<Tz>>,
|
||||||
|
/// last time when fertilizer was applied
|
||||||
|
last_fertilizer: Option<DateTime<Tz>>,
|
||||||
|
/// next time when fertilizer should be applied
|
||||||
|
next_fertilizer: Option<DateTime<Tz>>,
|
||||||
/// firmware build timestamp of sensor A (minutes since Unix epoch); None if unknown
|
/// firmware build timestamp of sensor A (minutes since Unix epoch); None if unknown
|
||||||
sensor_a_firmware_build_minutes: Option<u32>,
|
sensor_a_firmware_build_minutes: Option<u32>,
|
||||||
/// firmware build timestamp of sensor B (minutes since Unix epoch); None if unknown
|
/// firmware build timestamp of sensor B (minutes since Unix epoch); None if unknown
|
||||||
sensor_b_firmware_build_minutes: Option<u32>,
|
sensor_b_firmware_build_minutes: Option<u32>,
|
||||||
/// last time when fertilizer was applied
|
|
||||||
last_fertilizer_time: i64,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,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_mv: 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);
|
||||||
@@ -161,12 +163,14 @@ pub async fn determine_tank_state(
|
|||||||
if board.board_hal.get_config().tank.tank_sensor_enabled {
|
if board.board_hal.get_config().tank.tank_sensor_enabled {
|
||||||
match board
|
match board
|
||||||
.board_hal
|
.board_hal
|
||||||
.get_tank_sensor().tank_sensor_voltage().await
|
.get_tank_sensor()
|
||||||
|
.tank_sensor_voltage()
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
Ok(raw_sensor_value_mv) => {
|
Ok(raw_sensor_value_mv) => TankState::Present(raw_sensor_value_mv),
|
||||||
TankState::Present(raw_sensor_value_mv)
|
Err(err) => TankState::Error(TankError::BoardError {
|
||||||
},
|
message: err.to_string(),
|
||||||
Err(err) => TankState::Error(TankError::BoardError { message: err.to_string() }),
|
}),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
TankState::Disabled
|
TankState::Disabled
|
||||||
|
|||||||
@@ -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,7 +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;
|
||||||
let water_temp: FatResult<f32> = board.board_hal.get_tank_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(
|
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,
|
||||||
@@ -201,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)?))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,16 +350,44 @@ 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) {
|
||||||
@@ -344,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`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -428,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();
|
||||||
@@ -481,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 => {
|
||||||
@@ -632,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,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"},
|
||||||
@@ -669,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;
|
||||||
@@ -684,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>
|
||||||
|
|
||||||
|
<div id="wifi-results">
|
||||||
|
<p>Scan for available networks to see signal strength</p>
|
||||||
|
</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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TOAST_container_ID = 'toast-container';
|
||||||
|
const TOAST_STYLES_KEY = 'toast-styles-injected';
|
||||||
|
|
||||||
|
interface ToastOptions {
|
||||||
|
duration?: number;
|
||||||
|
dismissible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastData {
|
||||||
|
id: string;
|
||||||
|
type: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
message: string;
|
||||||
|
createdAt: number;
|
||||||
|
element?: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
constructor() {
|
||||||
this.container = this.ensureContainer();
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the toast container and inject styles
|
||||||
|
*/
|
||||||
|
private init(): void {
|
||||||
|
this.ensureContainer();
|
||||||
this.injectStyles();
|
this.injectStyles();
|
||||||
}
|
}
|
||||||
|
|
||||||
info(message: string, timeoutMs: number = 5000) {
|
/**
|
||||||
const el = this.createToast(message, 'info');
|
* Get or create the toast container element
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
private ensureContainer(): HTMLElement {
|
||||||
let container = document.getElementById('toast-container');
|
if (this.container) return this.container;
|
||||||
|
|
||||||
|
let container = document.getElementById(TOAST_container_ID);
|
||||||
if (!container) {
|
if (!container) {
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
container.id = 'toast-container';
|
container.id = TOAST_container_ID;
|
||||||
|
container.setAttribute('role', 'region');
|
||||||
|
container.setAttribute('aria-label', 'Notifications');
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.container = container;
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
private injectStyles() {
|
/**
|
||||||
if (this.stylesInjected) return;
|
* 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');
|
const style = document.createElement('style');
|
||||||
|
style.setAttribute('data-id', TOAST_STYLES_KEY);
|
||||||
style.textContent = `
|
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> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user