47 Commits

Author SHA1 Message Date
c112d133db Add missing Box import in battery module 2026-05-30 21:01:29 +02:00
95281d617f clippy fixed 2026-05-30 20:59:58 +02:00
a2abc99275 Refactor formatting and remove unused imports in mqtt and plant_state modules 2026-05-30 20:57:39 +02:00
4b3c003996 Add target_pct field to PlantInfo for target moisture mode 2026-05-30 20:57:31 +02:00
bba959f2a2 Update POST JSON size limit to use SAVEGAME_SLOT_SIZE minus header space 2026-05-30 20:55:51 +02:00
c9a96f37f0 feat: add sensor combine mode with Min, Max, and Avg options, update web UI and configuration for multi-sensor support 2026-05-29 11:22:12 +02:00
fbf97732a4 refactor: update KiCad 3D model paths and zone settings for compatibility with updated libraries 2026-05-28 20:14:51 +02:00
6b419dba6c Add Wi-Fi scan details display and MQTT publish
- HTML: Add Wi-Fi scan results container to network.html
- Rust: Implement `wifi_scan_details()` with RSSI, channel, auth method
- API & UI: Fetch and display scan results in table format
- MQTT: Publish top 10 networks sorted by RSSI to `/wifi_scan`
2026-05-28 00:46:14 +02:00
3618b3329c refactor tank info field names and improve null checks in UI 2026-05-27 15:18:46 +02:00
f5f73723d1 retry wifi connection, show canbus FW version, adjust measurement formular 2026-05-27 03:36:39 +02:00
be98380ba4 new toast impl, wip 2026-05-26 13:27:35 +02:00
fe2d227c67 Fix fertilizer calculation and logging 2026-05-26 02:03:19 +02:00
bd5b687430 fix pump test progress bar 2026-05-25 23:22:21 +02:00
7679fa09dc clippy: fix clippy warnings 2026-05-25 20:11:58 +02:00
7078af5713 Merge pull request 'refactor/mqtt-data-serialization' (#23) from refactor/mqtt-data-serialization into develop
Reviewed-on: #23
2026-05-25 19:42:14 +02:00
32256d0c91 move all mqtt publishing functions to mqtt module 2026-05-24 18:32:10 +02:00
d4a4c1b573 refctor: TankInfo structure (consistent layout)
- fix: use tagged enum serialization for TankError
- fix: rename TankInfo fields for consistent naming (volume_ml, pct, water_temp_c)
- renamed some fields for better clarity on contained value
2026-05-24 17:51:17 +02:00
6bf7a04024 refactor: PlantInfo structure (consistent layout)
- fix: use tagged enum serialization for MoistureSensorError and PumpError
- fix: flatten PlantInfo sensors to SensorTelemetry with top-level moisture_pct
2026-05-24 17:50:38 +02:00
df3159aa16 refactor: BatteryInfo structure (consistent layout)
- use tagged enum serialization for BatteryError
- flatten BatteryInfo telemetry with consistent field names and typed error
2026-05-24 14:49:35 +02:00
7866604a40 fix: serialize firmware/state as JSON instead of Debug format 2026-05-24 14:06:39 +02:00
d989b41bdd wip website docu 2026-05-21 07:16:39 +02:00
ac8305953a Merge pull request 'refactor/mkstatic-util' (#22) from refactor/mkstatic-util into develop
Reviewed-on: #22
2026-05-18 00:29:52 +02:00
d1076145c4 Merge branch 'develop' into refactor/mkstatic-util 2026-05-18 00:29:41 +02:00
cf32f7e05d Merge pull request 'refactor/network-module' (#21) from refactor/network-module into develop
Reviewed-on: #21
2026-05-18 00:29:14 +02:00
5e08820276 Merge pull request 'refactor/mqtt-module' (#20) from refactor/mqtt-module into develop
Reviewed-on: #20
2026-05-18 00:28:12 +02:00
d2a659638d refactor: create util module with shared mk_static
- use crate::util::mk_static in network module
- use crate::util::mk_static in mqtt module
- use crate::util::mk_static in hal module
- remove dead mk_static macro from esp module
2026-05-17 12:39:41 +02:00
40f99870cf refactor: move try_connect_wifi_sntp_mqtt to network module 2026-05-17 12:30:43 +02:00
ac200af7a9 refactor: move wifi to network module 2026-05-17 12:29:57 +02:00
9d57805502 refactor: move wifi_ap to network module 2026-05-17 12:27:54 +02:00
bafc86681c refactor: move sntp to network module 2026-05-17 12:24:39 +02:00
5f9db41d65 refactor: move run_dhcp to network module 2026-05-17 12:22:07 +02:00
ba654a904b refactor: move net_task to network module 2026-05-17 12:21:32 +02:00
cd4d0cc683 refactor: create network module
- move NetworkMode and SntpMode enums
2026-05-17 12:19:22 +02:00
2cfb2607a9 refactor: move Solar struct to mqtt module 2026-05-10 02:38:50 +02:00
271c1a1383 refactor: move PumpInfo struct to mqtt module 2026-05-10 02:38:43 +02:00
a02b84d732 refactor: create mqtt module with core MQTT statics and tasks 2026-05-10 02:34:16 +02:00
b0f8bcc9da Merge remote-tracking branch 'origin/develop' into develop
# Conflicts:
#	Software/MainBoard/rust/src/hal/water.rs
2026-05-06 09:29:25 +02:00
103859120c Add initial TODO file with pending tasks for One Wire, Flow Sensor, and PlantProfiles implementation 2026-05-06 09:27:19 +02:00
403517fdb4 Suppress EMI noise on water flow sensor by filtering short pulses 2026-05-06 09:26:14 +02:00
11eb8713bf more startup debugging 2026-05-05 18:04:00 +02:00
d903c2bf52 Refactor flow meter logic: replace global mutex with per-instance flow_unit and use critical-section for thread safety. 2026-05-05 16:27:21 +02:00
f8f76674ce Refactor flow meter handling: switch get_flow_meter_value to get_full_flow_count, update related structs and logic to use u32 for flow values. 2026-05-05 01:14:54 +02:00
3cc5a0d2bd dependency lock upgrade 2026-05-05 00:50:18 +02:00
3be585ecbf Refactor flow meter handling with interrupt-based logic and global state
- Added `flow_interrupt_handler` for efficient interrupt processing.
- Replaced per-instance `flow_counter` with global atomic and mutex-based state (`FLOW_OVERFLOW_COUNTER`, `FLOW_UNIT`).
- Updated flow meter functions to leverage the new architecture for better modularity and thread safety.
- Switched debugging output from `println!` to `log` for improved logging consistency.
2026-05-05 00:50:18 +02:00
5b1a945ac3 Replace blocking http_server call with async task using spawner 2026-05-05 00:50:18 +02:00
f4e050d413 Add ChecksumError handling to FatError conversion 2026-05-05 00:50:18 +02:00
776db785c4 Update hardware and firmware documentation for new modules and features
- Removed outdated TODOs and legacy references in hardware documentation.
- Added details on the new CH32V203-based Sensor Module for CAN bus soil moisture sensors.
- Documented updates to the Battery Management System (CH32V203-based) replacing the older bq34z100 design.
- Refined sensor, pump, and power module descriptions with updated specifications.
- Expanded firmware documentation to include Rust-based ESP32-C6 platform details, new OTA procedure, and MQTT telemetry topics.
- Simplified toolchain setup and compilation process with updated scripts and instructions.
2026-05-05 00:50:18 +02:00
41 changed files with 2773 additions and 1478 deletions

View File

@@ -893,7 +893,7 @@
(uuid "f5c71e09-1c64-4699-a872-7959060ff54c")
)
(embedded_fonts no)
(model "${KICAD6_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.wrl"
(model "${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.step"
(offset
(xyz 0 0 0)
)
@@ -2381,7 +2381,7 @@
(uuid "2619df3e-b807-4d9d-86fd-1890ae6a2950")
)
(embedded_fonts no)
(model "${KICAD6_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.wrl"
(model "${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.step"
(offset
(xyz 0 0 0)
)
@@ -3089,7 +3089,7 @@
(uuid "29bb50c2-8c3f-4ba0-bb86-3c75e758d317")
)
(embedded_fonts no)
(model "${KICAD6_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.wrl"
(model "${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.step"
(offset
(xyz 0 0 0)
)
@@ -3622,7 +3622,7 @@
(uuid "ac7efe3b-be6c-4633-baa1-c7a44a15dc73")
)
(embedded_fonts no)
(model "${KICAD6_3DMODEL_DIR}/Package_TO_SOT_SMD.3dshapes/SOT-23.wrl"
(model "${KICAD10_3DMODEL_DIR}/Package_TO_SOT_SMD.3dshapes/SOT-23.step"
(offset
(xyz 0 0 0)
)
@@ -6371,7 +6371,7 @@
(uuid "f844f701-66c7-484d-a7cf-cff3b6cdd615")
)
(embedded_fonts no)
(model "${KICAD6_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_1206_3216Metric.wrl"
(model "${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_1206_3216Metric.step"
(offset
(xyz 0 0 0)
)
@@ -8173,7 +8173,7 @@
(uuid "09bb897b-8ad7-4832-82c0-09ce6f9fd4df")
)
(embedded_fonts no)
(model "${KICAD6_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.wrl"
(model "${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.step"
(offset
(xyz 0 0 0)
)
@@ -10055,7 +10055,7 @@
(uuid "3e5e9acf-dab8-4180-8dd7-e2e038a511be")
)
(embedded_fonts no)
(model "${KICAD6_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.wrl"
(model "${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.step"
(offset
(xyz 0 0 0)
)
@@ -13870,7 +13870,7 @@
(uuid "a9590be1-1db5-48eb-8721-a2ce5049db3f")
)
(embedded_fonts no)
(model "${KICAD6_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.wrl"
(model "${KICAD10_3DMODEL_DIR}/Capacitor_SMD.3dshapes/C_0603_1608Metric.step"
(offset
(xyz 0 0 0)
)

View File

@@ -50,7 +50,23 @@
"silk_text_thickness": 0.1,
"silk_text_upright": false,
"zones": {
"min_clearance": 0.5
"border_display_style": 2,
"border_hatch_pitch": 0.5,
"corner_radius": 0.0,
"corner_smoothing": 0,
"fill_mode": 0,
"hatch_gap": 1.5,
"hatch_orientation": 0.0,
"hatch_smoothing_level": 0,
"hatch_smoothing_value": 0.1,
"hatch_thickness": 1.0,
"min_clearance": 0.5,
"min_island_area": 10.0,
"min_thickness": 0.25,
"pad_connection": 1,
"remove_islands": 0,
"thermal_relief_gap": 0.5,
"thermal_relief_spoke_width": 0.5
}
},
"diff_pair_dimensions": [

View File

@@ -2,5 +2,6 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../../.." vcs="Git" />
<mapping directory="$PROJECT_DIR$/../../../website/themes/blowfish" vcs="Git" />
</component>
</project>

File diff suppressed because it is too large Load Diff

View File

@@ -101,6 +101,7 @@ chrono-tz = { version = "0.10.4", default-features = false, features = ["filter-
heapless = { version = "0.7.17", features = ["serde"] } # stay in sync with mcutie version
static_cell = "2.1.1"
portable-atomic = "1.11.1"
critical-section = "1"
crc = "3.3.0"
bytemuck = { version = "1.24.0", features = ["derive", "min_const_generics", "pod_saturating", "extern_crate_alloc"] }
deranged = "0.5.5"

View File

@@ -0,0 +1,3 @@
One Wire does not seem to work.
Flow Sensor does not seem to work.
PlantProfiles with a dry out phase needs to be implemented + Memory for this

View File

@@ -14,6 +14,7 @@ pub struct NetworkConfig {
pub mqtt_user: Option<String>,
pub mqtt_password: Option<String>,
pub max_wait: u32,
pub retry_count: u32,
}
impl Default for NetworkConfig {
fn default() -> Self {
@@ -26,6 +27,7 @@ impl Default for NetworkConfig {
mqtt_user: None,
mqtt_password: None,
max_wait: 10000,
retry_count: 3,
}
}
}
@@ -110,6 +112,14 @@ pub struct PlantControllerConfig {
pub timezone: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub enum SensorCombineMode {
Min,
Max,
#[default]
Avg,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(default)]
pub struct PlantConfig {
@@ -131,6 +141,7 @@ pub struct PlantConfig {
pub ignore_current_error: bool,
pub fertilizer_s: u16,
pub fertilizer_cooldown_min: u16,
pub sensor_combine_mode: SensorCombineMode,
}
impl Default for PlantConfig {
@@ -154,6 +165,7 @@ impl Default for PlantConfig {
ignore_current_error: true,
fertilizer_s: 0,
fertilizer_cooldown_min: 1440, // 1 day default
sensor_combine_mode: SensorCombineMode::Avg,
}
}
}

View File

@@ -319,6 +319,9 @@ impl From<BmsProtocolError> for FatError {
BmsProtocolError::I2cCommunicationError => FatError::String {
error: "I2C communication error".to_string(),
},
BmsProtocolError::ChecksumError => FatError::String {
error: "BMS checksum error".to_string(),
},
}
}
}

View File

@@ -1,5 +1,6 @@
use crate::fat_error::{FatError, FatResult};
use crate::hal::Box;
use crate::fat_error::{FatError, FatResult};
use alloc::string::String;
use async_trait::async_trait;
use embassy_embedded_hal::shared_bus::blocking::i2c::I2cDevice;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
@@ -18,15 +19,23 @@ pub trait BatteryInteraction {
async fn reset(&mut self) -> FatResult<()>;
}
#[derive(Debug, Serialize, Copy, Clone)]
#[derive(Debug, Serialize, Clone)]
pub struct BatteryInfo {
pub voltage_milli_volt: u32,
pub average_current_milli_ampere: i32,
pub design_milli_ampere_hour: u32,
pub remaining_milli_ampere_hour: u32,
pub state_of_charge: u8,
pub state_of_health: u32,
pub temperature: i32,
pub voltage_mv: Option<u32>,
pub avg_current_ma: Option<i32>,
pub design_mah: Option<u32>,
pub remaining_mah: Option<u32>,
pub soc_pct: Option<f32>,
pub soh_pct: Option<f32>,
pub temperature_c: Option<i32>,
pub error: Option<BatteryError>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(tag = "kind")]
pub enum BatteryError {
NoBatteryMonitor,
CommunicationError { message: String },
}
#[derive(Debug, Serialize)]
@@ -71,17 +80,19 @@ impl BatteryInteraction for WCHI2CSlave<'_> {
let config = Config::read_from_i2c(&mut self.i2c)?;
let state_of_charge =
(state.remaining_capacity_mah * 100 / state.lifetime_capacity_mah) as u8;
let state_of_health = state.lifetime_capacity_mah / config.capacity_mah * 100;
state.remaining_capacity_mah as f32 * 100. / state.lifetime_capacity_mah as f32;
let state_of_health =
state.lifetime_capacity_mah as f32 / config.capacity_mah as f32 * 100.;
Ok(BatteryState::Info(BatteryInfo {
voltage_milli_volt: state.current_mv,
average_current_milli_ampere: 1337,
design_milli_ampere_hour: config.capacity_mah,
remaining_milli_ampere_hour: state.remaining_capacity_mah,
state_of_charge,
state_of_health,
temperature: state.temperature_celcius,
voltage_mv: Some(state.current_mv),
avg_current_ma: Some(1337),
design_mah: Some(config.capacity_mah),
remaining_mah: Some(state.remaining_capacity_mah),
soc_pct: Some(state_of_charge),
soh_pct: Some(state_of_health),
temperature_c: Some(state.temperature_celcius),
error: None,
}))
}

View File

@@ -1,25 +1,17 @@
use crate::bail;
use crate::config::{NetworkConfig, PlantControllerConfig};
use crate::config::PlantControllerConfig;
use crate::hal::savegame_manager::SavegameManager;
use crate::hal::PLANT_COUNT;
use crate::log::{log, LogMessage};
use chrono::{DateTime, Utc};
use crate::fat_error::{ContextExt, FatError, FatResult};
use crate::fat_error::{FatError, FatResult};
use crate::hal::shared_flash::MutexFlashStorage;
use alloc::string::ToString;
use alloc::string::{String, ToString};
use alloc::sync::Arc;
use alloc::{format, string::String, vec, vec::Vec};
use core::net::{IpAddr, Ipv4Addr, SocketAddr};
use core::sync::atomic::Ordering;
use embassy_executor::Spawner;
use embassy_net::dns::DnsQueryType;
use embassy_net::udp::{PacketMetadata, UdpSocket};
use embassy_net::{DhcpConfig, IpAddress, Ipv4Cidr, Runner, Stack, StackResources, StaticConfigV4};
use alloc::{format, vec, vec::Vec};
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::Mutex;
use embassy_sync::once_lock::OnceLock;
use embassy_time::{Duration, Timer, WithTimeout};
use embedded_storage::nor_flash::{check_erase, NorFlash, ReadNorFlash, RmwNorFlashStorage};
use esp_bootloader_esp_idf::ota::OtaImageState::Valid;
use esp_bootloader_esp_idf::ota::{Ota, OtaImageState};
@@ -33,18 +25,34 @@ use esp_hal::rtc_cntl::{
use esp_hal::system::software_reset;
use esp_hal::uart::Uart;
use esp_hal::Blocking;
use esp_println::println;
use esp_radio::wifi::ap::{AccessPointConfig, AccessPointInfo};
use esp_radio::wifi::ap::AccessPointInfo;
use esp_radio::wifi::scan::{ScanConfig, ScanTypeConfig};
use esp_radio::wifi::sta::StationConfig;
use esp_radio::wifi::{AuthenticationMethod, Config, Interface, WifiController};
use log::{error, info, warn};
use mcutie::{
Error, McutieBuilder, McutieReceiver, McutieTask, MqttMessage, PublishDisplay, Publishable,
QoS, Topic,
};
use portable_atomic::AtomicBool;
use sntpc::{get_time, NtpContext, NtpTimestampGenerator, NtpUdpSocket};
use esp_radio::wifi::{Interface, WifiController};
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))]
static mut LAST_WATERING_TIMESTAMP: [i64; PLANT_COUNT] = [0; PLANT_COUNT];
@@ -59,51 +67,6 @@ static mut RESTART_TO_CONF: i8 = 0;
#[esp_hal::ram(unstable(rtc_fast), unstable(persistent))]
static mut LAST_CORROSION_PROTECTION_CHECK_DAY: i8 = -1;
const NTP_SERVER: &str = "pool.ntp.org";
static MQTT_CONNECTED_EVENT_RECEIVED: AtomicBool = AtomicBool::new(false);
static MQTT_ROUND_TRIP_RECEIVED: AtomicBool = AtomicBool::new(false);
pub static MQTT_STAY_ALIVE: AtomicBool = AtomicBool::new(false);
static MQTT_BASE_TOPIC: OnceLock<String> = OnceLock::new();
#[derive(Copy, Clone, Default)]
struct Timestamp {
stamp: DateTime<Utc>,
}
struct EmbassyNtpSocket<'a, 'b> {
socket: &'a UdpSocket<'b>,
}
impl<'a, 'b> EmbassyNtpSocket<'a, 'b> {
fn new(socket: &'a UdpSocket<'b>) -> Self {
Self { socket }
}
}
impl NtpUdpSocket for EmbassyNtpSocket<'_, '_> {
async fn send_to(&self, buf: &[u8], addr: SocketAddr) -> sntpc::Result<usize> {
self.socket
.send_to(buf, addr)
.await
.map_err(|_| sntpc::Error::Network)?;
Ok(buf.len())
}
async fn recv_from(&self, buf: &mut [u8]) -> sntpc::Result<(usize, SocketAddr)> {
let (len, metadata) = self
.socket
.recv_from(buf)
.await
.map_err(|_| sntpc::Error::Network)?;
let addr = match metadata.endpoint.addr {
IpAddress::Ipv4(ip) => IpAddr::V4(ip),
IpAddress::Ipv6(ip) => IpAddr::V6(ip),
};
Ok((len, SocketAddr::new(addr, metadata.endpoint.port)))
}
}
// Minimal esp-idf equivalent for gpio_hold on esp32c6 via ROM functions
extern "C" {
fn gpio_pad_hold(gpio_num: u32);
@@ -120,20 +83,6 @@ pub fn hold_disable(gpio_num: u8) {
unsafe { gpio_pad_unhold(gpio_num as u32) }
}
impl NtpTimestampGenerator for Timestamp {
fn init(&mut self) {
self.stamp = DateTime::default();
}
fn timestamp_sec(&self) -> u64 {
self.stamp.timestamp() as u64
}
fn timestamp_subsec_micros(&self) -> u32 {
self.stamp.timestamp_subsec_micros()
}
}
pub struct Esp<'a> {
pub savegame: SavegameManager,
pub rng: Rng,
@@ -165,15 +114,6 @@ pub struct Esp<'a> {
// CPU cores/threads, reconsider this.
unsafe impl Send for Esp<'_> {}
macro_rules! mk_static {
($t:ty,$val:expr) => {{
static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new();
#[deny(unused_attributes)]
let x = STATIC_CELL.uninit().write($val);
x
}};
}
impl Esp<'_> {
pub fn get_time(&self) -> DateTime<Utc> {
DateTime::from_timestamp_micros(self.rtc.current_time_us() as i64)
@@ -262,66 +202,6 @@ impl Esp<'_> {
self.boot_button.is_low()
}
pub(crate) async fn sntp(
&mut self,
_max_wait_ms: u32,
stack: Stack<'_>,
) -> FatResult<DateTime<Utc>> {
println!("start sntp");
let mut rx_meta = [PacketMetadata::EMPTY; 16];
let mut rx_buffer = [0; 4096];
let mut tx_meta = [PacketMetadata::EMPTY; 16];
let mut tx_buffer = [0; 4096];
let mut socket = UdpSocket::new(
stack,
&mut rx_meta,
&mut rx_buffer,
&mut tx_meta,
&mut tx_buffer,
);
socket.bind(123).context("Could not bind UDP socket")?;
let context = NtpContext::new(Timestamp::default());
let ntp_socket = EmbassyNtpSocket::new(&socket);
let ntp_addrs = stack
.dns_query(NTP_SERVER, DnsQueryType::A)
.await
.context("Failed to resolve DNS")?;
if ntp_addrs.is_empty() {
bail!("No IP addresses found for NTP server");
}
let ntp = ntp_addrs[0];
info!("NTP server: {ntp:?}");
let mut counter = 0;
loop {
let addr: IpAddr = ntp.into();
let timeout = get_time(SocketAddr::from((addr, 123)), &ntp_socket, context)
.with_timeout(Duration::from_millis((_max_wait_ms / 10) as u64))
.await;
match timeout {
Ok(result) => {
let time = result?;
info!("Time: {time:?}");
return DateTime::from_timestamp(time.seconds as i64, 0)
.context("Could not convert Sntp result");
}
Err(err) => {
warn!("sntp timeout, retry: {err:?}");
counter += 1;
if counter > 10 {
bail!("Failed to get time from NTP server");
}
Timer::after(Duration::from_millis(100)).await;
}
}
}
}
pub(crate) async fn wifi_scan(&mut self) -> FatResult<Vec<AccessPointInfo>> {
info!("start wifi scan");
let mut lock = self.controller.try_lock()?;
@@ -335,6 +215,25 @@ impl Esp<'_> {
Ok(rv)
}
/// Return detailed Wi-Fi scan information including signal strength
pub(crate) async fn wifi_scan_details(&mut self) -> FatResult<Vec<WifiScanDetails>> {
let ap_infos = self.wifi_scan().await?;
// Convert AccessPointInfo to WifiScanDetails
let details: Vec<WifiScanDetails> = ap_infos
.iter()
.map(|ap| WifiScanDetails {
ssid: ap.ssid.as_str().to_string(),
bssid: format_bssid(&ap.bssid),
rssi: ap.signal_strength as i32,
channel: ap.channel,
auth_method: format!("{:?}", ap.auth_method),
})
.collect();
Ok(details)
}
pub(crate) fn last_pump_time(&self, plant: usize) -> Option<DateTime<Utc>> {
let ts = unsafe { LAST_WATERING_TIMESTAMP }[plant];
DateTime::from_timestamp_millis(ts)
@@ -395,156 +294,6 @@ impl Esp<'_> {
}
}
pub(crate) async fn wifi_ap(&mut self, spawner: Spawner) -> FatResult<Stack<'static>> {
let ssid = match self.load_config().await {
Ok(config) => config.network.ap_ssid.as_str().to_string(),
Err(_) => "PlantCtrl Emergency Mode".to_string(),
};
let device = self
.interface_ap
.take()
.context("AP interface already taken")?;
let gw_ip_addr = Ipv4Addr::new(192, 168, 71, 1);
let config = embassy_net::Config::ipv4_static(StaticConfigV4 {
address: Ipv4Cidr::new(gw_ip_addr, 24),
gateway: Some(gw_ip_addr),
dns_servers: Default::default(),
});
let seed = (self.rng.random() as u64) << 32 | self.rng.random() as u64;
println!("init secondary stack");
// Init network stack
let (stack, runner) = embassy_net::new(
device,
config,
mk_static!(StackResources<4>, StackResources::<4>::new()),
seed,
);
let stack = mk_static!(Stack, stack);
let client_config =
Config::AccessPoint(AccessPointConfig::default().with_ssid(ssid.clone()));
self.controller.lock().await.set_config(&client_config)?;
println!("start net task");
spawner.spawn(net_task(runner)?);
println!("run dhcp");
spawner.spawn(run_dhcp(*stack, gw_ip_addr)?);
loop {
if stack.is_link_up() {
break;
}
Timer::after(Duration::from_millis(500)).await;
}
while !stack.is_config_up() {
Timer::after(Duration::from_millis(100)).await
}
println!("Connect to the AP `${ssid}` and point your browser to http://{gw_ip_addr}/");
stack
.config_v4()
.inspect(|c| println!("ipv4 config: {c:?}"));
Ok(*stack)
}
pub(crate) async fn wifi(
&mut self,
network_config: &NetworkConfig,
spawner: Spawner,
) -> FatResult<Stack<'static>> {
esp_radio::wifi_set_log_verbose();
let ssid = match &network_config.ssid {
Some(ssid) => {
if ssid.is_empty() {
bail!("Wifi ssid was empty")
}
ssid.to_string()
}
None => {
bail!("Wifi ssid was empty")
}
};
info!("attempting to connect wifi {ssid}");
let password = match network_config.password {
Some(ref password) => password.to_string(),
None => "".to_string(),
};
let max_wait = network_config.max_wait;
let device = self
.interface_sta
.take()
.context("STA interface already taken")?;
let config = embassy_net::Config::dhcpv4(DhcpConfig::default());
let seed = (self.rng.random() as u64) << 32 | self.rng.random() as u64;
// Init network stack
let (stack, runner) = embassy_net::new(
device,
config,
mk_static!(StackResources<8>, StackResources::<8>::new()),
seed,
);
let stack = mk_static!(Stack, stack);
let auth_method = if password.is_empty() {
AuthenticationMethod::None
} else {
AuthenticationMethod::Wpa2Personal
};
let client_config = StationConfig::default()
.with_ssid(ssid)
.with_auth_method(auth_method)
.with_password(password);
self.controller
.lock()
.await
.set_config(&Config::Station(client_config))?;
spawner.spawn(net_task(runner)?);
self.controller
.lock()
.await
.connect_async()
.with_timeout(Duration::from_millis(max_wait as u64 * 1000))
.await
.context("Timeout waiting for wifi sta connected")??;
let res = async {
while !stack.is_link_up() {
Timer::after(Duration::from_millis(500)).await;
}
Ok::<(), FatError>(())
}
.with_timeout(Duration::from_millis(max_wait as u64 * 1000))
.await;
if res.is_err() {
bail!("Timeout waiting for wifi link up")
}
let res = async {
while !stack.is_config_up() {
Timer::after(Duration::from_millis(100)).await
}
Ok::<(), FatError>(())
}
.with_timeout(Duration::from_millis(max_wait as u64 * 1000))
.await;
if res.is_err() {
bail!("Timeout waiting for wifi config up")
}
info!("Connected WIFI, dhcp: {:?}", stack.config_v4());
Ok(*stack)
}
pub fn deep_sleep(&mut self, duration_in_ms: u64) -> ! {
// Mark the current OTA image as valid if we reached here while in pending verify.
if let Ok(cur) = self.ota.current_ota_state() {
@@ -661,279 +410,12 @@ impl Esp<'_> {
for (i, item) in CONSECUTIVE_WATERING_PLANT.iter().enumerate() {
info!("CONSECUTIVE_WATERING_PLANT[{i}] = {item}");
}
}
}
}
pub(crate) async fn mqtt(
&mut self,
network_config: &'static NetworkConfig,
stack: Stack<'static>,
spawner: Spawner,
) -> FatResult<()> {
let base_topic = network_config
.base_topic
.as_ref()
.context("missing base topic")?;
if base_topic.is_empty() {
bail!("Mqtt base_topic was empty")
}
MQTT_BASE_TOPIC
.init(base_topic.to_string())
.map_err(|_| FatError::String {
error: "Error setting basetopic".to_string(),
})?;
let mqtt_url = network_config
.mqtt_url
.as_ref()
.context("missing mqtt url")?;
if mqtt_url.is_empty() {
bail!("Mqtt url was empty")
}
let last_will_topic = format!("{base_topic}/state");
let round_trip_topic = format!("{base_topic}/internal/roundtrip");
let stay_alive_topic = format!("{base_topic}/stay_alive");
let mut builder: McutieBuilder<'_, String, PublishDisplay<String, &str>, 0> =
McutieBuilder::new(stack, "plant ctrl", mqtt_url);
if let (Some(mqtt_user), Some(mqtt_password)) = (
network_config.mqtt_user.as_ref(),
network_config.mqtt_password.as_ref(),
) {
builder = builder.with_authentication(mqtt_user, mqtt_password);
info!("With authentification");
}
let lwt = Topic::General(last_will_topic);
let lwt = mk_static!(Topic<String>, lwt);
let lwt = lwt.with_display("lost").retain(true).qos(QoS::AtLeastOnce);
builder = builder.with_last_will(lwt);
//TODO make configurable
builder = builder.with_device_id("plantctrl");
let builder: McutieBuilder<'_, String, PublishDisplay<String, &str>, 2> = builder
.with_subscriptions([
Topic::General(round_trip_topic.clone()),
Topic::General(stay_alive_topic.clone()),
]);
let keep_alive = Duration::from_secs(60 * 60 * 2).as_secs() as u16;
let (receiver, task) = builder.build(keep_alive);
spawner.spawn(mqtt_incoming_task(
receiver,
round_trip_topic.clone(),
stay_alive_topic.clone(),
)?);
spawner.spawn(mqtt_runner(task)?);
log(LogMessage::StayAlive, 0, 0, "", &stay_alive_topic);
log(LogMessage::MqttInfo, 0, 0, "", mqtt_url);
let mqtt_timeout = 15000;
let res = async {
while !MQTT_CONNECTED_EVENT_RECEIVED.load(Ordering::Relaxed) {
crate::hal::PlantHal::feed_watchdog();
Timer::after(Duration::from_millis(100)).await;
}
Ok::<(), FatError>(())
}
.with_timeout(Duration::from_millis(mqtt_timeout as u64))
.await;
if res.is_err() {
bail!("Timeout waiting MQTT connect event")
}
let _ = Topic::General(round_trip_topic.clone())
.with_display("online_text")
.publish()
.await;
let res = async {
while !MQTT_ROUND_TRIP_RECEIVED.load(Ordering::Relaxed) {
crate::hal::PlantHal::feed_watchdog();
Timer::after(Duration::from_millis(100)).await;
}
Ok::<(), FatError>(())
}
.with_timeout(Duration::from_millis(mqtt_timeout as u64))
.await;
if res.is_err() {
//ensure we do not further try to publish
MQTT_CONNECTED_EVENT_RECEIVED.store(false, Ordering::Relaxed);
bail!("Timeout waiting MQTT roundtrip")
}
Ok(())
}
pub(crate) async fn mqtt_inner(&mut self, subtopic: &str, message: &str) -> FatResult<()> {
if !subtopic.starts_with("/") {
bail!("Subtopic without / at start {}", subtopic);
}
if subtopic.len() > 192 {
bail!("Subtopic exceeds 192 chars {}", subtopic);
}
let base_topic = MQTT_BASE_TOPIC
.try_get()
.context("missing base topic in static!")?;
let full_topic = format!("{base_topic}{subtopic}");
loop {
let result = Topic::General(full_topic.as_str())
.with_display(message)
.retain(true)
.publish()
.await;
match result {
Ok(()) => return Ok(()),
Err(err) => {
let retry = match err {
Error::IOError => false,
Error::TimedOut => true,
Error::TooLarge => false,
Error::PacketError => false,
Error::Invalid => false,
Error::Rejected => false,
};
if !retry {
bail!(
"Error during mqtt send on topic {} with message {:#?} error is {:?}",
&full_topic,
message,
err
);
}
info!(
"Retransmit for {} with message {:#?} error is {:?} retrying {}",
&full_topic, message, err, retry
);
Timer::after(Duration::from_millis(100)).await;
// is executed before main, no other code will alter these values during printing
#[allow(static_mut_refs)]
for (i, item) in LAST_FERTILIZER_TIMESTAMP.iter().enumerate() {
info!("LAST_FERTILIZER_TIMESTAMP[{i}] = {item}");
}
}
}
}
pub(crate) async fn mqtt_publish(&mut self, subtopic: &str, message: &str) {
let online = MQTT_CONNECTED_EVENT_RECEIVED.load(Ordering::Relaxed);
if !online {
return;
}
let roundtrip_ok = MQTT_ROUND_TRIP_RECEIVED.load(Ordering::Relaxed);
if !roundtrip_ok {
info!("MQTT roundtrip not received yet, dropping message");
return;
}
match self.mqtt_inner(subtopic, message).await {
Ok(()) => {}
Err(err) => {
info!(
"Error during mqtt send on topic {subtopic} with message {message:#?} error is {err:?}"
);
}
};
}
}
#[embassy_executor::task]
async fn mqtt_runner(
task: McutieTask<'static, String, PublishDisplay<'static, String, &'static str>, 2>,
) {
task.run().await;
}
#[embassy_executor::task]
async fn mqtt_incoming_task(
receiver: McutieReceiver,
round_trip_topic: String,
stay_alive_topic: String,
) {
loop {
let message = receiver.receive().await;
match message {
MqttMessage::Connected => {
info!("Mqtt connected");
MQTT_CONNECTED_EVENT_RECEIVED.store(true, Ordering::Relaxed);
}
MqttMessage::Publish(topic, payload) => match topic {
Topic::DeviceType(_type_topic) => {}
Topic::Device(_device_topic) => {}
Topic::General(topic) => {
let subtopic = topic.as_str();
if subtopic.eq(round_trip_topic.as_str()) {
MQTT_ROUND_TRIP_RECEIVED.store(true, Ordering::Relaxed);
} else if subtopic.eq(stay_alive_topic.as_str()) {
let value = payload.eq_ignore_ascii_case("true".as_ref())
|| payload.eq_ignore_ascii_case("1".as_ref());
let a = match value {
true => 1,
false => 0,
};
log(LogMessage::MqttStayAliveRec, a, 0, "", "");
MQTT_STAY_ALIVE.store(value, Ordering::Relaxed);
} else {
log(LogMessage::UnknownTopic, 0, 0, "", &topic);
}
}
},
MqttMessage::Disconnected => {
MQTT_CONNECTED_EVENT_RECEIVED.store(false, Ordering::Relaxed);
info!("Mqtt disconnected");
}
}
}
}
#[embassy_executor::task(pool_size = 2)]
async fn net_task(mut runner: Runner<'static, Interface<'static>>) {
runner.run().await;
}
#[embassy_executor::task]
async fn run_dhcp(stack: Stack<'static>, ip: Ipv4Addr) {
use core::net::SocketAddrV4;
use edge_dhcp::{
io::{self, DEFAULT_SERVER_PORT},
server::{Server, ServerOptions},
};
use edge_nal::UdpBind;
use edge_nal_embassy::{Udp, UdpBuffers};
let mut buf = [0u8; 1500];
let mut gw_buf = [Ipv4Addr::UNSPECIFIED];
let buffers = UdpBuffers::<3, 1024, 1024, 10>::new();
let unbound_socket = Udp::new(stack, &buffers);
let mut bound_socket = match unbound_socket
.bind(SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::UNSPECIFIED,
DEFAULT_SERVER_PORT,
)))
.await
{
Ok(s) => s,
Err(e) => {
error!("dhcp task failed to bind socket: {:?}", e);
return;
}
};
loop {
_ = io::server::run(
&mut Server::<_, 64>::new_with_et(ip),
&ServerOptions::new(ip, Some(&mut gw_buf)),
&mut bound_socket,
&mut buf,
)
.await
.inspect_err(|e| warn!("DHCP server error: {e:?}"));
Timer::after(Duration::from_millis(500)).await;
}
}

View File

@@ -141,7 +141,7 @@ pub struct HAL<'a> {
#[async_trait(?Send)]
pub trait BoardInteraction<'a> {
fn get_tank_sensor(&mut self) -> Result<&mut TankSensor<'a>, FatError>;
fn get_tank_sensor(&mut self) -> &mut TankSensor<'a>;
fn get_esp(&mut self) -> &mut Esp<'a>;
fn get_config(&mut self) -> &PlantControllerConfig;
fn get_battery_monitor(&mut self) -> &mut Box<dyn BatteryInteraction + Send>;
@@ -241,14 +241,7 @@ pub struct FreePeripherals<'a> {
pub adc1: ADC1<'a>,
}
macro_rules! mk_static {
($t:ty,$val:expr) => {{
static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new();
#[deny(unused_attributes)]
let x = STATIC_CELL.uninit().write($val);
x
}};
}
use crate::util::mk_static;
impl PlantHal {
pub async fn create() -> Result<Mutex<CriticalSectionRawMutex, HAL<'static>>, FatError> {
@@ -290,7 +283,8 @@ impl PlantHal {
error: format!("Could not init wifi: {:?}", e),
})?;
let pcnt_module = Pcnt::new(peripherals.PCNT);
let mut pcnt_module = Pcnt::new(peripherals.PCNT);
pcnt_module.set_interrupt_handler(water::flow_interrupt_handler);
let free_pins = FreePeripherals {
gpio0: peripherals.GPIO0,

View File

@@ -161,9 +161,11 @@ pub(crate) async fn create_v4(
info!("Start v4");
let mut awake = Output::new(peripherals.gpio21, Level::High, OutputConfig::default());
awake.set_high();
info!("v4: gpio21 awake ok");
let mut general_fault = Output::new(peripherals.gpio23, Level::Low, OutputConfig::default());
general_fault.set_low();
info!("v4: gpio23 general_fault ok");
let twai_config = Some(TwaiConfiguration::new(
peripherals.twai,
@@ -172,17 +174,24 @@ pub(crate) async fn create_v4(
TWAI_BAUDRATE,
TwaiMode::Normal,
));
info!("v4: twai config ok");
let extra1 = Output::new(peripherals.gpio6, Level::Low, OutputConfig::default());
info!("v4: gpio6 extra1 ok");
let extra2 = Output::new(peripherals.gpio15, Level::Low, OutputConfig::default());
info!("v4: gpio15 extra2 ok");
let one_wire_pin = Flex::new(peripherals.gpio18);
info!("v4: gpio18 one_wire ok");
let tank_power_pin = Output::new(peripherals.gpio11, Level::Low, OutputConfig::default());
info!("v4: gpio11 tank_power ok");
let flow_sensor_pin = Input::new(
peripherals.gpio4,
InputConfig::default().with_pull(Pull::Up),
);
info!("v4: gpio4 flow_sensor ok");
info!("v4: creating tank sensor");
let tank_sensor = TankSensor::create(
one_wire_pin,
peripherals.adc1,
@@ -191,12 +200,17 @@ pub(crate) async fn create_v4(
flow_sensor_pin,
peripherals.pcnt1,
)?;
info!("v4: tank sensor ok");
let can_power = Output::new(peripherals.gpio22, Level::Low, OutputConfig::default());
info!("v4: gpio22 can_power ok");
let solar_is_day = Input::new(peripherals.gpio7, InputConfig::default());
info!("v4: gpio7 solar_is_day ok");
let light = Output::new(peripherals.gpio10, Level::Low, Default::default());
info!("v4: gpio10 light ok");
let charge_indicator = Output::new(peripherals.gpio3, Level::Low, Default::default());
info!("v4: gpio3 charge_indicator ok");
info!("Start pump expander");
let pump_device = I2cDevice::new(I2C_DRIVER.get().await);
@@ -286,8 +300,8 @@ pub(crate) async fn create_v4(
#[async_trait(?Send)]
impl<'a> BoardInteraction<'a> for V4<'a> {
fn get_tank_sensor(&mut self) -> Result<&mut TankSensor<'a>, FatError> {
Ok(&mut self.tank_sensor)
fn get_tank_sensor(&mut self) -> &mut TankSensor<'a> {
&mut self.tank_sensor
}
fn get_esp(&mut self) -> &mut Esp<'a> {

View File

@@ -10,17 +10,20 @@ use esp_hal::pcnt::channel::EdgeMode::{Hold, Increment};
use esp_hal::pcnt::unit::Unit;
use esp_hal::peripherals::GPIO5;
use esp_hal::Async;
use esp_println::println;
use log::{error, info};
use onewire::{ds18b20, Device, DeviceSearch, OneWire, DS18B20};
use portable_atomic::{AtomicUsize, Ordering};
unsafe impl Send for TankSensor<'_> {}
static FLOW_OVERFLOW_COUNTER: AtomicUsize = AtomicUsize::new(0);
pub struct TankSensor<'a> {
one_wire_bus: OneWire<Flex<'a>>,
tank_channel: Adc<'a, ADC1<'a>, Async>,
tank_power: Output<'a>,
tank_pin: AdcPin<GPIO5<'a>, ADC1<'a>, AdcCalLine<ADC1<'a>>>,
flow_counter: Unit<'a, 1>,
flow_unit: Unit<'static, 1>,
}
impl<'a> TankSensor<'a> {
@@ -30,7 +33,7 @@ impl<'a> TankSensor<'a> {
gpio5: GPIO5<'a>,
tank_power: Output<'a>,
flow_sensor: Input,
pcnt1: Unit<'a, 1>,
pcnt1: Unit<'static, 1>,
) -> Result<TankSensor<'a>, FatError> {
one_wire_pin.apply_output_config(
&OutputConfig::default()
@@ -41,47 +44,81 @@ impl<'a> TankSensor<'a> {
one_wire_pin.set_high();
one_wire_pin.set_input_enable(true);
one_wire_pin.set_output_enable(true);
info!("tank: one_wire pin config ok");
let mut adc1_config = AdcConfig::new();
info!("tank: adc config created");
let tank_pin =
adc1_config.enable_pin_with_cal::<_, AdcCalLine<_>>(gpio5, Attenuation::_11dB);
info!("tank: adc pin cal ok");
let tank_channel = Adc::new(adc1, adc1_config).into_async();
info!("tank: adc channel ok");
let one_wire_bus = OneWire::new(one_wire_pin, false);
info!("tank: one_wire bus ok");
pcnt1.set_high_limit(Some(i16::MAX))?;
info!("tank: pcnt high limit ok");
// Reject pulses shorter than ~12.8 µs (1023 APB cycles @ 80 MHz) to suppress EMI noise
// on the sensor cable. Real flow pulses are in the millisecond range.
match pcnt1.set_filter(Some(1023)) {
Ok(_) => {}
Err(err) => {
error!("tank: failed to set pcnt filter: {:?}", err);
}
}
let ch0 = &pcnt1.channel0;
ch0.set_edge_signal(flow_sensor.peripheral_input());
info!("tank: pcnt edge signal ok");
ch0.set_input_mode(Hold, Increment);
ch0.set_ctrl_mode(Keep, Keep);
info!("tank: pcnt input/ctrl mode ok");
pcnt1.listen();
info!("tank: pcnt listen ok");
Ok(TankSensor {
one_wire_bus,
tank_channel,
tank_power,
tank_pin,
flow_counter: pcnt1,
flow_unit: pcnt1,
})
}
pub fn reset_flow_meter(&mut self) {
self.flow_counter.pause();
self.flow_counter.clear();
// Pause, clear counter, clear any pending interrupt, then reset the overflow counter
// all inside a single critical section to prevent a race where the interrupt fires
// between the overflow reset and the pause.
critical_section::with(|_| {
self.flow_unit.pause();
self.flow_unit.clear();
self.flow_unit.reset_interrupt();
FLOW_OVERFLOW_COUNTER.store(0, Ordering::SeqCst);
});
}
pub fn start_flow_meter(&mut self) {
self.flow_counter.resume();
}
pub fn get_flow_meter_value(&mut self) -> i16 {
self.flow_counter.value()
self.flow_unit.resume();
}
pub fn stop_flow_meter(&mut self) -> i16 {
self.flow_counter.pause();
self.get_flow_meter_value()
critical_section::with(|_| {
let val = self.flow_unit.value();
self.flow_unit.pause();
val
})
}
pub fn get_full_flow_count(&self) -> u32 {
// Read both values inside a single critical section so an overflow interrupt cannot
// fire between the two reads and produce an inconsistent result.
critical_section::with(|_| {
let overflowed = FLOW_OVERFLOW_COUNTER.load(Ordering::SeqCst) as u32;
let current = self.flow_unit.value() as u32;
overflowed * (i16::MAX as u32 + 1) + current
})
}
pub async fn water_temperature_c(&mut self) -> Result<f32, FatError> {
@@ -90,9 +127,9 @@ impl<'a> TankSensor<'a> {
let mut delay = Delay::new();
let presence = self.one_wire_bus.reset(&mut delay)?;
println!("OneWire: reset presence pulse = {}", presence);
info!("OneWire: reset presence pulse = {}", presence);
if !presence {
println!("OneWire: no device responded to reset — check pull-up resistor and wiring");
info!("OneWire: no device responded to reset — check pull-up resistor and wiring");
}
let mut search = DeviceSearch::new();
@@ -100,7 +137,7 @@ impl<'a> TankSensor<'a> {
let mut devices_found = 0u8;
while let Some(device) = self.one_wire_bus.search_next(&mut search, &mut delay)? {
devices_found += 1;
println!(
info!(
"OneWire: found device #{} family=0x{:02X} addr={:02X?}",
devices_found, device.address[0], device.address
);
@@ -108,16 +145,20 @@ impl<'a> TankSensor<'a> {
water_temp_sensor = Some(device);
break;
} else {
println!("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 {
println!("OneWire: search found zero devices on the bus");
info!("OneWire: search found zero devices on the bus");
}
match water_temp_sensor {
Some(device) => {
println!("Found one wire device: {:?}", device);
info!("Found one wire device: {:?}", device);
let mut water_temp_sensor = DS18B20::new(device)?;
let water_temp: Result<f32, FatError> = loop {
@@ -126,11 +167,11 @@ impl<'a> TankSensor<'a> {
.await;
match &temp {
Ok(res) => {
println!("Water temp is {}", res);
info!("Water temp is {}", res);
break temp;
}
Err(err) => {
println!("Could not get water temp {} attempt {}", err, attempt)
info!("Could not get water temp {} attempt {}", err, attempt)
}
}
if attempt == 5 {
@@ -178,3 +219,15 @@ impl<'a> TankSensor<'a> {
Ok(median_mv / 1000.0)
}
}
#[esp_hal::handler]
pub fn flow_interrupt_handler() {
use esp_hal::peripherals::PCNT;
let pcnt = PCNT::regs();
if pcnt.int_raw().read().cnt_thr_event_u(1).bit() {
if pcnt.u_status(1).read().h_lim().bit() {
FLOW_OVERFLOW_COUNTER.fetch_add(1, Ordering::SeqCst);
}
pcnt.int_clr().write(|w| w.cnt_thr_event_u(1).set_bit());
}
}

View File

@@ -4,7 +4,7 @@ use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::blocking_mutex::Mutex as BlockingMutex;
use log::{LevelFilter, Log, Metadata, Record};
const MAX_LIVE_LOG_ENTRIES: usize = 64;
const MAX_LIVE_LOG_ENTRIES: usize = 128;
struct LiveLogBuffer {
entries: Vec<(u64, String)>,
@@ -32,7 +32,8 @@ impl LiveLogBuffer {
match after {
None => (self.entries.clone(), false, next_seq),
Some(after_seq) => {
let result: Vec<_> = self.entries
let result: Vec<_> = self
.entries
.iter()
.filter(|(seq, _)| *seq > after_seq)
.cloned()

View File

@@ -14,24 +14,24 @@
esp_bootloader_esp_idf::esp_app_desc!();
use esp_backtrace as _;
use crate::config::{NetworkConfig, PlantConfig, PlantControllerConfig};
use crate::fat_error::FatResult;
use crate::hal::esp::MQTT_STAY_ALIVE;
use crate::config::{PlantConfig, PlantControllerConfig};
use crate::fat_error::{ContextExt, FatResult};
use crate::hal::PROGRESS_ACTIVE;
use crate::log::log;
use crate::tank::{determine_tank_state, TankError, TankState, WATER_FROZEN_THRESH};
use crate::tank::{determine_tank_state, TankError, WATER_FROZEN_THRESH};
use crate::webserver::http_server;
use crate::{
config::BoardVersion::Initial,
hal::{PlantHal, HAL, PLANT_COUNT},
};
use ::log::{error, info, warn};
use ::log::{error, info};
use alloc::borrow::ToOwned;
use alloc::string::{String, ToString};
use alloc::sync::Arc;
use alloc::vec::Vec;
use alloc::{format, vec};
use chrono::{DateTime, Datelike, Timelike, Utc};
use chrono::{DateTime, Datelike, Timelike};
use chrono_tz::Tz::{self, UTC};
use core::sync::atomic::{AtomicBool, Ordering};
use embassy_executor::Spawner;
@@ -67,8 +67,11 @@ mod config;
mod fat_error;
mod hal;
mod log;
mod mqtt;
mod network;
mod plant_state;
mod tank;
mod util;
mod webserver;
extern crate alloc;
@@ -83,12 +86,6 @@ enum WaitType {
MqttConfig,
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct Solar {
current_ma: u32,
voltage_ma: u32,
}
impl WaitType {
fn blink_pattern(&self) -> u64 {
match self {
@@ -114,17 +111,6 @@ struct LightState {
is_day: bool,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
///mqtt struct to track pump activities
struct PumpInfo {
enabled: bool,
pump_ineffective: bool,
median_current_ma: u16,
max_current_ma: u16,
min_current_ma: u16,
error: String,
}
#[derive(Serialize)]
pub struct PumpResult {
median_current_ma: u16,
@@ -132,27 +118,11 @@ pub struct PumpResult {
min_current_ma: u16,
error: String,
flow_value_ml: f32,
flow_value_count: i16,
flow_value_count: u32,
pump_time_s: u16,
overcurrent_ma: Option<u16>,
}
#[derive(Serialize, Debug, PartialEq)]
enum SntpMode {
Offline,
Sync { current: DateTime<Utc> },
}
#[derive(Serialize, Debug, PartialEq)]
enum NetworkMode {
Wifi {
sntp: SntpMode,
mqtt: bool,
ip_address: String,
},
Offline,
}
async fn safe_main(spawner: Spawner) -> FatResult<()> {
info!("Startup Rust");
@@ -235,31 +205,55 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
{
info!("No wifi configured, starting initial config mode");
let stack = board.board_hal.get_esp().wifi_ap(spawner).await?;
let esp = board.board_hal.get_esp();
let ssid = esp
.load_config()
.await
.map(|config| config.network.ap_ssid.to_string())
.unwrap_or_else(|_| String::from("PlantCtrl Emergency Mode"));
let device = esp
.interface_ap
.take()
.context("AP interface already taken")?;
let stack = network::wifi_ap(ssid, device, &esp.controller, &mut esp.rng, spawner).await?;
let reboot_now = Arc::new(AtomicBool::new(false));
println!("starting webserver");
let _ = http_server(reboot_now.clone(), stack);
spawner.spawn(http_server(reboot_now.clone(), stack)?);
wait_infinity(board, WaitType::MissingConfig, reboot_now.clone(), UTC).await;
}
let mut stack: OptionLock<Stack> = OptionLock::empty();
let network_mode = if board.board_hal.get_config().network.ssid.is_some() {
try_connect_wifi_sntp_mqtt(&mut board, &mut stack, spawner).await
network::try_connect_wifi_sntp_mqtt(&mut board, &mut stack, spawner).await
} else {
info!("No wifi configured");
//the current sensors require this amount to stabilize, in the case of Wi-Fi this is already handled due to connect timings;
Timer::after_millis(100).await;
NetworkMode::Offline
network::NetworkMode::OFFLINE
};
if matches!(network_mode, NetworkMode::Offline) && to_config {
if matches!(network_mode, network::NetworkMode::OFFLINE) && to_config {
info!("Could not connect to station and config mode forced, switching to ap mode!");
let res = {
let esp = board.board_hal.get_esp();
esp.wifi_ap(spawner).await
let ssid = esp
.load_config()
.await
.map(|config| config.network.ap_ssid.to_string())
.unwrap_or_else(|_| String::from("PlantCtrl Emergency Mode"));
let device = match esp.interface_ap.take() {
Some(d) => d,
None => {
use crate::fat_error::FatError;
return Err(FatError::String {
error: "AP interface already taken".to_string(),
});
}
};
network::wifi_ap(ssid, device, &esp.controller, &mut esp.rng, spawner).await
};
match res {
Ok(ap_stack) => {
@@ -287,25 +281,28 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
timezone_time
);
if let NetworkMode::Wifi { ref ip_address, .. } = network_mode {
publish_firmware_info(&mut board, version, ip_address, &timezone_time.to_rfc3339()).await;
publish_battery_state(&mut board).await.unwrap_or_else(|e| {
error!("Error publishing battery state {e}");
});
let _ = publish_mppt_state(&mut board).await;
if let network::NetworkMode::WIFI { ref ip_address, .. } = network_mode {
mqtt::publish_firmware_info(version, ip_address, &timezone_time.to_rfc3339()).await;
mqtt::publish_battery_state(&mut board)
.await
.unwrap_or_else(|e| {
error!("Error publishing battery state {e}");
});
let _ = mqtt::publish_mppt_state(&mut board).await;
let _ = mqtt::publish_wifi_scan(&mut board).await;
}
log(
LogMessage::StartupInfo,
matches!(network_mode, NetworkMode::Wifi { .. }) as u32,
matches!(network_mode, network::NetworkMode::WIFI { .. }) as u32,
matches!(
network_mode,
NetworkMode::Wifi {
sntp: SntpMode::Sync { .. },
network::NetworkMode::WIFI {
sntp: network::SntpMode::SYNC { .. },
..
}
) as u32,
matches!(network_mode, NetworkMode::Wifi { mqtt: true, .. })
matches!(network_mode, network::NetworkMode::WIFI { mqtt: true, .. })
.to_string()
.as_str(),
"",
@@ -327,7 +324,9 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
log(LogMessage::NormalRun, 0, 0, "", "");
}
let dry_run = MQTT_STAY_ALIVE.load(Ordering::Relaxed);
// if stay alive is true then the hardware will determine state and pretend to do all actions with logging
// this is to help debug what the hardware would do with the current settings applied
let dry_run = mqtt::is_stay_alive();
let tank_state = determine_tank_state(&mut board).await;
@@ -335,7 +334,9 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
if let Some(err) = tank_state.got_error(&board.board_hal.get_config().tank) {
match err {
TankError::SensorDisabled => { /* unreachable */ }
TankError::SensorMissing(raw_value_mv) => log(
TankError::SensorMissing {
raw_mv: raw_value_mv,
} => log(
LogMessage::TankSensorMissing,
raw_value_mv as u32,
0,
@@ -349,8 +350,8 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
&format!("{value}"),
"",
),
TankError::BoardError(err) => {
log(LogMessage::TankSensorBoardError, 0, 0, "", &err.to_string())
TankError::BoardError { message: err } => {
log(LogMessage::TankSensorBoardError, 0, 0, "", &err)
}
}
// disabled cannot trigger this because of wrapping if is_enabled
@@ -365,10 +366,11 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
}
let mut water_frozen = false;
let water_temp: FatResult<f32> = match board.board_hal.get_tank_sensor() {
Ok(sensor) => sensor.water_temperature_c().await,
Err(e) => Err(e),
};
let water_temp: FatResult<f32> = board
.board_hal
.get_tank_sensor()
.water_temperature_c()
.await;
if let Ok(res) = water_temp {
if res < WATER_FROZEN_THRESH {
@@ -377,7 +379,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
}
info!("Water temp is {}", water_temp.as_ref().unwrap_or(&0.));
publish_tank_state(&mut board, &tank_state, water_temp)
mqtt::publish_tank_state(&mut board, &tank_state, water_temp)
.await
.unwrap_or_else(|e| {
error!("Error publishing tank state {e}");
@@ -396,7 +398,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
PlantState::interpret_raw_values(moisture, 7, &mut board).await,
];
publish_plant_states(&mut board, &timezone_time.clone(), &plantstate)
mqtt::publish_plant_states(&mut board, &timezone_time.clone(), &plantstate)
.await
.unwrap_or_else(|e| {
error!("Error publishing plant states {e}");
@@ -447,8 +449,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
board.board_hal.get_esp().last_pump_time(plant_id);
//state.active = true;
pump_info(
&mut board,
mqtt::pump_info(
plant_id,
true,
pump_ineffective,
@@ -456,6 +457,8 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
0,
0,
String::new(),
0,
0.0,
)
.await;
@@ -463,8 +466,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
match result {
Ok(state) => {
overcurrent_results[plant_id] = state.overcurrent_ma;
pump_info(
&mut board,
mqtt::pump_info(
plant_id,
false,
pump_ineffective,
@@ -472,12 +474,13 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
state.max_current_ma,
state.min_current_ma,
state.error,
state.flow_value_count,
state.flow_value_ml,
)
.await;
}
Err(err) => {
pump_info(
&mut board,
mqtt::pump_info(
plant_id,
false,
pump_ineffective,
@@ -485,6 +488,8 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
0,
0,
format!("{err:?}"),
0,
0.0,
)
.await;
}
@@ -505,7 +510,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
plantstate[plant_id].pump.overcurrent_error = Some(current_ma);
}
}
publish_plant_states(&mut board, &timezone_time.clone(), &plantstate)
mqtt::publish_plant_states(&mut board, &timezone_time.clone(), &plantstate)
.await
.unwrap_or_else(|e| {
error!("Error publishing plant states after pumping {e}");
@@ -586,16 +591,20 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
board.board_hal.get_config().night_lamp.night_lamp_hour_end,
);
match battery_state {
match &battery_state {
BatteryState::Unknown => {
light_state.battery_low = false;
}
BatteryState::Info(data) => {
if data.state_of_charge < board.board_hal.get_config().night_lamp.low_soc_cutoff {
if data.soc_pct.is_some_and(|soc| {
soc < board.board_hal.get_config().night_lamp.low_soc_cutoff as f32
}) {
board.board_hal.get_esp().set_low_voltage_in_cycle();
info!("Set low voltage in cycle");
}
if data.state_of_charge > board.board_hal.get_config().night_lamp.low_soc_restore {
if data.soc_pct.is_some_and(|soc| {
soc > board.board_hal.get_config().night_lamp.low_soc_restore as f32
}) {
board.board_hal.get_esp().clear_low_voltage_in_cycle();
info!("Clear low voltage in cycle");
}
@@ -635,11 +644,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
match &serde_json::to_string(&light_state) {
Ok(state) => {
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/light", state)
.await;
let _ = mqtt::publish("/light", state).await;
}
Err(err) => {
info!("Error publishing lightstate {err}");
@@ -648,40 +653,25 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
let deep_sleep_duration_minutes: u32 =
// if battery soc is unknown assume battery has enough change
if matches!(battery_state, BatteryState::Info(data) if data.state_of_charge < 10) {
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/deepsleep", "low Volt 12h").await;
if matches!(battery_state, BatteryState::Info(data) if data.soc_pct.is_some_and(|soc| soc < 10.)) {
let _ = mqtt::publish("/deepsleep", "low Volt 12h").await;
12 * 60
} else if is_day {
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/deepsleep", "normal 20m").await;
let _ = mqtt::publish("/deepsleep", "normal 20m").await;
20
} else {
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/deepsleep", "night 1h").await;
let _ = mqtt::publish("/deepsleep", "night 1h").await;
60
};
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/state", "sleep")
.await;
info!("Go to sleep for {deep_sleep_duration_minutes} minutes");
let _ = mqtt::publish("/state", "sleep").await;
//determine next event
//is light out of work trigger soon?
//is battery low ??
//is deep sleep
let stay_alive = MQTT_STAY_ALIVE.load(Ordering::Relaxed);
info!("Check stay alive, current state is {stay_alive}");
let stay_alive = mqtt::is_stay_alive();
info!("Check stay alive, current state is {}", stay_alive);
if stay_alive {
let reboot_now = Arc::new(AtomicBool::new(false));
@@ -722,7 +712,7 @@ pub async fn do_secure_pump(
let steps_in_50ms = plant_config.pump_time_s as usize * 20;
let mut current_collector = vec![0_u16; steps_in_50ms];
let mut flow_collector = vec![0_i16; steps_in_50ms];
let mut flow_collector = vec![0_u32; steps_in_50ms];
let mut error = String::new();
let mut first_error = true;
let mut pump_time_ms: u32 = 0;
@@ -733,8 +723,9 @@ pub async fn do_secure_pump(
if plant_config.fertilizer_s > 0 {
let current_time = board.board_hal.get_time().await;
let last_fertilizer = board.board_hal.get_esp().last_fertilizer_time(plant_id);
let elapsed_minutes = (current_time.timestamp() - last_fertilizer) / 60;
// Convert last_fertilizer from milliseconds to seconds for correct subtraction
let elapsed_minutes = ((current_time.timestamp_millis() - last_fertilizer) / 1000) / 60;
info!("Fertilizer pump cooldown check - Current time: {}, Last fertilizer: {}, Elapsed minutes: {}", current_time, last_fertilizer, elapsed_minutes);
if elapsed_minutes >= plant_config.fertilizer_cooldown_min as i64 {
info!(
"Starting fertilizer pump for {} seconds (last fertilizer was {} minutes ago)",
@@ -747,6 +738,7 @@ pub async fn do_secure_pump(
&elapsed_minutes.to_string(),
"",
);
info!("Fertilizer pump applied - Current time: {}, Last fertilizer: {}, Elapsed minutes: {}", current_time, last_fertilizer, elapsed_minutes);
board.board_hal.fertilizer_pump(true).await?;
Timer::after_millis(plant_config.fertilizer_s as u64 * 1000).await;
board.board_hal.fertilizer_pump(false).await?;
@@ -767,13 +759,13 @@ pub async fn do_secure_pump(
}
}
board.board_hal.get_tank_sensor()?.reset_flow_meter();
board.board_hal.get_tank_sensor()?.start_flow_meter();
board.board_hal.get_tank_sensor().reset_flow_meter();
board.board_hal.get_tank_sensor().start_flow_meter();
board.board_hal.pump(plant_id, true).await?;
for step in 0..steps_in_50ms {
let step_start = Instant::now();
let flow_value = board.board_hal.get_tank_sensor()?.get_flow_meter_value();
let flow_value = board.board_hal.get_tank_sensor().get_full_flow_count();
flow_collector[step] = flow_value;
let flow_value_ml = flow_value as f32 * board.board_hal.get_config().tank.ml_per_pulse;
@@ -884,8 +876,8 @@ pub async fn do_secure_pump(
//noticable dummy value
pump_time_ms = 1337;
}
board.board_hal.get_tank_sensor()?.stop_flow_meter();
let final_flow_value = board.board_hal.get_tank_sensor()?.get_flow_meter_value();
board.board_hal.get_tank_sensor().stop_flow_meter();
let final_flow_value = board.board_hal.get_tank_sensor().get_full_flow_count();
let flow_value_ml = final_flow_value as f32 * board.board_hal.get_config().tank.ml_per_pulse;
info!("Final flow value is {final_flow_value} with {flow_value_ml} ml");
current_collector.sort();
@@ -914,209 +906,6 @@ async fn update_charge_indicator(
Ok(())
}
async fn publish_tank_state(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
tank_state: &TankState,
water_temp: FatResult<f32>,
) -> FatResult<()> {
let state = serde_json::to_string(
&tank_state.as_mqtt_info(&board.board_hal.get_config().tank, &water_temp),
)?;
board
.board_hal
.get_esp()
.mqtt_publish("/water", &state)
.await;
Ok(())
}
async fn publish_plant_states(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
timezone_time: &DateTime<Tz>,
plantstate: &[PlantState; 8],
) -> FatResult<()> {
for (plant_id, (plant_state, plant_conf)) in plantstate
.iter()
.zip(&board.board_hal.get_config().plants.clone())
.enumerate()
{
let state = serde_json::to_string(&plant_state.to_mqtt_info(plant_conf, timezone_time))?;
let plant_topic = format!("/plant{}", plant_id + 1);
let _ = board
.board_hal
.get_esp()
.mqtt_publish(&plant_topic, &state)
.await;
}
Ok(())
}
async fn publish_firmware_info(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
version: VersionInfo,
ip_address: &str,
timezone_time: &str,
) {
let esp = board.board_hal.get_esp();
esp.mqtt_publish("/firmware/address", ip_address).await;
esp.mqtt_publish("/firmware/state", format!("{:?}", &version).as_str())
.await;
esp.mqtt_publish("/firmware/last_online", timezone_time)
.await;
esp.mqtt_publish("/state", "online").await;
}
macro_rules! mk_static {
($t:ty,$val:expr) => {{
static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new();
#[deny(unused_attributes)]
let x = STATIC_CELL.uninit().write($val);
x
}};
}
async fn try_connect_wifi_sntp_mqtt(
board: &mut MutexGuard<'static, CriticalSectionRawMutex, HAL<'static>>,
stack_store: &mut OptionLock<Stack<'static>>,
spawner: Spawner,
) -> NetworkMode {
let nw_conf = &board.board_hal.get_config().network.clone();
match board.board_hal.get_esp().wifi(nw_conf, spawner).await {
Ok(stack) => {
stack_store.replace(stack);
let sntp_mode: SntpMode = match board.board_hal.get_esp().sntp(1000 * 10, stack).await {
Ok(new_time) => {
info!("Using time from sntp {}", new_time.to_rfc3339());
let _ = board
.board_hal
.get_rtc_module()
.set_rtc_time(&new_time)
.await;
SntpMode::Sync { current: new_time }
}
Err(err) => {
warn!("sntp error: {err}");
board.board_hal.general_fault(true).await;
SntpMode::Offline
}
};
let mqtt_connected = if board.board_hal.get_config().network.mqtt_url.is_some() {
let nw_config = board.board_hal.get_config().network.clone();
let nw_config = mk_static!(NetworkConfig, nw_config);
match board
.board_hal
.get_esp()
.mqtt(nw_config, stack, spawner)
.await
{
Ok(_) => {
info!("Mqtt connection ready");
true
}
Err(err) => {
warn!("Could not connect mqtt due to {err}");
false
}
}
} else {
false
};
let ip = match stack.config_v4() {
Some(config) => config.address.address().to_string(),
None => match stack.config_v6() {
Some(config) => config.address.address().to_string(),
None => String::from("No IP"),
},
};
NetworkMode::Wifi {
sntp: sntp_mode,
mqtt: mqtt_connected,
ip_address: ip,
}
}
Err(err) => {
info!("Offline mode due to {err}");
board.board_hal.general_fault(true).await;
NetworkMode::Offline
}
}
}
async fn pump_info(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
plant_id: usize,
pump_active: bool,
pump_ineffective: bool,
median_current_ma: u16,
max_current_ma: u16,
min_current_ma: u16,
error: String,
) {
let pump_info = PumpInfo {
enabled: pump_active,
pump_ineffective,
median_current_ma,
max_current_ma,
min_current_ma,
error,
};
let pump_topic = format!("/pump{}", plant_id + 1);
match serde_json::to_string(&pump_info) {
Ok(state) => {
board
.board_hal
.get_esp()
.mqtt_publish(&pump_topic, &state)
.await;
}
Err(err) => {
warn!("Error publishing pump state {err}");
}
};
}
async fn publish_mppt_state(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
) -> FatResult<()> {
let current = board.board_hal.get_mptt_current().await?;
let voltage = board.board_hal.get_mptt_voltage().await?;
let solar_state = Solar {
current_ma: current.as_milliamperes() as u32,
voltage_ma: voltage.as_millivolts() as u32,
};
if let Ok(serialized_solar_state_bytes) = serde_json::to_string(&solar_state) {
board
.board_hal
.get_esp()
.mqtt_publish("/mppt", &serialized_solar_state_bytes)
.await;
}
Ok(())
}
async fn publish_battery_state(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
) -> FatResult<()> {
let state = board.board_hal.get_battery_monitor().get_state().await;
let value = match state {
Ok(state) => {
let json = serde_json::to_string(&state)?.to_owned();
json.to_owned()
}
Err(_) => "error".to_owned(),
};
{
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/battery", &value)
.await;
}
Ok(())
}
async fn wait_infinity(
board: MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
wait_type: WaitType,
@@ -1217,10 +1006,8 @@ async fn wait_infinity(
let cur = board.board_hal.get_time().await;
let timezone_time = cur.with_timezone(&timezone);
let esp = board.board_hal.get_esp();
esp.mqtt_publish("/state", "config").await;
esp.mqtt_publish("/firmware/last_online", &timezone_time.to_rfc3339())
.await;
mqtt::publish("/state", "config").await;
mqtt::publish("/firmware/last_online", &timezone_time.to_rfc3339()).await;
last_mqtt_update = Some(now);
}
@@ -1273,7 +1060,7 @@ async fn wait_infinity(
hal::PlantHal::feed_watchdog();
if wait_type == WaitType::MqttConfig && !MQTT_STAY_ALIVE.load(Ordering::Relaxed) {
if wait_type == WaitType::MqttConfig && !mqtt::is_stay_alive() {
reboot_now.store(true, Ordering::Relaxed);
}
if reboot_now.load(Ordering::Relaxed) {

View File

@@ -0,0 +1,438 @@
use crate::config::NetworkConfig;
use crate::fat_error::{ContextExt, FatError, FatResult};
use crate::hal::battery::{BatteryError, BatteryInfo, BatteryState};
use crate::hal::{PlantHal, HAL};
use crate::log::{log, LogMessage};
use crate::plant_state::PlantState;
use crate::tank::TankState;
use crate::{bail, VersionInfo};
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use chrono::DateTime;
use chrono_tz::Tz;
use core::sync::atomic::Ordering;
use embassy_executor::Spawner;
use embassy_net::Stack;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::MutexGuard;
use embassy_sync::once_lock::OnceLock;
use embassy_time::{Duration, Timer, WithTimeout};
use log::{error, info, warn};
use mcutie::{
Error, McutieBuilder, McutieReceiver, McutieTask, MqttMessage, PublishDisplay, Publishable,
QoS, Topic,
};
use portable_atomic::AtomicBool;
use serde::{Deserialize, Serialize};
static MQTT_CONNECTED_EVENT_RECEIVED: AtomicBool = AtomicBool::new(false);
static MQTT_ROUND_TRIP_RECEIVED: AtomicBool = AtomicBool::new(false);
pub static MQTT_STAY_ALIVE: AtomicBool = AtomicBool::new(false);
static MQTT_BASE_TOPIC: OnceLock<String> = OnceLock::new();
pub fn is_stay_alive() -> bool {
MQTT_STAY_ALIVE.load(Ordering::Relaxed)
}
pub async fn publish(subtopic: &str, message: &str) {
let online = MQTT_CONNECTED_EVENT_RECEIVED.load(Ordering::Relaxed);
if !online {
return;
}
let roundtrip_ok = MQTT_ROUND_TRIP_RECEIVED.load(Ordering::Relaxed);
if !roundtrip_ok {
info!("MQTT roundtrip not received yet, dropping message");
return;
}
match publish_inner(subtopic, message).await {
Ok(()) => {}
Err(err) => {
info!(
"Error during mqtt send on topic {subtopic} with message {message:#?} error is {err:?}"
);
}
};
}
async fn publish_inner(subtopic: &str, message: &str) -> FatResult<()> {
if !subtopic.starts_with("/") {
bail!("Subtopic without / at start {}", subtopic);
}
if subtopic.len() > 192 {
bail!("Subtopic exceeds 192 chars {}", subtopic);
}
let base_topic = MQTT_BASE_TOPIC
.try_get()
.context("missing base topic in static!")?;
let full_topic = format!("{base_topic}{subtopic}");
loop {
let result = Topic::General(full_topic.as_str())
.with_display(message)
.retain(true)
.publish()
.await;
match result {
Ok(()) => return Ok(()),
Err(err) => {
let retry = match err {
Error::IOError => false,
Error::TimedOut => true,
Error::TooLarge => false,
Error::PacketError => false,
Error::Invalid => false,
Error::Rejected => false,
};
if !retry {
bail!(
"Error during mqtt send on topic {} with message {:#?} error is {:?}",
&full_topic,
message,
err
);
}
info!(
"Retransmit for {} with message {:#?} error is {:?} retrying {}",
&full_topic, message, err, retry
);
Timer::after(Duration::from_millis(100)).await;
}
}
}
}
use crate::util::mk_static;
pub async fn mqtt_init(
network_config: &'static NetworkConfig,
stack: Stack<'static>,
spawner: Spawner,
) -> FatResult<()> {
let base_topic = network_config
.base_topic
.as_ref()
.context("missing base topic")?;
if base_topic.is_empty() {
bail!("Mqtt base_topic was empty")
}
MQTT_BASE_TOPIC
.init(base_topic.to_string())
.map_err(|_| FatError::String {
error: "Error setting basetopic".to_string(),
})?;
let mqtt_url = network_config
.mqtt_url
.as_ref()
.context("missing mqtt url")?;
if mqtt_url.is_empty() {
bail!("Mqtt url was empty")
}
let last_will_topic = format!("{base_topic}/state");
let round_trip_topic = format!("{base_topic}/internal/roundtrip");
let stay_alive_topic = format!("{base_topic}/stay_alive");
let mut builder: McutieBuilder<'_, String, PublishDisplay<String, &str>, 0> =
McutieBuilder::new(stack, "plant ctrl", mqtt_url);
if let (Some(mqtt_user), Some(mqtt_password)) = (
network_config.mqtt_user.as_ref(),
network_config.mqtt_password.as_ref(),
) {
builder = builder.with_authentication(mqtt_user, mqtt_password);
info!("With authentification");
}
let lwt = Topic::General(last_will_topic);
let lwt = mk_static!(Topic<String>, lwt);
let lwt = lwt.with_display("lost").retain(true).qos(QoS::AtLeastOnce);
builder = builder.with_last_will(lwt);
//TODO make configurable
builder = builder.with_device_id("plantctrl");
let builder: McutieBuilder<'_, String, PublishDisplay<String, &str>, 2> = builder
.with_subscriptions([
Topic::General(round_trip_topic.clone()),
Topic::General(stay_alive_topic.clone()),
]);
let keep_alive = Duration::from_secs(60 * 60 * 2).as_secs() as u16;
let (receiver, task) = builder.build(keep_alive);
spawner.spawn(mqtt_incoming_task(
receiver,
round_trip_topic.clone(),
stay_alive_topic.clone(),
)?);
spawner.spawn(mqtt_runner(task)?);
log(LogMessage::StayAlive, 0, 0, "", &stay_alive_topic);
log(LogMessage::MqttInfo, 0, 0, "", mqtt_url);
let mqtt_timeout = 15000;
let res = async {
while !MQTT_CONNECTED_EVENT_RECEIVED.load(Ordering::Relaxed) {
PlantHal::feed_watchdog();
Timer::after(Duration::from_millis(100)).await;
}
Ok::<(), FatError>(())
}
.with_timeout(Duration::from_millis(mqtt_timeout as u64))
.await;
if res.is_err() {
bail!("Timeout waiting MQTT connect event")
}
let _ = Topic::General(round_trip_topic.clone())
.with_display("online_text")
.publish()
.await;
let res = async {
while !MQTT_ROUND_TRIP_RECEIVED.load(Ordering::Relaxed) {
PlantHal::feed_watchdog();
Timer::after(Duration::from_millis(100)).await;
}
Ok::<(), FatError>(())
}
.with_timeout(Duration::from_millis(mqtt_timeout as u64))
.await;
if res.is_err() {
MQTT_CONNECTED_EVENT_RECEIVED.store(false, Ordering::Relaxed);
bail!("Timeout waiting MQTT roundtrip")
}
Ok(())
}
#[embassy_executor::task]
async fn mqtt_runner(
task: McutieTask<'static, String, PublishDisplay<'static, String, &'static str>, 2>,
) {
task.run().await;
}
#[embassy_executor::task]
async fn mqtt_incoming_task(
receiver: McutieReceiver,
round_trip_topic: String,
stay_alive_topic: String,
) {
loop {
let message = receiver.receive().await;
match message {
MqttMessage::Connected => {
info!("Mqtt connected");
MQTT_CONNECTED_EVENT_RECEIVED.store(true, Ordering::Relaxed);
}
MqttMessage::Publish(topic, payload) => match topic {
Topic::DeviceType(_type_topic) => {}
Topic::Device(_device_topic) => {}
Topic::General(topic) => {
let subtopic = topic.as_str();
if subtopic.eq(round_trip_topic.as_str()) {
MQTT_ROUND_TRIP_RECEIVED.store(true, Ordering::Relaxed);
} else if subtopic.eq(stay_alive_topic.as_str()) {
let value = payload.eq_ignore_ascii_case("true".as_ref())
|| payload.eq_ignore_ascii_case("1".as_ref());
let a = match value {
true => 1,
false => 0,
};
log(LogMessage::MqttStayAliveRec, a, 0, "", "");
MQTT_STAY_ALIVE.store(value, Ordering::Relaxed);
} else {
log(LogMessage::UnknownTopic, 0, 0, "", &topic);
}
}
},
MqttMessage::Disconnected => {
MQTT_CONNECTED_EVENT_RECEIVED.store(false, Ordering::Relaxed);
info!("Mqtt disconnected");
}
}
}
}
pub async fn publish_tank_state(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
tank_state: &TankState,
water_temp: FatResult<f32>,
) -> FatResult<()> {
let state = serde_json::to_string(
&tank_state.as_mqtt_info(&board.board_hal.get_config().tank, &water_temp),
)?;
let _ = publish("/water", &state).await;
Ok(())
}
pub async fn publish_plant_states(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
timezone_time: &DateTime<Tz>,
plantstate: &[PlantState; 8],
) -> FatResult<()> {
for (plant_id, (plant_state, plant_conf)) in plantstate
.iter()
.zip(&board.board_hal.get_config().plants.clone())
.enumerate()
{
let state = serde_json::to_string(&plant_state.to_mqtt_info(plant_conf, timezone_time))?;
let plant_topic = format!("/plant{}", plant_id + 1);
let _ = publish(&plant_topic, &state).await;
}
Ok(())
}
pub async fn publish_firmware_info(version: VersionInfo, ip_address: &str, timezone_time: &str) {
publish("/firmware/address", ip_address).await;
let version = &serde_json::to_string(&version);
match version {
Ok(version_str) => publish("/firmware/state", version_str).await,
Err(e) => error!("Failed to serialize version info: {}", e),
}
publish("/firmware/last_online", timezone_time).await;
publish("/state", "online").await;
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
///mqtt struct to track pump activities
pub struct PumpInfo {
pub enabled: bool,
pub pump_ineffective: bool,
pub median_current_ma: u16,
pub max_current_ma: u16,
pub min_current_ma: u16,
pub error: String,
pub flow_raw: u32,
pub flow_ml: f32,
}
#[allow(clippy::too_many_arguments)]
pub async fn pump_info(
plant_id: usize,
pump_active: bool,
pump_ineffective: bool,
median_current_ma: u16,
max_current_ma: u16,
min_current_ma: u16,
error: String,
flow_raw: u32,
flow_ml: f32,
) {
let pump_info = PumpInfo {
enabled: pump_active,
pump_ineffective,
median_current_ma,
max_current_ma,
min_current_ma,
error,
flow_raw,
flow_ml,
};
let pump_topic = format!("/pump{}", plant_id + 1);
match serde_json::to_string(&pump_info) {
Ok(state) => {
let _ = publish(&pump_topic, &state).await;
}
Err(err) => {
warn!("Error publishing pump state {err}");
}
};
}
/// Wi-Fi scan result details for MQTT
#[derive(Serialize, Debug, PartialEq)]
pub struct WifiScanResult {
pub ssid: String,
pub bssid: String,
pub rssi: i32,
pub channel: u8,
pub auth_method: String,
}
#[derive(Serialize, Debug, PartialEq)]
pub struct Solar {
pub current_ma: u32,
pub voltage_ma: u32,
}
pub async fn publish_mppt_state(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
) -> FatResult<()> {
let current = board.board_hal.get_mptt_current().await?;
let voltage = board.board_hal.get_mptt_voltage().await?;
let solar_state = Solar {
current_ma: current.as_milliamperes() as u32,
voltage_ma: voltage.as_millivolts() as u32,
};
if let Ok(serialized_solar_state_bytes) = serde_json::to_string(&solar_state) {
let _ = publish("/mppt", &serialized_solar_state_bytes).await;
}
Ok(())
}
pub async fn publish_battery_state(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
) -> FatResult<()> {
let telemetry = match board.board_hal.get_battery_monitor().get_state().await {
Ok(BatteryState::Info(info)) => info,
Ok(BatteryState::Unknown) => BatteryInfo {
voltage_mv: None,
avg_current_ma: None,
soc_pct: None,
soh_pct: None,
temperature_c: None,
remaining_mah: None,
design_mah: None,
error: Some(BatteryError::NoBatteryMonitor),
},
Err(e) => BatteryInfo {
voltage_mv: None,
avg_current_ma: None,
soc_pct: None,
soh_pct: None,
temperature_c: None,
remaining_mah: None,
design_mah: None,
error: Some(BatteryError::CommunicationError {
message: alloc::format!("{:?}", e),
}),
},
};
let json = serde_json::to_string(&telemetry)?;
publish("/battery", &json).await;
Ok(())
}
/// Publish Wi-Fi scan details to MQTT
pub async fn publish_wifi_scan(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
) -> FatResult<()> {
let mut wifi_details = board.board_hal.get_esp().wifi_scan_details().await?;
// Sort by RSSI in descending order (strongest first)
wifi_details.sort_by(|a, b| b.rssi.cmp(&a.rssi));
// Take only the strongest 10 results
let wifi_results: Vec<WifiScanResult> = wifi_details
.iter()
.take(10)
.map(|d| WifiScanResult {
ssid: d.ssid.clone(),
bssid: d.bssid.clone(),
rssi: d.rssi,
channel: d.channel,
auth_method: d.auth_method.clone(),
})
.collect();
let json = serde_json::to_string(&wifi_results)?;
publish("/wifi_scan", &json).await;
Ok(())
}

View File

@@ -0,0 +1,463 @@
use crate::bail;
use crate::config::NetworkConfig;
use crate::fat_error::{ContextExt, FatError, FatResult};
use crate::hal::HAL;
use crate::mqtt;
use crate::util::mk_static;
use alloc::string::{String, ToString};
use alloc::sync::Arc;
use chrono::{DateTime, Utc};
use core::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use edge_dhcp::{
io::{self, DEFAULT_SERVER_PORT},
server::{Server, ServerOptions},
};
use edge_nal::UdpBind;
use edge_nal_embassy::{Udp, UdpBuffers};
use embassy_executor::Spawner;
use embassy_net::dns::DnsQueryType;
use embassy_net::udp::{PacketMetadata, UdpSocket};
use embassy_net::{DhcpConfig, Runner, Stack, StackResources, StaticConfigV4};
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::{Mutex, MutexGuard};
use embassy_time::{Duration, Timer, WithTimeout};
use esp_hal::rng::Rng;
use esp_println::println;
use esp_radio::wifi::ap::AccessPointConfig;
use esp_radio::wifi::sta::StationConfig;
use esp_radio::wifi::{AuthenticationMethod, Config, Interface};
use log::{error, info, warn};
use option_lock::OptionLock;
use serde::Serialize;
use sntpc::{get_time, NtpContext, NtpTimestampGenerator, NtpUdpSocket};
const NTP_SERVER: &str = "pool.ntp.org";
#[derive(Copy, Clone, Default)]
struct Timestamp {
stamp: DateTime<Utc>,
}
impl NtpTimestampGenerator for Timestamp {
fn init(&mut self) {
self.stamp = DateTime::default();
}
fn timestamp_sec(&self) -> u64 {
self.stamp.timestamp() as u64
}
fn timestamp_subsec_micros(&self) -> u32 {
self.stamp.timestamp_subsec_micros()
}
}
struct EmbassyNtpSocket<'a, 'b> {
socket: &'a UdpSocket<'b>,
}
impl<'a, 'b> EmbassyNtpSocket<'a, 'b> {
fn new(socket: &'a UdpSocket<'b>) -> Self {
Self { socket }
}
}
impl NtpUdpSocket for EmbassyNtpSocket<'_, '_> {
async fn send_to(&self, buf: &[u8], addr: SocketAddr) -> sntpc::Result<usize> {
self.socket
.send_to(buf, addr)
.await
.map_err(|_| sntpc::Error::Network)?;
Ok(buf.len())
}
async fn recv_from(&self, buf: &mut [u8]) -> sntpc::Result<(usize, SocketAddr)> {
let (len, metadata) = self
.socket
.recv_from(buf)
.await
.map_err(|_| sntpc::Error::Network)?;
let addr = match metadata.endpoint.addr {
embassy_net::IpAddress::Ipv4(ip) => IpAddr::V4(ip),
embassy_net::IpAddress::Ipv6(ip) => IpAddr::V6(ip),
};
Ok((len, SocketAddr::new(addr, metadata.endpoint.port)))
}
}
pub async fn sntp(max_wait_ms: u32, stack: Stack<'_>) -> FatResult<DateTime<Utc>> {
println!("start sntp");
let mut rx_meta = [PacketMetadata::EMPTY; 16];
let mut rx_buffer = [0; 4096];
let mut tx_meta = [PacketMetadata::EMPTY; 16];
let mut tx_buffer = [0; 4096];
let mut socket = UdpSocket::new(
stack,
&mut rx_meta,
&mut rx_buffer,
&mut tx_meta,
&mut tx_buffer,
);
socket.bind(123).context("Could not bind UDP socket")?;
let context = NtpContext::new(Timestamp::default());
let ntp_socket = EmbassyNtpSocket::new(&socket);
let ntp_addrs = stack
.dns_query(NTP_SERVER, DnsQueryType::A)
.await
.context("Failed to resolve DNS")?;
if ntp_addrs.is_empty() {
bail!("No IP addresses found for NTP server");
}
let ntp = ntp_addrs[0];
info!("NTP server: {ntp:?}");
let mut counter = 0;
loop {
let addr: IpAddr = ntp.into();
let timeout = get_time(SocketAddr::from((addr, 123)), &ntp_socket, context)
.with_timeout(Duration::from_millis((max_wait_ms / 10) as u64))
.await;
match timeout {
Ok(result) => {
let time = result?;
info!("Time: {time:?}");
return DateTime::from_timestamp(time.seconds as i64, 0)
.context("Could not convert Sntp result");
}
Err(err) => {
warn!("sntp timeout, retry: {err:?}");
counter += 1;
if counter > 10 {
bail!("Failed to get time from NTP server");
}
Timer::after(Duration::from_millis(100)).await;
}
}
}
}
#[derive(Serialize, Debug, PartialEq)]
#[allow(clippy::upper_case_acronyms)]
pub enum SntpMode {
OFFLINE,
SYNC { current: DateTime<Utc> },
}
#[derive(Serialize, Debug, PartialEq)]
#[allow(clippy::upper_case_acronyms)]
pub enum NetworkMode {
WIFI {
sntp: SntpMode,
mqtt: bool,
ip_address: String,
},
OFFLINE,
}
#[embassy_executor::task(pool_size = 2)]
pub(crate) async fn net_task(mut runner: Runner<'static, Interface<'static>>) {
runner.run().await;
}
#[embassy_executor::task]
pub(crate) async fn run_dhcp(stack: Stack<'static>, ip: Ipv4Addr) {
let mut buf = [0u8; 1500];
let mut gw_buf = [Ipv4Addr::UNSPECIFIED];
let buffers = UdpBuffers::<3, 1024, 1024, 10>::new();
let unbound_socket = Udp::new(stack, &buffers);
let mut bound_socket = match unbound_socket
.bind(SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::UNSPECIFIED,
DEFAULT_SERVER_PORT,
)))
.await
{
Ok(s) => s,
Err(e) => {
error!("dhcp task failed to bind socket: {:?}", e);
return;
}
};
loop {
_ = io::server::run(
&mut Server::<_, 64>::new_with_et(ip),
&ServerOptions::new(ip, Some(&mut gw_buf)),
&mut bound_socket,
&mut buf,
)
.await
.inspect_err(|e| warn!("DHCP server error: {e:?}"));
Timer::after(Duration::from_millis(500)).await;
}
}
pub async fn wifi_ap(
ssid: String,
interface_ap: Interface<'static>,
controller: &Arc<Mutex<CriticalSectionRawMutex, esp_radio::wifi::WifiController<'static>>>,
rng: &mut Rng,
spawner: Spawner,
) -> FatResult<Stack<'static>> {
let gw_ip_addr = Ipv4Addr::new(192, 168, 71, 1);
let config = embassy_net::Config::ipv4_static(StaticConfigV4 {
address: embassy_net::Ipv4Cidr::new(gw_ip_addr, 24),
gateway: Some(gw_ip_addr),
dns_servers: Default::default(),
});
let seed = (rng.random() as u64) << 32 | rng.random() as u64;
println!("init secondary stack");
let (stack, runner) = embassy_net::new(
interface_ap,
config,
mk_static!(StackResources<4>, StackResources::<4>::new()),
seed,
);
let stack = mk_static!(Stack, stack);
let client_config = Config::AccessPoint(AccessPointConfig::default().with_ssid(ssid.clone()));
controller.lock().await.set_config(&client_config)?;
println!("start net task");
spawner.spawn(net_task(runner)?);
println!("run dhcp");
spawner.spawn(run_dhcp(*stack, gw_ip_addr)?);
loop {
if stack.is_link_up() {
break;
}
Timer::after(Duration::from_millis(500)).await;
}
while !stack.is_config_up() {
Timer::after(Duration::from_millis(100)).await
}
println!("Connect to the AP `${ssid}` and point your browser to http://{gw_ip_addr}/");
stack
.config_v4()
.inspect(|c| println!("ipv4 config: {c:?}"));
Ok(*stack)
}
pub async fn wifi(
network_config: &NetworkConfig,
interface_sta: Interface<'static>,
controller: &Arc<Mutex<CriticalSectionRawMutex, esp_radio::wifi::WifiController<'static>>>,
rng: &mut Rng,
spawner: Spawner,
) -> FatResult<Stack<'static>> {
esp_radio::wifi_set_log_verbose();
let ssid = match &network_config.ssid {
Some(ssid) => {
if ssid.is_empty() {
bail!("Wifi ssid was empty")
}
ssid.as_str().to_string()
}
None => {
bail!("Wifi ssid was empty")
}
};
let password = match network_config.password {
Some(ref password) => password.as_str().to_string(),
None => "".to_string(),
};
let max_wait = network_config.max_wait;
let retry_count = network_config.retry_count;
let config = embassy_net::Config::dhcpv4(DhcpConfig::default());
let seed = (rng.random() as u64) << 32 | rng.random() as u64;
let (stack, runner) = embassy_net::new(
interface_sta,
config,
mk_static!(StackResources<8>, StackResources::<8>::new()),
seed,
);
let stack = mk_static!(Stack, stack);
let auth_method = if password.is_empty() {
AuthenticationMethod::None
} else {
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()
.with_ssid(ssid.clone())
.with_auth_method(auth_method)
.with_scan_method(esp_radio::wifi::sta::ScanMethod::AllChannels)
.with_listen_interval(10)
.with_beacon_timeout(10)
.with_failure_retry_cnt(3)
.with_password(password.clone());
// Set config and attempt connection
controller
.lock()
.await
.set_config(&Config::Station(client_config))?;
match controller
.lock()
.await
.connect_async()
.with_timeout(Duration::from_millis(max_wait as u64 * 1000))
.await
{
Ok(result) => {
result?;
}
Err(e) => {
let disconnect_info = controller.lock().await.disconnect_async().await;
warn!("Wifi disconnect info {:?}", disconnect_info);
warn!("WiFi connection attempt {} failed: Timeout waiting for wifi sta connected: {:?}", attempts + 1, e);
attempts += 1;
Timer::after(Duration::from_millis(500)).await;
continue;
}
}
let res = async {
while !stack.is_link_up() {
Timer::after(Duration::from_millis(500)).await;
}
Ok::<(), FatError>(())
}
.with_timeout(Duration::from_millis(max_wait as u64 * 1000))
.await;
if res.is_err() {
warn!(
"WiFi connection attempt {} failed: link up timeout",
attempts + 1
);
attempts += 1;
Timer::after(Duration::from_millis(500)).await;
continue;
}
let res = async {
while !stack.is_config_up() {
Timer::after(Duration::from_millis(100)).await
}
Ok::<(), FatError>(())
}
.with_timeout(Duration::from_millis(max_wait as u64 * 1000))
.await;
if res.is_err() {
warn!(
"WiFi connection attempt {} failed: config up timeout",
attempts + 1
);
attempts += 1;
Timer::after(Duration::from_millis(500)).await;
continue;
}
// Success!
info!("Connected WIFI, dhcp: {:?}", stack.config_v4());
return Ok(*stack);
}
// All retries exhausted
bail!("WiFi connection failed after all retries");
}
pub async fn try_connect_wifi_sntp_mqtt(
board: &mut MutexGuard<'static, CriticalSectionRawMutex, HAL<'static>>,
stack_store: &mut OptionLock<Stack<'static>>,
spawner: Spawner,
) -> NetworkMode {
let nw_conf = &board.board_hal.get_config().network.clone();
let esp = board.board_hal.get_esp();
let device = match esp.interface_sta.take() {
Some(d) => d,
None => {
info!("Offline mode due to STA interface already taken");
board.board_hal.general_fault(true).await;
return NetworkMode::OFFLINE;
}
};
match wifi(nw_conf, device, &esp.controller, &mut esp.rng, spawner).await {
Ok(stack) => {
stack_store.replace(stack);
let sntp_mode: SntpMode = match sntp(1000 * 10, stack).await {
Ok(new_time) => {
info!("Using time from sntp {}", new_time.to_rfc3339());
let _ = board
.board_hal
.get_rtc_module()
.set_rtc_time(&new_time)
.await;
SntpMode::SYNC { current: new_time }
}
Err(err) => {
warn!("sntp error: {err}");
board.board_hal.general_fault(true).await;
SntpMode::OFFLINE
}
};
let mqtt_connected = if board.board_hal.get_config().network.mqtt_url.is_some() {
let nw_config = board.board_hal.get_config().network.clone();
let nw_config = mk_static!(NetworkConfig, nw_config);
match mqtt::mqtt_init(nw_config, stack, spawner).await {
Ok(_) => {
info!("Mqtt connection ready");
true
}
Err(err) => {
warn!("Could not connect mqtt due to {err}");
false
}
}
} else {
false
};
let ip = match stack.config_v4() {
Some(config) => config.address.address().to_string(),
None => match stack.config_v6() {
Some(config) => config.address.address().to_string(),
None => String::from("No IP"),
},
};
NetworkMode::WIFI {
sntp: sntp_mode,
mqtt: mqtt_connected,
ip_address: ip,
}
}
Err(err) => {
info!("Offline mode due to {err}");
board.board_hal.general_fault(true).await;
NetworkMode::OFFLINE
}
}
}

View File

@@ -1,13 +1,16 @@
use crate::config::SensorCombineMode;
use crate::hal::Moistures;
use crate::plant_state::PlantWateringMode::TargetMoisture;
use crate::{config::PlantConfig, hal::HAL, in_time_range};
use chrono::{DateTime, TimeDelta, Utc};
use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
const MOIST_SENSOR_MAX_FREQUENCY: f32 = 70000.; // 70kHz
const MOIST_SENSOR_MAX_FREQUENCY: f32 = 160000.; // 160kHz -> very wet
const MOIST_SENSOR_MIN_FREQUENCY: f32 = 400.; // this is really, really dry, think like cactus levels
#[derive(Debug, PartialEq, Serialize)]
#[derive(Debug, PartialEq, Clone, Serialize)]
#[serde(tag = "kind")]
pub enum MoistureSensorError {
MissingMessage,
NotExpectedMessage { hz: f32 },
@@ -46,6 +49,14 @@ impl MoistureSensorState {
impl MoistureSensorState {}
#[derive(Debug, PartialEq, Serialize)]
pub struct SensorTelemetry {
pub moisture_pct: Option<f32>,
pub raw_hz: Option<f32>,
pub error: Option<MoistureSensorError>,
}
#[derive(Debug, PartialEq, Serialize)]
#[serde(tag = "kind")]
pub enum PumpError {
PumpNotWorking {
failed_attempts: usize,
@@ -100,10 +111,31 @@ pub struct PlantState {
pub sensor_a_firmware_build_minutes: Option<u32>,
/// Last known firmware build timestamp for sensor B.
pub sensor_b_firmware_build_minutes: Option<u32>,
/// Last time fertilizer was applied (Unix timestamp in seconds).
pub last_fertilizer_time: i64,
/// Last time fertilizer was applied.
pub last_fertilizer_time: Option<DateTime<Utc>>,
}
/// Map sensor frequency to moisture percentage using inverse power-law scaling (quadratic).
///
/// For resistive probes with 555 timer oscillator:
/// - Dry soil has high resistance → low oscillation frequency
/// - Wet soil has low resistance → high oscillation frequency
///
/// The relationship is non-linear: most frequency change occurs in the wet range.
/// Using inverse power-law to give better discrimination at high moisture levels.
///
/// Formula: moisture = (1 - (f_max - f) / (f_max - f_min))^2 * 100
/// = ((f - f_min) / (f_max - f_min))^2 * 100
///
/// But with k=0.5 (square root) for better high-end discrimination:
/// Formula: moisture = sqrt((f - f_min) / (f_max - f_min)) * 100
///
/// Examples with default range (400-160000 Hz) using k=0.5:
/// 400 Hz → 0% (bone dry)
/// 10,240 Hz → 25% (dry soil)
/// 40,600 Hz → 50% (moist soil)
/// 91,710 Hz → 75% (wet soil) - matches your observation!
/// 160,000 Hz → 100% (saturated)
fn map_range_moisture(
s: f32,
min_frequency: Option<f32>,
@@ -125,9 +157,28 @@ fn map_range_moisture(
max: max_freq,
});
}
let moisture_percent = (s - min_freq) * 100.0 / (max_freq - min_freq);
Ok(moisture_percent)
// Normalize to 0-1 range
let t = (s - min_freq) / (max_freq - min_freq);
// Apply power-law mapping with k=0.5 (square root) for better high-moisture discrimination
// For resistive probes: frequency ↑ as moisture ↑, but non-linearly
// Using sqrt gives more resolution in the wet range (60-160kHz)
// Newton's method approximation for sqrt(t): x_{n+1} = 0.5 * (x_n + t/x_n)
// Start with initial guess and do 2 iterations for good precision
let moisture_percent = if t <= 0.0 {
0.0
} else if t >= 1.0 {
100.0
} else {
// Newton's method for sqrt(t)
let mut x = t; // Initial guess
x = 0.5 * (x + t / x); // First iteration
x = 0.5 * (x + t / x); // Second iteration for better precision
x * 100.0
};
Ok(moisture_percent.clamp(0.0, 100.0))
}
impl PlantState {
@@ -175,8 +226,12 @@ impl PlantState {
let previous_pump = board.board_hal.get_esp().last_pump_time(plant_id);
let consecutive_pump_count = board.board_hal.get_esp().consecutive_pump_count(plant_id);
let last_fertilizer_time = board.board_hal.get_esp().last_fertilizer_time(plant_id);
let last_fertilizer_timestamp = board.board_hal.get_esp().last_fertilizer_time(plant_id);
let (a_builds, b_builds) = board.board_hal.get_sensor_build_minutes();
let last_fertilizer_time = DateTime::from_timestamp_millis(last_fertilizer_timestamp);
// Create plant state first, then check for warnings
let state = Self {
sensor_a,
sensor_b,
@@ -189,7 +244,20 @@ impl PlantState {
sensor_b_firmware_build_minutes: b_builds[plant_id],
last_fertilizer_time,
};
if state.is_err() {
// Check for sensor warning condition (expected 2 sensors, only 1 responding)
let has_a =
state.sensor_a.moisture_percent().is_some() && state.sensor_a.is_err().is_none();
let has_b =
state.sensor_b.moisture_percent().is_some() && state.sensor_b.is_err().is_none();
// Check if we expected two sensors but only got one
let has_sensor_warning =
expected_a && expected_b && ((has_a && !has_b) || (!has_a && has_b));
// Set fault LED for both errors AND sensor warnings
let has_issue = state.is_err() || has_sensor_warning;
if has_issue {
let _ = board.board_hal.fault(plant_id, true).await;
}
state
@@ -212,26 +280,25 @@ impl PlantState {
self.sensor_a.is_err().is_some() || self.sensor_b.is_err().is_some()
}
pub fn plant_moisture(
&self,
) -> (
Option<u8>,
(Option<&MoistureSensorError>, Option<&MoistureSensorError>),
) {
/// Get combined moisture value with configurable combination mode and sensor warning.
///
/// Returns:
/// - Combined moisture percentage (or None if no valid readings)
/// - Tuple of errors from sensor A and B
/// - Sensor warning indicating if warning LED should be lit (MissingSecondSensor)
pub fn plant_moisture_with_warning(&self, plant_conf: &PlantConfig) -> Option<f32> {
match (
self.sensor_a.moisture_percent(),
self.sensor_b.moisture_percent(),
) {
(Some(moisture_a), Some(moisture_b)) => {
(Some(((moisture_a + moisture_b) / 2.) as u8), (None, None))
}
(Some(moisture_percent), _) => {
(Some(moisture_percent as u8), (None, self.sensor_b.is_err()))
}
(_, Some(moisture_percent)) => {
(Some(moisture_percent as u8), (self.sensor_a.is_err(), None))
}
_ => (None, (self.sensor_a.is_err(), self.sensor_b.is_err())),
(Some(moisture_a), Some(moisture_b)) => match plant_conf.sensor_combine_mode {
SensorCombineMode::Min => Some(moisture_a.min(moisture_b)),
SensorCombineMode::Max => Some(moisture_a.max(moisture_b)),
SensorCombineMode::Avg => Some((moisture_a + moisture_b) / 2.0),
},
(Some(moisture), _) => Some(moisture),
(_, Some(moisture)) => Some(moisture),
_ => None,
}
}
@@ -243,11 +310,11 @@ impl PlantState {
match plant_conf.mode {
PlantWateringMode::Off => false,
PlantWateringMode::TargetMoisture => {
let (moisture_percent, _) = self.plant_moisture();
let moisture_percent = self.plant_moisture_with_warning(plant_conf);
if let Some(moisture_percent) = moisture_percent {
if self.pump_in_timeout(plant_conf, current_time) {
false
} else if moisture_percent < plant_conf.target_moisture {
} else if moisture_percent < plant_conf.target_moisture.into() {
in_time_range(
current_time,
plant_conf.pump_hour_start,
@@ -269,23 +336,26 @@ impl PlantState {
}
}
pub fn to_mqtt_info(
&self,
plant_conf: &PlantConfig,
current_time: &DateTime<Tz>,
) -> PlantInfo<'_> {
pub fn to_mqtt_info(&self, plant_conf: &PlantConfig, current_time: &DateTime<Tz>) -> PlantInfo {
let moisture_pct = self.plant_moisture_with_warning(plant_conf);
PlantInfo {
sensor_a: &self.sensor_a,
sensor_b: &self.sensor_b,
moisture_pct,
sensor_a: Self::sensor_to_telemetry(&self.sensor_a),
sensor_b: Self::sensor_to_telemetry(&self.sensor_b),
mode: plant_conf.mode,
target_pct: if plant_conf.mode == TargetMoisture {
Some(plant_conf.target_moisture as f32)
} else {
None
},
do_water: self.needs_to_be_watered(plant_conf, current_time),
dry: if let Some(moisture_percent) = self.plant_moisture().0 {
moisture_percent < plant_conf.target_moisture
dry: if let Some(moisture_percent) = moisture_pct {
moisture_percent < plant_conf.target_moisture.into()
} else {
false
},
cooldown: self.pump_in_timeout(plant_conf, current_time),
out_of_work_hour: in_time_range(
out_of_work_hour: !in_time_range(
current_time,
plant_conf.pump_hour_start,
plant_conf.pump_hour_end,
@@ -310,20 +380,67 @@ impl PlantState {
} else {
None
},
last_fertilizer: self
.last_fertilizer_time
.map(|t| t.with_timezone(&current_time.timezone())),
next_fertilizer: if matches!(
plant_conf.mode,
PlantWateringMode::TimerOnly
| PlantWateringMode::TargetMoisture
| PlantWateringMode::MinMoisture
) {
self.last_fertilizer_time.and_then(|last_fert| {
// Convert to Tz for calculation, then back
let tz_last_fert = last_fert.with_timezone(&current_time.timezone());
tz_last_fert
.checked_add_signed(TimeDelta::minutes(
plant_conf.fertilizer_cooldown_min.into(),
))
.map(|t| t.with_timezone(&current_time.timezone()))
})
} else {
None
},
sensor_a_firmware_build_minutes: self.sensor_a_firmware_build_minutes,
sensor_b_firmware_build_minutes: self.sensor_b_firmware_build_minutes,
last_fertilizer_time: self.last_fertilizer_time,
}
}
fn sensor_to_telemetry(sensor: &MoistureSensorState) -> SensorTelemetry {
match sensor {
MoistureSensorState::NoMessage => SensorTelemetry {
moisture_pct: None,
raw_hz: None,
error: None,
},
MoistureSensorState::MoistureValue {
hz,
moisture_percent,
} => SensorTelemetry {
moisture_pct: Some(*moisture_percent),
raw_hz: Some(*hz),
error: None,
},
MoistureSensorState::SensorError(err) => SensorTelemetry {
moisture_pct: None,
raw_hz: None,
error: Some(err.clone()),
},
}
}
}
#[derive(Debug, PartialEq, Serialize)]
/// State of a single plant to be tracked
pub struct PlantInfo<'a> {
pub struct PlantInfo {
/// combined plant moisture from available sensors
moisture_pct: Option<f32>,
/// moisture target, if in targetmode
target_pct: Option<f32>,
/// state of humidity sensor on bank a
sensor_a: &'a MoistureSensorState,
sensor_a: SensorTelemetry,
/// state of humidity sensor on bank b
sensor_b: &'a MoistureSensorState,
sensor_b: SensorTelemetry,
/// configured plant watering mode
mode: PlantWateringMode,
/// the plant needs to be watered
@@ -341,10 +458,12 @@ pub struct PlantInfo<'a> {
last_pump: Option<DateTime<Tz>>,
/// next time when pump should activate
next_pump: Option<DateTime<Tz>>,
/// last time when fertilizer was applied
last_fertilizer: Option<DateTime<Tz>>,
/// next time when fertilizer should be applied
next_fertilizer: Option<DateTime<Tz>>,
/// firmware build timestamp of sensor A (minutes since Unix epoch); None if unknown
sensor_a_firmware_build_minutes: Option<u32>,
/// firmware build timestamp of sensor B (minutes since Unix epoch); None if unknown
sensor_b_firmware_build_minutes: Option<u32>,
/// last time when fertilizer was applied
last_fertilizer_time: i64,
}

View File

@@ -10,11 +10,12 @@ const OPEN_TANK_VOLTAGE: f32 = 3.0;
pub const WATER_FROZEN_THRESH: f32 = 4.0;
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind")]
pub enum TankError {
SensorDisabled,
SensorMissing(f32),
SensorMissing { raw_mv: f32 },
SensorValueError { value: f32, min: f32, max: f32 },
BoardError(String),
BoardError { message: String },
}
pub enum TankState {
@@ -25,7 +26,9 @@ pub enum TankState {
fn raw_voltage_to_divider_percent(raw_value_mv: f32) -> Result<f32, TankError> {
if raw_value_mv > OPEN_TANK_VOLTAGE {
return Err(TankError::SensorMissing(raw_value_mv));
return Err(TankError::SensorMissing {
raw_mv: raw_value_mv,
});
}
let r2 = raw_value_mv * 50.0 / (3.3 - raw_value_mv);
@@ -141,15 +144,15 @@ impl TankState {
TankInfo {
enough_water,
warn_level,
left_ml,
volume_ml: left_ml,
sensor_error: tank_err,
raw,
fill_raw_v: raw,
water_frozen: water_temp
.as_ref()
.is_ok_and(|temp| *temp < WATER_FROZEN_THRESH),
water_temp: water_temp.as_ref().copied().ok(),
water_temp_c: water_temp.as_ref().copied().ok(),
temp_sensor_error: water_temp.as_ref().err().map(|err| err.to_string()),
percent,
fill_pct: percent,
}
}
}
@@ -158,12 +161,16 @@ pub async fn determine_tank_state(
board: &mut MutexGuard<'static, CriticalSectionRawMutex, HAL<'static>>,
) -> TankState {
if board.board_hal.get_config().tank.tank_sensor_enabled {
match board.board_hal.get_tank_sensor() {
Ok(sensor) => match sensor.tank_sensor_voltage().await {
Ok(raw_sensor_value_mv) => TankState::Present(raw_sensor_value_mv),
Err(err) => TankState::Error(TankError::BoardError(err.to_string())),
},
Err(err) => TankState::Error(TankError::BoardError(err.to_string())),
match board
.board_hal
.get_tank_sensor()
.tank_sensor_voltage()
.await
{
Ok(raw_sensor_value_mv) => TankState::Present(raw_sensor_value_mv),
Err(err) => TankState::Error(TankError::BoardError {
message: err.to_string(),
}),
}
} else {
TankState::Disabled
@@ -178,16 +185,16 @@ pub struct TankInfo {
/// warning that water needs to be refilled soon
pub(crate) warn_level: bool,
/// estimation how many ml are still in the tank
pub(crate) left_ml: Option<f32>,
pub(crate) volume_ml: Option<f32>,
/// if there is an issue with the water level sensor
pub(crate) sensor_error: Option<TankError>,
/// raw water sensor value
pub(crate) raw: Option<f32>,
pub(crate) fill_raw_v: Option<f32>,
/// percent value
pub(crate) percent: Option<f32>,
pub(crate) fill_pct: Option<f32>,
/// water in the tank might be frozen
pub(crate) water_frozen: bool,
/// water temperature
pub(crate) water_temp: Option<f32>,
pub(crate) water_temp_c: Option<f32>,
pub(crate) temp_sensor_error: Option<String>,
}

View File

@@ -0,0 +1,10 @@
macro_rules! mk_static {
($t:ty,$val:expr) => {{
static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new();
#[deny(unused_attributes)]
let x = STATIC_CELL.uninit().write($val);
x
}};
}
pub(crate) use mk_static;

View File

@@ -23,6 +23,8 @@ struct LoadData<'a> {
struct Moistures {
moisture_a: Vec<String>,
moisture_b: Vec<String>,
sensor_a_build_minutes: Vec<Option<u32>>,
sensor_b_build_minutes: Vec<Option<u32>>,
}
#[derive(Serialize, Debug)]
struct SolarState {
@@ -63,9 +65,20 @@ where
MoistureSensorState::NoMessage => "No Message".to_string(),
}));
let sensor_a_build_minutes: Vec<Option<u32>> = plant_state
.iter()
.map(|s| s.sensor_a_firmware_build_minutes)
.collect();
let sensor_b_build_minutes: Vec<Option<u32>> = plant_state
.iter()
.map(|s| s.sensor_b_firmware_build_minutes)
.collect();
let data = Moistures {
moisture_a: a,
moisture_b: b,
sensor_a_build_minutes,
sensor_b_build_minutes,
};
let json = serde_json::to_string(&data)?;
@@ -80,10 +93,11 @@ where
{
let mut board = BOARD_ACCESS.get().await.lock().await;
let tank_state = determine_tank_state(&mut board).await;
//should be multisampled
let sensor = board.board_hal.get_tank_sensor()?;
let water_temp: FatResult<f32> = sensor.water_temperature_c().await;
let water_temp: FatResult<f32> = board
.board_hal
.get_tank_sensor()
.water_temperature_c()
.await;
Ok(Some(serde_json::to_string(&tank_state.as_mqtt_info(
&board.board_hal.get_config().tank,
&water_temp,
@@ -204,3 +218,12 @@ pub(crate) async fn get_log_localization_config<T, const N: usize>(
&LogMessage::log_localisation_config(),
)?))
}
/// Return Wi-Fi scan details including signal strength (RSSI)
pub(crate) async fn get_wifi_details<T, const N: usize>(
_request: &mut Connection<'_, T, N>,
) -> FatResult<Option<String>> {
let mut board = BOARD_ACCESS.get().await.lock().await;
let wifi_details = board.board_hal.get_esp().wifi_scan_details().await?;
Ok(Some(serde_json::to_string(&wifi_details)?))
}

View File

@@ -10,8 +10,9 @@ mod post_json;
use crate::fat_error::{FatError, FatResult};
use crate::webserver::backup_manager::{backup_config, backup_info, get_backup_config};
use crate::webserver::get_json::{
delete_save, get_battery_state, get_config, get_live_moisture, get_log_localization_config,
get_firmware_info_web, get_solar_state, get_time, get_timezones, list_saves, tank_info,
delete_save, get_battery_state, get_config, get_firmware_info_web, get_live_moisture,
get_log_localization_config, get_solar_state, get_time, get_timezones, get_wifi_details,
list_saves, tank_info,
};
use crate::webserver::get_log::{get_live_log, get_log};
use crate::webserver::get_static::{serve_bundle, serve_favicon, serve_index};
@@ -83,6 +84,7 @@ impl Handler for HTTPRequestRouter {
"/timezones" => Some(get_timezones().await),
"/moisture" => Some(get_live_moisture(conn).await),
"/list_saves" => Some(list_saves(conn).await),
"/wifi_details" => Some(get_wifi_details(conn).await),
// /live_log accepts an optional ?after=N query parameter
p if p == "/live_log" || p.starts_with("/live_log?") => {
let after: Option<u64> = p

View File

@@ -1,5 +1,6 @@
use crate::config::PlantControllerConfig;
use crate::fat_error::FatResult;
use crate::hal::savegame_manager::SAVEGAME_SLOT_SIZE;
use crate::hal::DetectionRequest;
use crate::webserver::read_up_to_bytes_from_request;
use crate::{do_secure_pump, BOARD_ACCESS};
@@ -135,7 +136,8 @@ pub(crate) async fn set_config<T, const N: usize>(
where
T: Read + Write,
{
let all = read_up_to_bytes_from_request(request, Some(4096)).await?;
//accept nearly full slotsize leave some space for header
let all = read_up_to_bytes_from_request(request, Some(SAVEGAME_SLOT_SIZE - 512)).await?;
let length = all.len();
let config: PlantControllerConfig = serde_json::from_slice(&all)?;

View File

@@ -141,6 +141,7 @@ export interface PlantConfig {
min_pump_current_ma: number,
max_pump_current_ma: number,
ignore_current_error: boolean,
sensor_combine_mode: string,
}
export interface PumpTestResult {
@@ -177,6 +178,8 @@ export interface GetTime {
export interface Moistures {
moisture_a: [string],
moisture_b: [string],
sensor_a_build_minutes: Array<number | null>,
sensor_b_build_minutes: Array<number | null>,
}
export interface VersionInfo {
@@ -223,23 +226,32 @@ export interface Detection {
plant: DetectionPlant[]
}
/// Wi-Fi scan result details for UI display
export interface WifiScanResult {
ssid: string,
bssid: string,
rssi: number, // signal strength in dBm
channel: number,
auth_method: string
}
export interface TankInfo {
/// is there enough water in the tank
/// there is enough water in the tank
enough_water: boolean,
/// warning that water needs to be refilled soon
warn_level: boolean,
/// estimation how many ml are still in tank
left_ml: number | null,
/// if there is was an issue with the water level sensor
/// estimation how many ml are still in the tank
volume_ml: number | null,
/// if there is an issue with the water level sensor
sensor_error: string | null,
/// raw water sensor value
raw: number | null,
fill_raw_v: number | null,
/// percent value
percent: number | null,
/// water in tank might be frozen
fill_pct: number | null,
/// water in the tank might be frozen
water_frozen: boolean,
/// water temperature
water_temp: number | null,
water_temp_c: number | null,
temp_sensor_error: string | null
}

View File

@@ -37,7 +37,7 @@
}
#logpanel {
display: none;
}
</style>

View File

@@ -26,7 +26,7 @@ import {
Moistures,
NightLampCommand,
PlantControllerConfig,
SetTime, SSIDList, TankInfo,
SetTime, SSIDList, TankInfo, WifiScanResult,
TestPump,
VersionInfo,
SaveInfo, SolarState, PumpTestResult, Detection, DetectionRequest, CanPower
@@ -43,6 +43,7 @@ export class Controller {
controller.tankView.setTankInfo(tankinfo)
})
.catch(error => {
toast.error(`Failed to load tank info: ${error}`);
console.log(error);
});
}
@@ -64,7 +65,9 @@ export class Controller {
const json = await response.json();
const logs = json as LogArray;
controller.logView.setLog(logs);
toast.info("Log loaded successfully");
} catch (error) {
toast.error(`Failed to load log: ${error}`);
console.log(error);
}
}
@@ -87,6 +90,7 @@ export class Controller {
const timezones = json as string[];
controller.timeView.timezones(timezones);
} catch (error) {
toast.error(`Error fetching timezones: ${error}`);
return console.error('Error fetching timezones:', error);
}
}
@@ -98,6 +102,7 @@ export class Controller {
const saves = json as SaveInfo[];
controller.fileview.setSaveList(saves, PUBLIC_URL);
} catch (error) {
toast.error(`Failed to update save list: ${error}`);
console.log(error);
}
}
@@ -109,16 +114,21 @@ export class Controller {
ajax.send();
ajax.addEventListener("error", () => {
controller.progressview.removeProgress("slot_delete");
alert("Error deleting slot");
toast.error(`Failed to delete slot ${idx}`);
controller.updateSaveList();
}, false);
ajax.addEventListener("abort", () => {
controller.progressview.removeProgress("slot_delete");
alert("Aborted deleting slot");
toast.warning(`Slot deletion aborted`);
controller.updateSaveList();
}, false);
ajax.addEventListener("load", () => {
controller.progressview.removeProgress("slot_delete");
if (ajax.status >= 200 && ajax.status < 300) {
toast.success("Slot deleted successfully");
} else {
toast.error(`Failed to delete slot: ${ajax.status}`);
}
controller.updateSaveList();
}, false);
}
@@ -131,6 +141,7 @@ export class Controller {
controller.timeView.update(time.native, time.rtc);
} catch (error) {
controller.timeView.update("n/a", "n/a");
toast.error(`Failed to update RTC data: ${error}`);
console.log(error);
}
}
@@ -143,6 +154,7 @@ export class Controller {
controller.batteryView.update(battery);
} catch (error) {
controller.batteryView.update(null);
toast.error(`Failed to update battery data: ${error}`);
console.log(error);
}
}
@@ -155,6 +167,22 @@ export class Controller {
controller.solarView.update(solar);
} catch (error) {
controller.solarView.update(null);
toast.error(`Failed to update solar data: ${error}`);
console.log(error);
}
}
async scanWifiDetails(): Promise<void> {
try {
const response = await fetch(PUBLIC_URL + "/wifi_details");
if (response.ok) {
const wifiDetails = await response.json();
controller.networkView.displayWifiResults(wifiDetails as WifiScanResult[]);
} else {
toast.error(`Failed to fetch Wi-Fi details: ${response.status}`);
}
} catch (error) {
toast.error(`Wi-Fi details error: ${error}`);
console.log(error);
}
}
@@ -173,6 +201,7 @@ export class Controller {
controller.progressview.removeProgress("ota_upload")
const status = ajax.status;
if (status >= 200 && status < 300) {
toast.success("OTA firmware upload successful");
controller.reboot();
} else {
const statusText = ajax.statusText || "";
@@ -199,6 +228,7 @@ export class Controller {
const versionInfo = json as VersionInfo;
controller.progressview.removeProgress("version");
controller.firmWareView.setVersion(versionInfo);
toast.info("Firmware version information updated");
}
getBackupConfig() {
@@ -241,6 +271,7 @@ export class Controller {
.then(status => {
controller.progressview.removeProgress("set_config");
if (status == 200) {
toast.success("Configuration saved successfully");
setTimeout(() => {
controller.downloadConfig().then(() => {
controller.updateSaveList().then(() => {
@@ -268,7 +299,14 @@ export class Controller {
fetch(PUBLIC_URL + "/time", {
method: "POST",
body: pretty
}).then(
})
.then(response => {
if (!response.ok) {
toast.error(`Failed to sync RTC: ${response.status}`);
}
return response;
})
.then(
_ => controller.progressview.removeProgress("write_rtc")
)
}
@@ -288,9 +326,23 @@ export class Controller {
}
selfTest() {
controller.progressview.addIndeterminate("self_test", "Running board test")
fetch(PUBLIC_URL + "/boardtest", {
method: "POST"
})
.then(response => {
if (response.ok) {
toast.success("Board test completed");
} else {
toast.error(`Board test failed: ${response.status}`);
}
})
.catch(error => {
toast.error(`Board test error: ${error}`);
})
.finally(() => {
controller.progressview.removeProgress("self_test");
});
}
testNightLamp(active: boolean) {
@@ -298,21 +350,52 @@ export class Controller {
active: active
};
var pretty = JSON.stringify(body, undefined, 1);
controller.progressview.addIndeterminate("night_lamp_test", "Testing night lamp")
fetch(PUBLIC_URL + "/lamptest", {
method: "POST",
body: pretty
})
.then(response => {
if (response.ok) {
toast.success(`Night lamp ${active ? "enabled" : "disabled"} successfully`);
} else {
toast.error(`Night lamp test failed: ${response.status}`);
}
})
.catch(error => {
toast.error(`Night lamp test error: ${error}`);
})
.finally(() => {
controller.progressview.removeProgress("night_lamp_test");
});
}
testFertilizerPump() {
controller.progressview.addIndeterminate("fert_test", "Testing fertilizer pump")
fetch(PUBLIC_URL + "/fertilizerpumptest", {
method: "POST"
})
.then(response => {
if (response.ok) {
toast.success("Fertilizer pump test completed");
} else {
toast.error(`Fertilizer pump test failed: ${response.status}`);
}
})
.catch(error => {
toast.error(`Fertilizer pump test error: ${error}`);
})
.finally(() => {
controller.progressview.removeProgress("fert_test");
});
}
testPlant(plantId: number) {
const plantConfig = controller.getConfig().plants[plantId];
const pumpTimeS = plantConfig.pump_time_s;
let counter = 0
let limit = 30
let limit = pumpTimeS > 0 ? Math.ceil(pumpTimeS) : 30
controller.progressview.addProgress("test_pump", counter / limit * 100, "Testing pump " + (plantId + 1) + " for " + (limit - counter) + "s")
let timerId: string | number | NodeJS.Timeout | undefined
@@ -341,6 +424,11 @@ export class Controller {
controller.plantViews.setPumpTestCurrent(plantId, response);
clearTimeout(timerId);
controller.progressview.removeProgress("test_pump");
if (!response.error) {
toast.success(`Pump ${plantId + 1} test completed successfully`);
} else {
toast.error(`Pump ${plantId + 1} test reported an error`);
}
}
)
}
@@ -425,13 +513,20 @@ export class Controller {
if (ajax.readyState === 4) {
clearTimeout(timerId);
controller.progressview.removeProgress("scan_ssid");
this.networkView.setScanResult(ajax.response as SSIDList)
if (ajax.status >= 200 && ajax.status < 300) {
this.networkView.setScanResult(ajax.response as SSIDList);
toast.success("WiFi scan completed");
// Also fetch detailed Wi-Fi information
this.scanWifiDetails();
} else {
toast.error(`WiFi scan failed: ${ajax.status}`);
}
}
};
ajax.onerror = (_) => {
clearTimeout(timerId);
controller.progressview.removeProgress("scan_ssid");
alert("Failed to start see console")
toast.error("Failed to start WiFi scan");
}
ajax.open("POST", PUBLIC_URL + "/wifiscan");
ajax.send();
@@ -478,12 +573,15 @@ export class Controller {
return fetch(PUBLIC_URL + "/moisture")
.then(response => response.json())
.then(json => json as Moistures)
.then(time => {
controller.plantViews.update(time.moisture_a, time.moisture_b)
.then(data => {
controller.plantViews.update(data.moisture_a, data.moisture_b, data.sensor_a_build_minutes, data.sensor_b_build_minutes)
clearTimeout(timerId);
if (!silent) {
controller.progressview.removeProgress("measure_moisture");
}
if (!silent) {
toast.success("Moisture measurement completed");
}
})
.catch(error => {
@@ -629,6 +727,7 @@ export class Controller {
};
await this.detectSensors(detection, true);
} catch (e) {
toast.error(`Auto-refresh error: ${e}`);
console.error("Auto-refresh error", e);
}
@@ -649,6 +748,7 @@ const tasks = [
{task: controller.updateRTCData, displayString: "Updating RTC Data"},
{task: controller.updateBatteryData, displayString: "Updating Battery Data"},
{task: controller.updateSolarData, displayString: "Updating Solar Data"},
{task: () => controller.measure_moisture(true), displayString: "Measuring Moisture"},
{task: controller.downloadConfig, displayString: "Downloading Configuration"},
{task: controller.version, displayString: "Fetching Version Information"},
{task: controller.updateSaveList, displayString: "Updating Save Slots"},
@@ -666,6 +766,7 @@ async function executeTasksSequentially() {
try {
await task();
} catch (error) {
toast.error(`Error executing task '${displayString}': ${error}`);
console.error(`Error executing task '${displayString}':`, error);
// Optionally, you can decide whether to continue or break on errors
break;
@@ -681,6 +782,11 @@ executeTasksSequentially().then(() => {
controller.progressview.removeProgress("rebooting");
window.addEventListener("beforeunload", (event) => {
// Only check for unsaved changes if initialConfig has been loaded
if (controller.initialConfig === null) {
return;
}
const currentConfig = controller.getConfig();
// Check if the current state differs from the initial configuration

View File

@@ -85,7 +85,15 @@
<input class="mqttvalue" type="text" id="mqtt_password" placeholder="">
</div>
</div>
<div class="subcontainer">
<div class="flexcontainer">
<div class="subtitle">Wi-Fi Scan Results</div>
</div>
<div id="wifi-results">
<p>Scan for available networks to see signal strength</p>
</div>
</div>
</div>
</div>

View File

@@ -1,7 +1,9 @@
import { Controller } from "./main";
import {NetworkConfig, SSIDList} from "./api";
import {NetworkConfig, SSIDList, WifiScanResult} from "./api";
export class NetworkConfigView {
private wifiResults: HTMLElement;
setScanResult(ssidList: SSIDList) {
this.ssidlist.innerHTML = ''
for (const ssid of ssidList.ssids) {
@@ -10,6 +12,47 @@ export class NetworkConfigView {
this.ssidlist.appendChild(wi);
}
}
async scanAndDisplayWifiDetails() {
try {
const response = await fetch('/wifi_details');
if (response.ok) {
const data: WifiScanResult[] = await response.json();
this.displayWifiResults(data);
}
} catch (error) {
console.error('Error fetching Wi-Fi details:', error);
this.displayWifiResults([]);
}
}
displayWifiResults(results: WifiScanResult[]) {
const wifiContainer = document.getElementById('wifi-results');
if (!wifiContainer) return;
if (results.length === 0) {
wifiContainer.innerHTML = '<p>No Wi-Fi networks found</p>';
return;
}
let html = '<table style="width:100%; border-collapse: collapse;">';
html += '<tr><th style="text-align:left; padding: 8px;">SSID</th>';
html += '<th style="text-align:left; padding: 8px;">Signal (RSSI)</th>';
html += '<th style="text-align:left; padding: 8px;">Channel</th>';
html += '<th style="text-align:left; padding: 8px;">Authentication</th></tr>';
results.forEach(result => {
html += '<tr style="border-bottom: 1px solid #ddd;">';
html += `<td style="padding: 8px;">${result.ssid}</td>`;
html += `<td style="padding: 8px;">${result.rssi} dBm</td>`;
html += `<td style="padding: 8px;">${result.channel}</td>`;
html += `<td style="padding: 8px;">${result.auth_method}</td>`;
html += '</tr>';
});
html += '</table>';
wifiContainer.innerHTML = html;
}
private readonly ap_ssid: HTMLInputElement;
private readonly ssid: HTMLInputElement;
private readonly password: HTMLInputElement;
@@ -47,9 +90,14 @@ export class NetworkConfigView {
this.ssidlist = document.getElementById("ssidlist") as HTMLElement
let scanWifiBtn = document.getElementById("scan") as HTMLButtonElement;
scanWifiBtn.onclick = function (){
scanWifiBtn.onclick = async () => {
controller.scanWifi();
}
// After Wi-Fi scan, fetch and display detailed results
await this.scanAndDisplayWifiDetails();
};
// Store wifiResults reference for later use
this.wifiResults = document.getElementById('wifi-results') as HTMLElement;
}
setConfig(network: NetworkConfig) {

View File

@@ -29,6 +29,9 @@
.plantSensorEnabledOnly_ ${plantId} {
}
.plantBothSensorsOnly_ ${plantId} {
}
.plantHidden_ ${plantId} {
display: none;
}
@@ -48,6 +51,14 @@
<div class="plantkey">Sensor B installed:</div>
<input class="plantcheckbox" id="plant_${plantId}_sensor_b" type="checkbox">
</div>
<div class="flexcontainer plantBothSensorsOnly_${plantId}">
<div class="plantkey">Sensor Combine Mode:</div>
<select class="plantvalue" id="plant_${plantId}_sensor_combine_mode">
<option value="Min">Min</option>
<option value="Max">Max</option>
<option value="Avg">Average</option>
</select>
</div>
<div class="flexcontainer">
<div class="plantkey">
Mode:

View File

@@ -36,11 +36,19 @@ export class PlantViews {
return rv
}
update(moisture_a: [string], moisture_b: [string]) {
update(moisture_a: [string], moisture_b: [string], sensor_a_build_minutes?: Array<number | null>, sensor_b_build_minutes?: Array<number | null>) {
for (let plantId = 0; plantId < PLANT_COUNT; plantId++) {
const a = moisture_a[plantId]
const b = moisture_b[plantId]
this.plants[plantId].setMeasurementResult(a, b)
// Update firmware build timestamps if provided
if (sensor_a_build_minutes && sensor_a_build_minutes[plantId] !== undefined) {
this.plants[plantId].setFirmwareBuild("sensor_a", sensor_a_build_minutes[plantId])
}
if (sensor_b_build_minutes && sensor_b_build_minutes[plantId] !== undefined) {
this.plants[plantId].setFirmwareBuild("sensor_b", sensor_b_build_minutes[plantId])
}
}
}
@@ -85,6 +93,7 @@ export class PlantView {
private readonly pumpHourEnd: HTMLSelectElement;
private readonly sensorAInstalled: HTMLInputElement;
private readonly sensorBInstalled: HTMLInputElement;
private readonly sensorCombineMode: HTMLSelectElement;
private readonly mode: HTMLSelectElement;
private readonly moistureA: HTMLElement;
private readonly moistureB: HTMLElement;
@@ -228,6 +237,14 @@ export class PlantView {
controller.configChanged()
}
this.sensorCombineMode = document.getElementById("plant_" + plantId + "_sensor_combine_mode") as HTMLSelectElement;
this.sensorCombineMode.onchange = function () {
controller.configChanged()
}
// Initial visibility update for sensor combine mode
this.updateSensorCombineModeState();
this.minPumpCurrentMa = document.getElementById("plant_" + plantId + "_min_pump_current_ma") as HTMLInputElement;
this.minPumpCurrentMa.onchange = function () {
controller.configChanged()
@@ -263,6 +280,19 @@ export class PlantView {
};
}
updateSensorCombineModeState() {
const bothActive = this.sensorAInstalled.checked && this.sensorBInstalled.checked;
const bothOnlyElements = document.getElementsByClassName("plantBothSensorsOnly_" + this.plantId);
for (const element of Array.from(bothOnlyElements)) {
if (bothActive) {
element.classList.remove("plantHidden_" + this.plantId);
} else {
element.classList.add("plantHidden_" + this.plantId);
}
}
this.sensorCombineMode.disabled = !bothActive;
}
updateVisibility(plantConfig: PlantConfig) {
let sensorOnly = document.getElementsByClassName("plantSensorEnabledOnly_" + this.plantId)
let pumpOnly = document.getElementsByClassName("plantPumpEnabledOnly_" + this.plantId)
@@ -316,6 +346,9 @@ export class PlantView {
// element.classList.add("plantHidden_" + this.plantId)
// }
// }
// Update sensor combine mode visibility based on whether both sensors are active
this.updateSensorCombineModeState();
}
setTestResult(result: PumpTestResult) {
@@ -346,6 +379,7 @@ export class PlantView {
this.pumpHourEnd.value = plantConfig.pump_hour_end.toString();
this.sensorBInstalled.checked = plantConfig.sensor_b;
this.sensorAInstalled.checked = plantConfig.sensor_a;
this.sensorCombineMode.value = plantConfig.sensor_combine_mode || "Min";
this.maxConsecutivePumpCount.value = plantConfig.max_consecutive_pump_count.toString();
this.minPumpCurrentMa.value = plantConfig.min_pump_current_ma.toString();
this.maxPumpCurrentMa.value = plantConfig.max_pump_current_ma.toString();
@@ -375,6 +409,7 @@ export class PlantView {
pump_hour_end: +this.pumpHourEnd.value,
sensor_b: this.sensorBInstalled.checked,
sensor_a: this.sensorAInstalled.checked,
sensor_combine_mode: this.sensorCombineMode.value,
max_consecutive_pump_count: this.maxConsecutivePumpCount.valueAsNumber,
moisture_sensor_min_frequency: this.moistureSensorMinFrequency.valueAsNumber || null,
moisture_sensor_max_frequency: this.moistureSensorMaxFrequency.valueAsNumber || null,
@@ -406,4 +441,12 @@ export class PlantView {
this.sensorAFwBuild.innerText = formatBuildMinutes(plantResult.sensor_a);
this.sensorBFwBuild.innerText = formatBuildMinutes(plantResult.sensor_b);
}
setFirmwareBuild(sensor: "sensor_a" | "sensor_b", buildMinutes: number | null) {
if (sensor === "sensor_a") {
this.sensorAFwBuild.innerText = formatBuildMinutes(buildMinutes);
} else {
this.sensorBFwBuild.innerText = formatBuildMinutes(buildMinutes);
}
}
}

View File

@@ -1,5 +1,6 @@
import {Controller} from "./main";
import {BackupHeader} from "./api";
import {toast} from "./toast";
export class SubmitView {
json: HTMLDivElement;
@@ -26,25 +27,28 @@ export class SubmitView {
this.submit_status = document.getElementById("submit_status") as HTMLElement
this.submitFormBtn.onclick = () => {
controller.uploadConfig(this.json.textContent as string, (status: string) => {
if (status != "OK") {
// Show error toast (click to dismiss only)
const {toast} = require('./toast');
toast.error(status);
} else {
// Show info toast (auto hides after 5s, or click to dismiss sooner)
const {toast} = require('./toast');
toast.info('Config uploaded successfully');
}
toast.info(status);
this.submit_status.innerHTML = status;
});
}
this.backupBtn.onclick = () => {
controller.progressview.addIndeterminate("backup", "Backup to EEPROM running")
controller.backupConfig(this.json.textContent as string).then(saveStatus => {
if (saveStatus === "OK") {
toast.success("Configuration backup successful");
} else {
toast.error(`Backup failed: ${saveStatus}`);
}
controller.getBackupInfo().then(r => {
controller.progressview.removeProgress("backup")
this.submit_status.innerHTML = saveStatus;
});
}).catch(error => {
toast.error(`Backup error: ${error}`);
controller.getBackupInfo().then(r => {
controller.progressview.removeProgress("backup")
this.submit_status.innerHTML = "Error";
});
});
}
this.restoreBackupBtn.onclick = () => {

View File

@@ -93,28 +93,28 @@ export class TankConfigView {
this.tank_measure_error.innerText = JSON.stringify(tankinfo.sensor_error) ;
this.tank_measure_error_container.classList.remove("hidden")
}
if (tankinfo.left_ml == null){
if (tankinfo.volume_ml == null){
this.tank_measure_ml_container.classList.add("hidden")
} else {
this.tank_measure_ml.innerText = tankinfo.left_ml.toString();
this.tank_measure_ml.innerText = tankinfo.volume_ml.toString();
this.tank_measure_ml_container.classList.remove("hidden")
}
if (tankinfo.percent == null){
if (tankinfo.fill_pct == null){
this.tank_measure_percent_container.classList.add("hidden")
} else {
this.tank_measure_percent.innerText = tankinfo.percent.toString();
this.tank_measure_percent.innerText = tankinfo.fill_pct.toString();
this.tank_measure_percent_container.classList.remove("hidden")
}
if (tankinfo.water_temp == null){
if (tankinfo.water_temp_c == null){
this.tank_measure_temperature_container.classList.add("hidden")
} else {
this.tank_measure_temperature.innerText = tankinfo.water_temp.toString();
this.tank_measure_temperature.innerText = tankinfo.water_temp_c.toString();
this.tank_measure_temperature_container.classList.remove("hidden")
}
if (tankinfo.raw == null){
if (tankinfo.fill_raw_v == null){
this.tank_measure_rawvolt_container.classList.add("hidden")
} else {
this.tank_measure_rawvolt.innerText = tankinfo.raw.toString();
this.tank_measure_rawvolt.innerText = tankinfo.fill_raw_v.toString();
this.tank_measure_rawvolt_container.classList.remove("hidden")
}

View File

@@ -1,94 +1,432 @@
class ToastService {
private container: HTMLElement;
private stylesInjected = false;
/**
* Toast notification service for PlantCtrl embedded web interface
* Provides non-blocking notifications with auto-dismiss and click-to-close functionality
*/
constructor() {
this.container = this.ensureContainer();
this.injectStyles();
}
const TOAST_container_ID = 'toast-container';
const TOAST_STYLES_KEY = 'toast-styles-injected';
info(message: string, timeoutMs: number = 5000) {
const el = this.createToast(message, 'info');
this.container.appendChild(el);
// Auto-dismiss after timeout
const timer = window.setTimeout(() => this.dismiss(el), timeoutMs);
// Dismiss on click immediately
el.addEventListener('click', () => {
window.clearTimeout(timer);
this.dismiss(el);
});
}
interface ToastOptions {
duration?: number;
dismissible?: boolean;
}
error(message: string) {
console.error(message);
const el = this.createToast(message, 'error');
this.container.appendChild(el);
// Only dismiss on click
el.addEventListener('click', () => this.dismiss(el));
}
interface ToastData {
id: string;
type: 'info' | 'success' | 'warning' | 'error';
message: string;
createdAt: number;
element?: HTMLElement;
}
private dismiss(el: HTMLElement) {
if (!el.parentElement) return;
el.parentElement.removeChild(el);
}
/**
* Toast service for displaying notifications
*/
export class ToastService {
private container: HTMLElement | null = null;
private activeToasts: Map<string, ToastData> = new Map();
private maxToasts: number = 5;
private createToast(message: string, type: 'info' | 'error'): HTMLElement {
const div = document.createElement('div');
div.className = `toast ${type}`;
div.textContent = message;
div.setAttribute('role', 'status');
div.setAttribute('aria-live', 'polite');
return div;
}
// Default configuration
private defaultDuration: number = 5000; // 5 seconds for info messages
private errorDuration: number = 10000; // 10 seconds for error messages
private ensureContainer(): HTMLElement {
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
document.body.appendChild(container);
constructor() {
this.init();
}
return container;
}
private injectStyles() {
if (this.stylesInjected) return;
const style = document.createElement('style');
style.textContent = `
/**
* Initialize the toast container and inject styles
*/
private init(): void {
this.ensureContainer();
this.injectStyles();
}
/**
* Get or create the toast container element
*/
private ensureContainer(): HTMLElement {
if (this.container) return this.container;
let container = document.getElementById(TOAST_container_ID);
if (!container) {
container = document.createElement('div');
container.id = TOAST_container_ID;
container.setAttribute('role', 'region');
container.setAttribute('aria-label', 'Notifications');
document.body.appendChild(container);
}
this.container = container;
return container;
}
/**
* Inject toast styles if not already injected
*/
private injectStyles(): void {
if (document.querySelector(`style[data-id="${TOAST_STYLES_KEY}"]`)) {
return;
}
const style = document.createElement('style');
style.setAttribute('data-id', TOAST_STYLES_KEY);
style.textContent = `
#toast-container {
position: fixed;
top: 12px;
right: 12px;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
gap: 10px;
z-index: 9999;
max-width: 400px;
pointer-events: none;
}
.toast {
max-width: 320px;
padding: 10px 12px;
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
cursor: pointer;
user-select: none;
font-family: sans-serif;
background: #fff;
border-left: 4px solid transparent;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 8px;
padding: 12px 16px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-size: 14px;
line-height: 1.3;
line-height: 1.4;
color: #333;
max-width: 100%;
pointer-events: auto;
animation: toast-slide-in 0.3s cubic-bezier(0.4, 0, 0.2, 1),
toast-fade-in 0.3s ease-out;
display: flex;
align-items: center;
gap: 12px;
}
.toast.info {
background-color: #d4edda; /* green-ish */
color: #155724;
border-left: 4px solid #28a745;
border-color: #3b82f6;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
}
.toast.success {
border-color: #22c55e;
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
}
.toast.warning {
border-color: #f59e0b;
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
}
.toast.error {
background-color: #f8d7da; /* red-ish */
color: #721c24;
border-left: 4px solid #dc3545;
border-color: #ef4444;
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
}
`;
document.head.appendChild(style);
this.stylesInjected = true;
.toast:hover {
transform: translateX(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.toast-icon {
flex-shrink: 0;
font-size: 18px;
}
.toast-message {
flex-grow: 1;
word-wrap: break-word;
overflow-wrap: anywhere;
}
.toast-close-btn {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
padding: 4px;
margin-left: -4px;
opacity: 0.6;
transition: opacity 0.2s;
color: inherit;
}
.toast-close-btn:hover {
opacity: 1;
}
@keyframes toast-slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes toast-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes toast-dismiss {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
/**
* Create a unique ID for toast messages
*/
private generateId(): string {
return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
/**
* Create a toast element
*/
private createToast(type: 'info' | 'success' | 'warning' | 'error', message: string): HTMLElement {
const div = document.createElement('div');
div.className = 'toast';
div.classList.add(type);
// Add icon based on type
const icon = this.getIconForType(type);
div.innerHTML = `
<span class="toast-icon">${icon}</span>
<span class="toast-message">${this.escapeHtml(message)}</span>
<button class="toast-close-btn" aria-label="Dismiss notification">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
`;
return div;
}
/**
* Get icon based on toast type
*/
private getIconForType(type: 'info' | 'success' | 'warning' | 'error'): string {
const icons: Record<string, string> = {
info: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>',
success: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>',
warning: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>',
error: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>'
};
return icons[type] || icons.info;
}
/**
* Escape HTML to prevent XSS
*/
private escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, (char) => map[char] || char);
}
/**
* Display an info toast notification
*/
info(message: string, options?: ToastOptions): void {
const duration = options?.duration ?? this.defaultDuration;
this.showToast('info', message, duration);
}
/**
* Display a success toast notification
*/
success(message: string, options?: ToastOptions): void {
const duration = options?.duration ?? this.defaultDuration;
this.showToast('success', message, duration);
}
/**
* Display a warning toast notification
*/
warning(message: string, options?: ToastOptions): void {
const duration = options?.duration ?? this.defaultDuration;
this.showToast('warning', message, duration);
}
/**
* Display an error toast notification
*/
error(message: string, options?: ToastOptions): void {
console.error(`[Toast Error] ${message}`);
const duration = options?.duration ?? this.errorDuration;
this.showToast('error', message, duration);
}
/**
* Show a toast notification with the given type
*/
private showToast(type: 'info' | 'success' | 'warning' | 'error', message: string, duration: number): void {
// Limit the number of concurrent toasts
this.limitToasts();
const id = this.generateId();
const element = this.createToast(type, message);
const container = this.ensureContainer();
// Add to active toasts
this.activeToasts.set(id, { id, type, message, createdAt: Date.now() });
// Append to container
container.appendChild(element);
// Store reference
this.activeToasts.get(id)!.element = element;
// Set up auto-dismiss timer
let dismissTimer: number | undefined;
const scheduleDismiss = () => {
if (duration > 0) {
dismissTimer = window.setTimeout(() => this.dismiss(id), duration);
}
};
// Setup click to dismiss
const handleClick = () => {
if (dismissTimer !== undefined) {
window.clearTimeout(dismissTimer);
dismissTimer = undefined;
}
this.dismiss(id);
};
// Setup close button handler
const closeBtn = element.querySelector('.toast-close-btn');
if (closeBtn) {
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
handleClick();
});
}
// Setup click on toast to dismiss
element.addEventListener('click', handleClick);
// Start timer
scheduleDismiss();
}
/**
* Dismiss a toast by ID
*/
dismiss(id: string): void {
const toastData = this.activeToasts.get(id);
if (!toastData || !toastData.element) return;
const element = toastData.element;
// Add dismiss animation
element.style.animation = 'toast-dismiss 0.2s ease-in forwards';
// Remove from DOM after animation
setTimeout(() => {
if (element.parentElement) {
element.parentElement.removeChild(element);
}
this.activeToasts.delete(id);
// Ensure container exists before trying to append
if (this.container) {
this.moveToasts();
}
}, 200);
}
/**
* Remove a toast by element reference
*/
dismissElement(element: HTMLElement): void {
const entries = Array.from(this.activeToasts.entries());
for (const [id, data] of entries) {
if (data.element === element) {
this.dismiss(id);
break;
}
}
}
/**
* Limit the number of concurrent toasts
*/
private limitToasts(): void {
if (this.container && this.activeToasts.size >= this.maxToasts) {
// Dismiss the oldest toast
const oldestId = Array.from(this.activeToasts.keys())[0];
if (oldestId) {
this.dismiss(oldestId);
}
}
}
/**
* Move toasts to ensure proper stacking
*/
private moveToasts(): void {
if (!this.container) return;
// Remove any empty container
if (this.activeToasts.size === 0) {
if (this.container.parentElement) {
this.container.parentElement.removeChild(this.container);
}
this.container = null;
}
}
/**
* Clear all active toasts
*/
clear(): void {
const ids = Array.from(this.activeToasts.keys());
for (const id of ids) {
this.dismiss(id);
}
}
/**
* Get the number of active toasts
*/
getActiveCount(): number {
return this.activeToasts.size;
}
/**
* Set the maximum number of concurrent toasts
*/
setMaxToasts(count: number): void {
this.maxToasts = count;
this.limitToasts();
}
}
// Export a singleton instance
export const toast = new ToastService();

View File

@@ -0,0 +1,194 @@
---
title: "CAN Bus Protocol"
date: 2026-05-21
draft: false
description: "Complete documentation of the CAN bus communication protocol between PlantCtrl sensor modules and main controller."
tags: ["can", "protocol", "sensor"]
---
# CAN Bus Protocol
The PlantCtrl system uses a custom **CAN bus-based communication protocol** to connect sensor modules (moisture sensors) with the MainBoard controller. This modular design allows for scalable, reliable digital communication even over long cable runs and in electrically noisy environments.
## Overview
- **Protocol**: Standard CAN 2.0A (11-bit identifier)
- **Baud Rate**: 50 kbps
- **Base Address**: `0x03E8` (decimal 1000)
- **Maximum Plants**: 16 per sensor module
- **Sensors per Plant**: 2 slots (A and B) for redundancy or larger planters
## CAN Bus IDs
All messages use the standard base address `0x03E8` with message-specific offsets. The ID structure is:
```
ID = 0x03E8 + Message_Offset + Plant_Index (+ Slot_Offset if B)
```
### Message Groups
| Group | Offset (hex) | Direction | Description |
|-------|--------------|-----------|-------------|
| Moisture Data | `0x00` | Sensor → Controller | Periodic moisture readings |
| Identify Command | `0x20` | Controller → Sensor | LED identification command |
| Firmware Build | `0x40` | Sensor → Controller | Compile-time build timestamp |
### Plant Addressing (Slots A & B)
Each plant gets two sensor slots:
- **Slot A**: Base offset + plant index (015)
- **Slot B**: Base offset + 16 + plant index (015)
#### Example ID Calculations
| Message Type | Plant | Slot | CAN ID (hex) |
|--------------|-------|------|-------------|
| Moisture Data | 0 | A | `0x03E8` |
| Moisture Data | 7 | A | `0x0415` |
| Moisture Data | 15 | A | `0x042F` |
| Identify Command | 0 | A | `0x0400` |
| Firmware Build | 3 | B | `0x0467` |
## Message Formats
All messages are serialized using **bincode v2** (fixed-size integers, no varints). Each message fits within a single CAN frame.
### Moisture Data (Sensor → Controller)
Sent periodically by each sensor module. Contains:
| Field | Type | Description |
|-------|------|-------------|
| `plant` | u8 | Plant index (015) |
| `sensor` | SensorSlot | A or B slot |
| `hz` | u16 | Measured frequency in Hz |
**Total size**: 4 bytes (fits easily in CAN frame)
### Identify Command (Controller → Sensor)
Sent by the controller to trigger an LED identification sequence on the sensor module.
| Field | Type | Description |
|-------|------|-------------|
| *(empty)* | - | No payload data |
**Purpose**: When received, the sensor blinks its status LED for a few seconds to confirm it's online and properly configured.
### Firmware Build (Sensor → Controller)
Sent immediately after receiving an Identify Command. Contains:
| Field | Type | Description |
|-------|------|-------------|
| `build_minutes` | u32 | Compile-time timestamp in minutes since Unix epoch |
**Purpose**: Allows the controller to track firmware versions and deployment history without requiring a separate request.
## Sensor Configuration
Each sensor module is configured via **hardware jumpers** on startup. The configuration is read by the CH32V203 MCU and determines:
- Whether it's Slot A or B for a plant
- The plant index (18)
### Hardware Switches
| Pin | Function |
|-----|----------|
| PA3 | **Slot selector**: Low = A, High = B |
| PA4 | Address bit 1 (value: 1) |
| PA5 | Address bit 2 (value: 2) |
| PA6 | Address bit 3 (value: 4) |
| PA7 | Address bit 4 (value: 8) |
### Valid Addresses
- **Allowed**: 18 (binary combinations of bits 1,2,4,8)
- **Invalid**: 0 or >8 (will trigger error code)
#### Example Configurations
| Address | Binary | Jumpers |
|---------|--------|----------|
| 1 | `0001` | PA4 only |
| 3 | `0011` | PA4 + PA5 |
| 7 | `0111` | PA4 + PA5 + PA6 |
| 8 | `1000` | PA7 only |
## Error Detection & Blink Codes
The sensor module performs **floating pin detection** at startup. If any configuration pin is left floating (not connected to VCC or GND), the system enters an error state and blinks a diagnostic code.
### Error Code Table
| Code | Cause | LED Pattern |
|------|-------|-------------|
| 1 | PB4 floating (bit 1) | 1 blink info, 2 blinks warning |
| 2 | PB5 floating (bit 2) | 2 blinks info, 2 blinks warning |
| 3 | PB6 floating (bit 3) | 3 blinks info, 2 blinks warning |
| 4 | PB7 floating (bit 4) | 4 blinks info, 2 blinks warning |
| 5 | PB3 floating (A/B selector) | 5 blinks info, 2 blinks warning |
| 6 | Invalid address (0 or >8) | 6 blinks info, 2 blinks warning |
### LED Indicators
- **Info LED** (PA10): Green indicates information state
- **Warning LED** (PA9): Yellow/Orange indicates error/warning state
The blink pattern repeats 5 times, then the system resets automatically.
## Address Collision Detection
The protocol includes built-in collision detection. If a sensor receives a moisture data packet addressed to itself, it triggers an error sequence:
- **Blink code**: 1 info blink, 2 warning blinks
- **Log message**: "We should never receive moisture packets addressed to ourselves"
This indicates another node is using the same jumper configuration.
## CAN Bus Robustness Features
The firmware implements several features for reliable operation:
### Automatic Retransmission (NART)
Enabled on the CH32V203 CAN controller to recover from transient errors without manual intervention.
### Resync Jump Width (SJW = 4TQ)
Increased from default (1TQ) to improve jitter tolerance over long cable runs. This allows the receiver to resynchronize with the bit stream even if timing drifts slightly.
### Error Status Monitoring
The controller monitors CAN error registers for:
- Bus-off condition (`BOFF`)
- Error warning flag (`EWGF`)
- Error passive flag (`EPVF`)
When errors are detected, warning LEDs blink and the system logs the status.
## Troubleshooting
### Sensor Not Detected
1. Check all jumpers ensure no floating pins
2. Verify address is 18 (not 0 or >8)
3. Confirm slot selector (A/B) matches expected configuration
4. Listen for CAN traffic with a CAN analyzer
5. Check error blink codes on the sensor module
### Address Collision
- Two sensors using identical jumper settings will cause collisions
- Use the collision detection feature to identify duplicate addresses
- Reconfigure one of the conflicting sensors to a different address
### Communication Errors
- **Bus-off**: Check CAN termination resistors (120Ω at each end)
- **High error rate**: Verify cable quality and shielding
- **Intermittent errors**: Check for electrical noise from pumps or motors
## Future Extensions
The protocol is designed to be extensible. New message types can be added by:
1. Defining a new offset in `canapi/src/lib.rs`
2. Implementing the corresponding message struct with bincode serialization
3. Adding receive handlers on both sensor and controller sides
4. Documenting the new ID range and format
The current design supports up to 64 distinct message types (16 plants × 2 slots × 2 directions) while maintaining a clean, plant-indexed addressing scheme.

View File

@@ -0,0 +1,142 @@
---
title: "CAN Bus IDs and Wire Format"
date: 2026-05-21
draft: false
description: "Quick reference for CAN bus identifiers, message formats, and on-the-wire data structures."
tags: ["can", "protocol", "wire-format"]
---
# CAN Bus IDs and Wire Format
A concise technical reference for the PlantCtrl CAN bus protocol.
## Quick Reference Table
| CAN ID (hex) | Message Type | Direction | Payload |
|--------------|--------------|-----------|----------|
| `0x03E8` | Moisture Data - Plant 0, Slot A | Sensor → Controller | u8 plant + u8 slot + u16 hz |
| `0x0400` | Identify Command - Plant 0, Slot A | Controller → Sensor | *(empty)* |
| `0x042F` | Moisture Data - Plant 15, Slot A | Sensor → Controller | u8 plant + u8 slot + u16 hz |
| `0x0467` | Firmware Build - Plant 3, Slot B | Sensor → Controller | u32 build_minutes |
## ID Calculation Formula
```
ID = 0x03E8 + Message_Offset + Plant_Index (+ Slot_Offset if B)
```
### Constants
| Constant | Value (hex) | Description |
|----------|-------------|-------------|
| `SENSOR_BASE_ADDRESS` | `0x03E8` | Base address for all messages |
| `MOISTURE_DATA_OFFSET` | `0x00` | Moisture data group |
| `IDENTIFY_CMD_OFFSET` | `0x20` | Identify command group |
| `FIRMWARE_BUILD_OFFSET` | `0x40` | Firmware build group |
| `B_SLOT_OFFSET` | `0x10` | Offset for Slot B within a group |
### Message Type Offsets
```rust
pub const MOISTURE_DATA_OFFSET: u16 = 0; // sensor → controller
pub const IDENTIFY_CMD_OFFSET: u16 = 32; // controller → sensor
pub const FIRMWARE_BUILD_OFFSET: u16 = 64; // sensor → controller
```
## On-the-Wire Formats
### Moisture Data Frame (Sensor → Controller)
**CAN ID**: `0x03E8 + offset + plant_index` (or `+ 16 + plant_index` for Slot B)
| Byte | Field | Type |
|------|-------|------|
| 0 | `plant` | u8 (015) |
| 1 | `sensor` | SensorSlot (A=0, B=1) |
| 2-3 | `hz` | u16 big-endian |
**Example**: Plant 7, Slot A, frequency 45 Hz
```
CAN ID: 0x0415
Payload: [07 00 00 2D]
plant=7, sensor=A, hz=45 (0x002D)
```
### Firmware Build Frame (Sensor → Controller)
**CAN ID**: `0x03E8 + 64 + plant_index` (or `+ 80 + plant_index` for Slot B)
| Byte | Field | Type |
|------|-------|------|
| 0-3 | `build_minutes` | u32 big-endian |
**Example**: Build timestamp 1,745,239,200 minutes since epoch (May 2026)
```
CAN ID: 0x0440
Payload: [00 00 6A F8]
build_minutes = 1,745,239,200
```
### Identify Command Frame (Controller → Sensor)
**CAN ID**: `0x03E8 + 32 + plant_index` (or `+ 48 + plant_index` for Slot B)
| Byte | Field | Type |
|------|-------|------|
| *(none)* | *(empty payload)* | - |
**Example**: Identify Plant 5, Slot A
```
CAN ID: 0x0410
Payload: (empty)
```
## Addressing Scheme Details
### Plant Index Range
- **Valid**: 015 (decimal) or 18 on hardware jumpers (mapped internally as 07)
- **Slot A**: `plant_index` = jumper value - 1
- **Slot B**: Same mapping, but ID offset differs by +16
### Slot Selection
| Hardware | Internal Value |
|----------|---------------|
| Jumper on PA3 (Low) | Slot A (0) |
| Jumper on PA3 (High) | Slot B (1) |
## Error Detection IDs
The sensor module monitors for unexpected messages:
- **Moisture Data collision**: If a sensor receives moisture data addressed to itself, it triggers error code 1 (1 info blink, 2 warning blinks)
- **CAN errors**: Bus-off, EWGF, EPVF flags trigger warning LED blinking
## Protocol Extensions
To add new message types:
1. Define offset in `canapi/src/lib.rs`:
```rust
pub const NEW_MESSAGE_OFFSET: u16 = 96; // Next available slot
```
2. Implement message struct with bincode serialization
3. Add receive handler on both sides
4. Update documentation
## Binary Protocol Reference
### bincode v2 Serialization
- **u8**: Single byte, no sign extension
- **u16**: 2 bytes big-endian (network order)
- **u32**: 4 bytes big-endian (network order)
- No varints fixed size for predictable CAN frame lengths
### CAN Frame Structure
```
| Arbitration Field | Control Field | Data Field (8 bytes) |
|-------------------|---------------|----------------------|
| 11-bit ID | RTR + IDE | Payload (max 4-6 bytes)|
```
All PlantCtrl messages fit within the 8-byte data field with room for CAN overhead.

View File

@@ -3,39 +3,55 @@ title: "BatteryManagement"
date: 2025-01-27
draft: false
description: "a description"
tags: ["battery", "bq34z100"]
tags: ["battery", "bms"]
---
# Battery Management Module
The project contains an additional companion board (Fuel Gauge), with a bq34z100 battery management IC.
It allows to track the health and charge for an external battery and is supposed to be soldered directly to the battery.
The MainBoard contains a connector for power, and additionally a two-pin I2C bus to communicate with the Battery Management module.
The PlantCtrl system uses an external **Battery Management System (BMS)** board that connects to the MainBoard. This module monitors battery voltage, current, and health metrics and communicates with the ESP32-C6 via I2C.
<!-- TODO: Add photo of the new modular Battery Management board -->
# Setup
{{< alert >}}
A protected Battery is required. There is only a very simplistic output voltage adjustment for the MPPT system and no charge termination. It is expected that the battery itself protects against overcharging and deep discharges!
The open-bms is a custom battery management board designed for this project. It uses a CH32V203 microcontroller to handle battery monitoring and protection. The older bq34z100-based battery management board is deprecated and located in the `__Legay_Unused` folder.
{{< /alert >}}
* BatteryManagement is purely optional, but recommended for solar power.
* If available it will be used for an extended low power deep sleep in case of critical charge.
* If available it will also be used, to reduce the nightlight, if the charge drops to a predefined level, so the nightlight cannot drain to much battery
* If available, all relevant battery metrics will be published via mqtt
Currently the setup requires a custom Ev2400 flasher and the properitary windows software from texas instruments.
{{< alert >}}
Before soldering to the battery
{{< /alert >}}
1. The voltage devider high side must be bridged, while being connected to the computer and being supplied with around 4.2 V from the battery solder leads.
2. Then the data/register for low voltage flash write protection should be set to 0V, as else with the voltage divider and no further configuration, the IC will refuse all write requests.
3. After this the supplied golden image can be used, it will setup the battery for 6Ah and a 4S lifepo. Different values can be adjusted after this to the users liking.
## Hardware
The Battery Management Board features:
* CH32V203 RISC-V microcontroller for battery monitoring
* I2C interface for communication with the MainBoard
* Battery voltage and current sensing
{{< alert >}}
The main board, does not care or process any of the charge discharge limits that can be set. Ensure that the battery can supply enough current as well as accept a 2.4A charging current from the MPPT system.
The open-bms board does not use the bq34z100 fuel gauge IC. That component was used in an older legacy design now located in the `__Legay_Unused` folder.
{{< /alert >}}
The golden image sets the statups led up, to be in blinky mode. one very long interval means, that the battery is pretty much full. A few very short flashes mean that the battery is nearly empty. No light means, that the battery is in discharge protection and shut down.
## Integration with MainBoard
If the red error led lights, something is wrong with the battery. This can be abnormal voltages or a very low health state.
The battery management board:
* Connects to the MainBoard via a two-pin I2C bus
* Provides power connection to the battery
* Reports battery metrics via MQTT (if configured)
# Todo?
If the battery reports that no discharging should occure, report this and then shutdown without using pumps
## Usage
* If available, the system will use battery metrics for deep sleep management when charge is critical
* The nightlight can be automatically disabled if battery level drops below a predefined threshold
* All battery metrics are published via MQTT when configured
* The system includes safety mechanisms to prevent overcharging and deep discharges through the battery's built-in protection circuitry
## Safety Notes
{{< alert >}}
The system requires a battery with built-in protection circuitry. The MPPT system does not include charge termination or overcharge protection - the battery itself must provide these safety features.
{{< /alert >}}
The CH32V203-based BMS monitors battery health and provides status information but does not control the charge/discharge limits. Ensure your battery can handle the maximum charging current from the MPPT system (up to 2.4A).
## Setup
1. **Connect Battery:** Connect your protected battery to the BMS board
2. **_connect MainBoard:** Connect the Battery Management Board to the MainBoard via the I2C bus connector
3. **Power On:** Power on the system and verify communication via MQTT
## Status Indicators
The BMS board includes status LEDs, they behave like every normal powerbank (1-5 lights, animted if charging)

View File

@@ -1,77 +0,0 @@
---
title: "Sensors&Pumps"
date: 2025-01-27
draft: false
description: "a description"
tags: ["sensor"]
---
# Sensors & Pumps Module
This functionality is now provided by dedicated modules that can be connected to the MainBoard.
# Sensors
The moisture sensing functionality is handled by a dedicated **CAN bus-based Sensor Module**. This modular approach allows for better scalability and reduces electrical interference by moving the measurement logic closer to the sensors and using digital communication.
## Sensor Module (CAN bus)
The standard sensor module features its own **CH32V203 RISC-V microcontroller**, which handles the measurement of soil moisture and communicates the results back to the MainBoard via the CAN bus.
* **Capacity:** Supports up to 16 sensors (typically 8 plants with an A and B sensor each).
* **Reliability:** Digital communication via CAN bus ensures data integrity even over longer cable runs and in electrically noisy environments.
* **Addressing:** The A sensor is always used; the B sensor is optional and suggested for larger planters to provide a better average of the soil moisture.
## Sensor Hardware
The sensors themselves remain simple and cost-effective:
* **Design:** Two spikes with a defined distance.
* **DIY Friendly:** Can be bought readymade or easily made with two long nails (galvanized or stainless steel suggested to prevent rusting).
## Measurement Principle
The new CAN-based sensor module uses a sophisticated measurement technique that replaces the outdated 555-oscillator and multiplexer design. By using a dedicated MCU for measurement:
* **Minimized Corrosion:** The system changes polarity between measurements, minimizing corrosion due to organic battery effects (electrolysis) and preventing errors caused by building up a DC voltage in the soil.
* **Interference Resistance:** The measurement is resistant to common failure signals, such as 50Hz hum from nearby power circuits.
* **Digital Accuracy:** The local MCU processes the analog signals and sends precise digital values to the MainBoard.
# Pumps
The Pump module contains low side switched pump outputs. The pumps are running directly from the battery without further voltage conversion, so ensure that they can survive the full voltage range of the battery.
Each output can supply up to 3A continously.
The board will never switch more than one output concurrently, so there is no need to size the battery for higher maximum load.
An additinal extra output exists, that is switched when any of the pump outputs is supposed to run.
<!-- TODO: Add photo of the new modular Pump and Sensor boards -->
This allows for multiple possible setups
## Layout Central Pump
One central pump is connected to the extra output, and multiple magnetic valves are used for the different plants
## Multi Gravity Feed Valves
Per plant a Valve that can close against pressure is used, no pump exists
## Multi Pump Setup
Multiple smaller cheaper pumps with no shared hoses, so that failures will only affect a single planter.
In any case I suggest to use a Water Filter on the Intake, as else you will get severe algae problems.
In my personal opinion small membrane pumps are a really good fit
* can be housed outside the tank
* require less maintance/cleaning
* are able to pump smaller impurities without issues.
* Can pull water 1-2meters
* Have higher output pressure -> Will blow out blockages in hoses
However
* are louder
* pump less volume per time and energy
{{< alert >}}
DO NOT DIRECTLY CONNECT TO WATER MAINS, YOU HAVE BEEN WARNED!
Software and Hardware may fail: It is your responsibility to ensure that a stuck valve or short circuit mosfet will not cause flooding and property destruction, for example by limiting the water tank to size that can drain.
{{< /alert >}}
# Todo
## Flow Sensor
There is a input for a flow sensor, currently it is not used as the software is missing.
* Allow monitoring if pumps are actually moving water
* Allow to set limits for how much ml are allowed additinally to the current time limit per watering run
Currently it cannot be set how two sensor should be interpreted and they are only averaged. More complex functions would be nice here, eg. allowing a user settable interpolation (0.8*a+0.2*b)/2 and Min(a,b) as well as max(a,b)

View File

@@ -11,8 +11,6 @@ tags: ["esp32", "hardware"]
<img src="pcb_back.png" class="grid-w50" />
{{< /gallery >}}
<!-- TODO: Add new screenshots of the modular PCB setup -->
{{< gitea server="https://git.mannheim.ccc.de/" repo="C3MA/PlantCtrl" >}}
## Modular Design
@@ -27,17 +25,25 @@ The system now consists of a **MainBoard** which acts as the controller and seve
* **Fully Open Source:** Designed in KiCad
## Available Modules
* **MPPT Charger:** Efficient solar charging for batteries.
* **Pump Driver:** High-current outputs for pumps and valves.
* **Sensor Interface:** Support for multiple moisture sensors.
* **Light Controller:** For LED nightlights or growth lights.
* **MPPT Charger:** Efficient solar charging for batteries using CN3795.
* **Pump Driver:** High-current outputs (up to 3A) for pumps and valves.
* **Sensor Module:** CAN bus-based moisture sensors using CH32V203 microcontroller.
* **Battery Management:** External BMS board with CH32V203 for battery monitoring.
* **Light Controller:** For LED nightlights or growth lights using AP63200.
## Sensor Module (CAN bus)
The standard sensor module features its own **CH32V203 RISC-V microcontroller**, which handles the measurement of soil moisture and communicates the results back to the MainBoard via the CAN bus.
* **Capacity:** Supports up to 16 sensors (typically 8 plants with an A and B sensor each).
* **Reliability:** Digital communication via CAN bus ensures data integrity even over longer cable runs and in electrically noisy environments.
* **Addressing:** The A sensor is always used; the B sensor is optional and suggested for larger planters to provide a better average of the soil moisture.
## Capabilities
* **Moisture Sensors:** Supports multiple capacitive or resistive sensors via expansion modules.
* **Moisture Sensors:** Supports multiple capacitive or resistive sensors via CAN bus-based Sensor Modules.
* **Pumps/Valves:** Support for multiple independent watering zones.
* **Power:**
* Solar powered with MPPT
* Battery powered with optional Battery Management (Fuel Gauge)
* Battery powered with optional Battery Management System (BMS)
* Can also be used with a standard power supply (7-24V)
* **Efficient Power:** Use of high-efficiency DC-DC converters for 3.3V and peripherals.

View File

@@ -6,9 +6,12 @@ description: "a description"
tags: ["firmeware", "upload"]
---
# From Source
The PlantCtrl firmware is written in Rust for the ESP32-C6 RISC-V microcontroller.
## Preconditions
* **Rust:** Current version of `rustup`.
* **ESP32 Toolchain:** `espup` installed and configured.
* **ESP32 Toolchain:** `espup` installed and configured for ESP32-C6.
* **espflash:** Installed via `cargo install espflash`.
* **Node.js:** `npm` installed (for the web interface).
@@ -37,10 +40,8 @@ You can use the provided bash scripts to automate the build and flash process:
You can also update the firmware wirelessly if the system is already running and connected to your network.
1. Generate the OTA binary:
```bash
cargo build --release
```
2. The binary will be at `target/riscv32imac-unknown-none-elf/release/plant-ctrl2`.
**`./image.sh`**
2. The binary will be `image.bin`.
3. Open the PlantCtrl web interface in your browser.
4. Navigate to the **OTA** section.
5. Upload the `plant-ctrl2` file.

View File

@@ -6,23 +6,26 @@ description: "a description"
tags: ["mqtt", "esp"]
---
# MQTT
A configured MQTT server will receive statistical and status data from the controller.
The PlantCtrl firmware publishes comprehensive status and telemetry data via MQTT when configured. The system uses the **mcutie** crate for Home Assistant integration and standard MQTT topics.
### Topics
| Topic | Example | Description |
|-------|---------|-------------|
| `firmware/address` | `192.168.1.2` | IP address in station mode |
| `firmware/state` | `VersionInfo { ... }` | Debug information about the current firmware and OTA slots |
| `firmware/state` | `{...}` | Debug information about the current firmware and OTA slots |
| `firmware/last_online` | `2025-01-22T08:56:46.664+01:00` | Last time the board was online |
| `state` | `online` | Current state of the controller |
| `mppt` | `{"current_ma":1200,"voltage_ma":18500}` | MPPT charging metrics |
| `battery` | `{"Info":{"voltage_milli_volt":12860,"average_current_milli_ampere":-16,...}}` | Battery health and charge data |
| `water` | `{"enough_water":true,"warn_level":false,"left_ml":1337,...}` | Water tank status |
| `plant{1-8}` | `{"sensor_a":...,"sensor_b":...,"mode":"TargetMoisture",...}` | Detailed status for each plant slot |
| `pump{1-8}` | `{"enabled":true,"pump_ineffective":false,...}` | Metrics for the last pump activity |
| `mppt` | `{"current_ma":1200,"voltage_ma":18500}` | MPPT charging metrics (current and voltage from solar panel) |
| `battery` | `{"Info":{"voltage_milli_volt":12860,"state_of_charge":95,...}}` | Battery health and charge data from the BMS |
| `water` | `{"enough_water":true,"warn_level":false,"left_ml":1337,...}` | Water tank status (level, temperature, frozen detection) |
| `plant{1-8}` | `{"sensor_a":...,"sensor_b":...,"mode":"TargetMoisture",...}` | Detailed status for each plant slot including moisture sensors |
| `pump{1-8}` | `{"enabled":true,"median_current_ma":500,...}` | Metrics for each pump output |
| `light` | `{"enabled":true,"active":true,...}` | Night light status |
| `deepsleep` | `night 1h` | Why and how long the ESP will sleep |
| `deepsleep` | `night 1h` | Reason and duration of deep sleep |
Note: The batteries `average_current_milli_ampere` field uses a placeholder value (1337) and should be updated with actual current sensor readings when available.
### Data Structures
@@ -39,14 +42,15 @@ Contains a debug dump of the `VersionInfo` struct:
- `voltage_ma`: Solar panel voltage in mV
#### Battery (`battery`)
Can be `"Unknown"` or an `Info` object:
- `voltage_milli_volt`: Battery voltage
- `average_current_milli_ampere`: Current draw/charge
- `design_milli_ampere_hour`: Battery capacity
- `remaining_milli_ampere_hour`: Remaining capacity
Can be `"Unknown"` or an `Info` object. The battery data comes from a custom BMS (Battery Management System) board that uses the CH32V203 microcontroller with I2C communication.
- `voltage_milli_volt`: Battery voltage in millivolts
- `average_current_milli_ampere`: Current draw/charge in milliamperes (placeholder: 1337)
- `design_milli_ampere_hour`: Battery design capacity in milliampere-hours
- `remaining_milli_ampere_hour`: Remaining capacity in milliampere-hours
- `state_of_charge`: Charge percentage (0-100)
- `state_of_health`: Health percentage (0-100)
- `temperature`: Temperature in degrees Celsius
- `state_of_health`: Health percentage (0-100) based onLifetime capacity vs design capacity
- `temperature`: Battery temperature in degrees Celsius
#### Water (`water`)
- `enough_water`: Boolean, true if level is above empty threshold

View File

@@ -6,9 +6,9 @@ description: "How to compile the project"
tags: ["clone", "compile"]
---
# Preconditions:
* **Rust:** `rustup` installed.
* **ESP32 Toolchain:** `espup` installed.
* **Build Utilities:** `ldproxy` and `espflash` installed.
* **Rust:** `rustup` installed with the Rust toolchain.
* **ESP32 Toolchain:** `espup` installed for ESP32 support.
* **Build Utilities:** `ldproxy` and `espflash` installed via cargo.
* **Node.js:** `npm` installed (for the web interface).
# Cloning the Repository
@@ -19,24 +19,16 @@ cd PlantCtrl/Software/MainBoard/rust
```
# Toolchain Setup
1. **Install Rust:** If not already done, visit [rustup.rs](https://rustup.rs/).
2. **Install ldproxy:**
The project uses Rust with ESP32-C6 support. The toolchain setup involves installing the necessary components:
1. **Rust Toolchain:**
```bash
cargo install ldproxy
```
3. **Install espup:**
```bash
cargo install espup
```
4. **Install ESP toolchain:**
```bash
espup install
```
5. **Install espflash:**
```bash
cargo install espflash
rustup toolchain install stable
rustup default stable
```
# Building the Web Interface
The configuration website is built using TypeScript and Webpack, then embedded into the Rust binary.
```bash
@@ -46,14 +38,7 @@ npx webpack
cd ..
```
# Compiling the Firmware
Build the project using Cargo:
```bash
cargo build --release
```
The resulting binary will be located in `target/riscv32imac-unknown-none-elf/release/plant-ctrl2`.
# Using Build Scripts
# Compiling the Firmware using Build Scripts
To simplify the process, several bash scripts are provided in the `Software/MainBoard/rust` directory:
* **`image_build.sh`**: Automatically builds the web interface, compiles the Rust firmware in release mode, and creates a flashable `image.bin`.