Compare commits
47 Commits
ef0ec47d92
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| c112d133db | |||
| 95281d617f | |||
| a2abc99275 | |||
| 4b3c003996 | |||
| bba959f2a2 | |||
| c9a96f37f0 | |||
| fbf97732a4 | |||
| 6b419dba6c | |||
| 3618b3329c | |||
| f5f73723d1 | |||
| be98380ba4 | |||
| fe2d227c67 | |||
| bd5b687430 | |||
| 7679fa09dc | |||
| 7078af5713 | |||
|
32256d0c91
|
|||
|
d4a4c1b573
|
|||
|
6bf7a04024
|
|||
|
df3159aa16
|
|||
|
7866604a40
|
|||
| d989b41bdd | |||
| ac8305953a | |||
| d1076145c4 | |||
| cf32f7e05d | |||
| 5e08820276 | |||
|
d2a659638d
|
|||
|
40f99870cf
|
|||
|
ac200af7a9
|
|||
|
9d57805502
|
|||
|
bafc86681c
|
|||
|
5f9db41d65
|
|||
|
ba654a904b
|
|||
|
cd4d0cc683
|
|||
|
2cfb2607a9
|
|||
|
271c1a1383
|
|||
|
a02b84d732
|
|||
| b0f8bcc9da | |||
| 103859120c | |||
| 403517fdb4 | |||
| 11eb8713bf | |||
| d903c2bf52 | |||
| f8f76674ce | |||
| 3cc5a0d2bd | |||
| 3be585ecbf | |||
| 5b1a945ac3 | |||
| f4e050d413 | |||
| 776db785c4 |
@@ -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)
|
||||
)
|
||||
|
||||
@@ -50,7 +50,23 @@
|
||||
"silk_text_thickness": 0.1,
|
||||
"silk_text_upright": false,
|
||||
"zones": {
|
||||
"min_clearance": 0.5
|
||||
"border_display_style": 2,
|
||||
"border_hatch_pitch": 0.5,
|
||||
"corner_radius": 0.0,
|
||||
"corner_smoothing": 0,
|
||||
"fill_mode": 0,
|
||||
"hatch_gap": 1.5,
|
||||
"hatch_orientation": 0.0,
|
||||
"hatch_smoothing_level": 0,
|
||||
"hatch_smoothing_value": 0.1,
|
||||
"hatch_thickness": 1.0,
|
||||
"min_clearance": 0.5,
|
||||
"min_island_area": 10.0,
|
||||
"min_thickness": 0.25,
|
||||
"pad_connection": 1,
|
||||
"remove_islands": 0,
|
||||
"thermal_relief_gap": 0.5,
|
||||
"thermal_relief_spoke_width": 0.5
|
||||
}
|
||||
},
|
||||
"diff_pair_dimensions": [
|
||||
|
||||
1
Software/MainBoard/rust/.idea/vcs.xml
generated
1
Software/MainBoard/rust/.idea/vcs.xml
generated
@@ -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>
|
||||
396
Software/MainBoard/rust/Cargo.lock
generated
396
Software/MainBoard/rust/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
3
Software/MainBoard/rust/TODO
Normal file
3
Software/MainBoard/rust/TODO
Normal 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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
438
Software/MainBoard/rust/src/mqtt.rs
Normal file
438
Software/MainBoard/rust/src/mqtt.rs
Normal 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(())
|
||||
}
|
||||
463
Software/MainBoard/rust/src/network.rs
Normal file
463
Software/MainBoard/rust/src/network.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¤t_time.timezone())),
|
||||
next_fertilizer: if matches!(
|
||||
plant_conf.mode,
|
||||
PlantWateringMode::TimerOnly
|
||||
| PlantWateringMode::TargetMoisture
|
||||
| PlantWateringMode::MinMoisture
|
||||
) {
|
||||
self.last_fertilizer_time.and_then(|last_fert| {
|
||||
// Convert to Tz for calculation, then back
|
||||
let tz_last_fert = last_fert.with_timezone(¤t_time.timezone());
|
||||
tz_last_fert
|
||||
.checked_add_signed(TimeDelta::minutes(
|
||||
plant_conf.fertilizer_cooldown_min.into(),
|
||||
))
|
||||
.map(|t| t.with_timezone(¤t_time.timezone()))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
sensor_a_firmware_build_minutes: self.sensor_a_firmware_build_minutes,
|
||||
sensor_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,
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
10
Software/MainBoard/rust/src/util.rs
Normal file
10
Software/MainBoard/rust/src/util.rs
Normal 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;
|
||||
@@ -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)?))
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ mod post_json;
|
||||
use crate::fat_error::{FatError, FatResult};
|
||||
use crate::webserver::backup_manager::{backup_config, backup_info, get_backup_config};
|
||||
use crate::webserver::get_json::{
|
||||
delete_save, get_battery_state, get_config, get_live_moisture, get_log_localization_config,
|
||||
get_firmware_info_web, get_solar_state, get_time, get_timezones, list_saves, tank_info,
|
||||
delete_save, get_battery_state, get_config, get_firmware_info_web, get_live_moisture,
|
||||
get_log_localization_config, get_solar_state, get_time, get_timezones, get_wifi_details,
|
||||
list_saves, tank_info,
|
||||
};
|
||||
use crate::webserver::get_log::{get_live_log, get_log};
|
||||
use crate::webserver::get_static::{serve_bundle, serve_favicon, serve_index};
|
||||
@@ -83,6 +84,7 @@ impl Handler for HTTPRequestRouter {
|
||||
"/timezones" => Some(get_timezones().await),
|
||||
"/moisture" => Some(get_live_moisture(conn).await),
|
||||
"/list_saves" => Some(list_saves(conn).await),
|
||||
"/wifi_details" => Some(get_wifi_details(conn).await),
|
||||
// /live_log accepts an optional ?after=N query parameter
|
||||
p if p == "/live_log" || p.starts_with("/live_log?") => {
|
||||
let after: Option<u64> = p
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::config::PlantControllerConfig;
|
||||
use crate::fat_error::FatResult;
|
||||
use crate::hal::savegame_manager::SAVEGAME_SLOT_SIZE;
|
||||
use crate::hal::DetectionRequest;
|
||||
use crate::webserver::read_up_to_bytes_from_request;
|
||||
use crate::{do_secure_pump, BOARD_ACCESS};
|
||||
@@ -135,7 +136,8 @@ pub(crate) async fn set_config<T, const N: usize>(
|
||||
where
|
||||
T: Read + Write,
|
||||
{
|
||||
let all = read_up_to_bytes_from_request(request, Some(4096)).await?;
|
||||
//accept nearly full slotsize leave some space for header
|
||||
let all = read_up_to_bytes_from_request(request, Some(SAVEGAME_SLOT_SIZE - 512)).await?;
|
||||
let length = all.len();
|
||||
let config: PlantControllerConfig = serde_json::from_slice(&all)?;
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ export interface PlantConfig {
|
||||
min_pump_current_ma: number,
|
||||
max_pump_current_ma: number,
|
||||
ignore_current_error: boolean,
|
||||
sensor_combine_mode: string,
|
||||
}
|
||||
|
||||
export interface PumpTestResult {
|
||||
@@ -177,6 +178,8 @@ export interface GetTime {
|
||||
export interface Moistures {
|
||||
moisture_a: [string],
|
||||
moisture_b: [string],
|
||||
sensor_a_build_minutes: Array<number | null>,
|
||||
sensor_b_build_minutes: Array<number | null>,
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
@@ -223,23 +226,32 @@ export interface Detection {
|
||||
plant: DetectionPlant[]
|
||||
}
|
||||
|
||||
/// Wi-Fi scan result details for UI display
|
||||
export interface WifiScanResult {
|
||||
ssid: string,
|
||||
bssid: string,
|
||||
rssi: number, // signal strength in dBm
|
||||
channel: number,
|
||||
auth_method: string
|
||||
}
|
||||
|
||||
export interface TankInfo {
|
||||
/// is there enough water in the tank
|
||||
/// there is enough water in the tank
|
||||
enough_water: boolean,
|
||||
/// warning that water needs to be refilled soon
|
||||
warn_level: boolean,
|
||||
/// estimation how many ml are still in tank
|
||||
left_ml: number | null,
|
||||
/// if there is was an issue with the water level sensor
|
||||
/// estimation how many ml are still in the tank
|
||||
volume_ml: number | null,
|
||||
/// if there is an issue with the water level sensor
|
||||
sensor_error: string | null,
|
||||
/// raw water sensor value
|
||||
raw: number | null,
|
||||
fill_raw_v: number | null,
|
||||
/// percent value
|
||||
percent: number | null,
|
||||
/// water in tank might be frozen
|
||||
fill_pct: number | null,
|
||||
/// water in the tank might be frozen
|
||||
water_frozen: boolean,
|
||||
/// water temperature
|
||||
water_temp: number | null,
|
||||
water_temp_c: number | null,
|
||||
temp_sensor_error: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
}
|
||||
|
||||
#logpanel {
|
||||
display: none;
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
Moistures,
|
||||
NightLampCommand,
|
||||
PlantControllerConfig,
|
||||
SetTime, SSIDList, TankInfo,
|
||||
SetTime, SSIDList, TankInfo, WifiScanResult,
|
||||
TestPump,
|
||||
VersionInfo,
|
||||
SaveInfo, SolarState, PumpTestResult, Detection, DetectionRequest, CanPower
|
||||
@@ -43,6 +43,7 @@ export class Controller {
|
||||
controller.tankView.setTankInfo(tankinfo)
|
||||
})
|
||||
.catch(error => {
|
||||
toast.error(`Failed to load tank info: ${error}`);
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
@@ -64,7 +65,9 @@ export class Controller {
|
||||
const json = await response.json();
|
||||
const logs = json as LogArray;
|
||||
controller.logView.setLog(logs);
|
||||
toast.info("Log loaded successfully");
|
||||
} catch (error) {
|
||||
toast.error(`Failed to load log: ${error}`);
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
@@ -87,6 +90,7 @@ export class Controller {
|
||||
const timezones = json as string[];
|
||||
controller.timeView.timezones(timezones);
|
||||
} catch (error) {
|
||||
toast.error(`Error fetching timezones: ${error}`);
|
||||
return console.error('Error fetching timezones:', error);
|
||||
}
|
||||
}
|
||||
@@ -98,6 +102,7 @@ export class Controller {
|
||||
const saves = json as SaveInfo[];
|
||||
controller.fileview.setSaveList(saves, PUBLIC_URL);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to update save list: ${error}`);
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
@@ -109,16 +114,21 @@ export class Controller {
|
||||
ajax.send();
|
||||
ajax.addEventListener("error", () => {
|
||||
controller.progressview.removeProgress("slot_delete");
|
||||
alert("Error deleting slot");
|
||||
toast.error(`Failed to delete slot ${idx}`);
|
||||
controller.updateSaveList();
|
||||
}, false);
|
||||
ajax.addEventListener("abort", () => {
|
||||
controller.progressview.removeProgress("slot_delete");
|
||||
alert("Aborted deleting slot");
|
||||
toast.warning(`Slot deletion aborted`);
|
||||
controller.updateSaveList();
|
||||
}, false);
|
||||
ajax.addEventListener("load", () => {
|
||||
controller.progressview.removeProgress("slot_delete");
|
||||
if (ajax.status >= 200 && ajax.status < 300) {
|
||||
toast.success("Slot deleted successfully");
|
||||
} else {
|
||||
toast.error(`Failed to delete slot: ${ajax.status}`);
|
||||
}
|
||||
controller.updateSaveList();
|
||||
}, false);
|
||||
}
|
||||
@@ -131,6 +141,7 @@ export class Controller {
|
||||
controller.timeView.update(time.native, time.rtc);
|
||||
} catch (error) {
|
||||
controller.timeView.update("n/a", "n/a");
|
||||
toast.error(`Failed to update RTC data: ${error}`);
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
@@ -143,6 +154,7 @@ export class Controller {
|
||||
controller.batteryView.update(battery);
|
||||
} catch (error) {
|
||||
controller.batteryView.update(null);
|
||||
toast.error(`Failed to update battery data: ${error}`);
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
@@ -155,6 +167,22 @@ export class Controller {
|
||||
controller.solarView.update(solar);
|
||||
} catch (error) {
|
||||
controller.solarView.update(null);
|
||||
toast.error(`Failed to update solar data: ${error}`);
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
async scanWifiDetails(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(PUBLIC_URL + "/wifi_details");
|
||||
if (response.ok) {
|
||||
const wifiDetails = await response.json();
|
||||
controller.networkView.displayWifiResults(wifiDetails as WifiScanResult[]);
|
||||
} else {
|
||||
toast.error(`Failed to fetch Wi-Fi details: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Wi-Fi details error: ${error}`);
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
@@ -173,6 +201,7 @@ export class Controller {
|
||||
controller.progressview.removeProgress("ota_upload")
|
||||
const status = ajax.status;
|
||||
if (status >= 200 && status < 300) {
|
||||
toast.success("OTA firmware upload successful");
|
||||
controller.reboot();
|
||||
} else {
|
||||
const statusText = ajax.statusText || "";
|
||||
@@ -199,6 +228,7 @@ export class Controller {
|
||||
const versionInfo = json as VersionInfo;
|
||||
controller.progressview.removeProgress("version");
|
||||
controller.firmWareView.setVersion(versionInfo);
|
||||
toast.info("Firmware version information updated");
|
||||
}
|
||||
|
||||
getBackupConfig() {
|
||||
@@ -241,6 +271,7 @@ export class Controller {
|
||||
.then(status => {
|
||||
controller.progressview.removeProgress("set_config");
|
||||
if (status == 200) {
|
||||
toast.success("Configuration saved successfully");
|
||||
setTimeout(() => {
|
||||
controller.downloadConfig().then(() => {
|
||||
controller.updateSaveList().then(() => {
|
||||
@@ -268,7 +299,14 @@ export class Controller {
|
||||
fetch(PUBLIC_URL + "/time", {
|
||||
method: "POST",
|
||||
body: pretty
|
||||
}).then(
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
toast.error(`Failed to sync RTC: ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.then(
|
||||
_ => controller.progressview.removeProgress("write_rtc")
|
||||
)
|
||||
}
|
||||
@@ -288,9 +326,23 @@ export class Controller {
|
||||
}
|
||||
|
||||
selfTest() {
|
||||
controller.progressview.addIndeterminate("self_test", "Running board test")
|
||||
fetch(PUBLIC_URL + "/boardtest", {
|
||||
method: "POST"
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
toast.success("Board test completed");
|
||||
} else {
|
||||
toast.error(`Board test failed: ${response.status}`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
toast.error(`Board test error: ${error}`);
|
||||
})
|
||||
.finally(() => {
|
||||
controller.progressview.removeProgress("self_test");
|
||||
});
|
||||
}
|
||||
|
||||
testNightLamp(active: boolean) {
|
||||
@@ -298,21 +350,52 @@ export class Controller {
|
||||
active: active
|
||||
};
|
||||
var pretty = JSON.stringify(body, undefined, 1);
|
||||
controller.progressview.addIndeterminate("night_lamp_test", "Testing night lamp")
|
||||
fetch(PUBLIC_URL + "/lamptest", {
|
||||
method: "POST",
|
||||
body: pretty
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
toast.success(`Night lamp ${active ? "enabled" : "disabled"} successfully`);
|
||||
} else {
|
||||
toast.error(`Night lamp test failed: ${response.status}`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
toast.error(`Night lamp test error: ${error}`);
|
||||
})
|
||||
.finally(() => {
|
||||
controller.progressview.removeProgress("night_lamp_test");
|
||||
});
|
||||
}
|
||||
|
||||
testFertilizerPump() {
|
||||
controller.progressview.addIndeterminate("fert_test", "Testing fertilizer pump")
|
||||
fetch(PUBLIC_URL + "/fertilizerpumptest", {
|
||||
method: "POST"
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
toast.success("Fertilizer pump test completed");
|
||||
} else {
|
||||
toast.error(`Fertilizer pump test failed: ${response.status}`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
toast.error(`Fertilizer pump test error: ${error}`);
|
||||
})
|
||||
.finally(() => {
|
||||
controller.progressview.removeProgress("fert_test");
|
||||
});
|
||||
}
|
||||
|
||||
testPlant(plantId: number) {
|
||||
const plantConfig = controller.getConfig().plants[plantId];
|
||||
const pumpTimeS = plantConfig.pump_time_s;
|
||||
|
||||
let counter = 0
|
||||
let limit = 30
|
||||
let limit = pumpTimeS > 0 ? Math.ceil(pumpTimeS) : 30
|
||||
controller.progressview.addProgress("test_pump", counter / limit * 100, "Testing pump " + (plantId + 1) + " for " + (limit - counter) + "s")
|
||||
|
||||
let timerId: string | number | NodeJS.Timeout | undefined
|
||||
@@ -341,6 +424,11 @@ export class Controller {
|
||||
controller.plantViews.setPumpTestCurrent(plantId, response);
|
||||
clearTimeout(timerId);
|
||||
controller.progressview.removeProgress("test_pump");
|
||||
if (!response.error) {
|
||||
toast.success(`Pump ${plantId + 1} test completed successfully`);
|
||||
} else {
|
||||
toast.error(`Pump ${plantId + 1} test reported an error`);
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -425,13 +513,20 @@ export class Controller {
|
||||
if (ajax.readyState === 4) {
|
||||
clearTimeout(timerId);
|
||||
controller.progressview.removeProgress("scan_ssid");
|
||||
this.networkView.setScanResult(ajax.response as SSIDList)
|
||||
if (ajax.status >= 200 && ajax.status < 300) {
|
||||
this.networkView.setScanResult(ajax.response as SSIDList);
|
||||
toast.success("WiFi scan completed");
|
||||
// Also fetch detailed Wi-Fi information
|
||||
this.scanWifiDetails();
|
||||
} else {
|
||||
toast.error(`WiFi scan failed: ${ajax.status}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
ajax.onerror = (_) => {
|
||||
clearTimeout(timerId);
|
||||
controller.progressview.removeProgress("scan_ssid");
|
||||
alert("Failed to start see console")
|
||||
toast.error("Failed to start WiFi scan");
|
||||
}
|
||||
ajax.open("POST", PUBLIC_URL + "/wifiscan");
|
||||
ajax.send();
|
||||
@@ -478,12 +573,15 @@ export class Controller {
|
||||
return fetch(PUBLIC_URL + "/moisture")
|
||||
.then(response => response.json())
|
||||
.then(json => json as Moistures)
|
||||
.then(time => {
|
||||
controller.plantViews.update(time.moisture_a, time.moisture_b)
|
||||
.then(data => {
|
||||
controller.plantViews.update(data.moisture_a, data.moisture_b, data.sensor_a_build_minutes, data.sensor_b_build_minutes)
|
||||
clearTimeout(timerId);
|
||||
if (!silent) {
|
||||
controller.progressview.removeProgress("measure_moisture");
|
||||
}
|
||||
if (!silent) {
|
||||
toast.success("Moisture measurement completed");
|
||||
}
|
||||
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -629,6 +727,7 @@ export class Controller {
|
||||
};
|
||||
await this.detectSensors(detection, true);
|
||||
} catch (e) {
|
||||
toast.error(`Auto-refresh error: ${e}`);
|
||||
console.error("Auto-refresh error", e);
|
||||
}
|
||||
|
||||
@@ -649,6 +748,7 @@ const tasks = [
|
||||
{task: controller.updateRTCData, displayString: "Updating RTC Data"},
|
||||
{task: controller.updateBatteryData, displayString: "Updating Battery Data"},
|
||||
{task: controller.updateSolarData, displayString: "Updating Solar Data"},
|
||||
{task: () => controller.measure_moisture(true), displayString: "Measuring Moisture"},
|
||||
{task: controller.downloadConfig, displayString: "Downloading Configuration"},
|
||||
{task: controller.version, displayString: "Fetching Version Information"},
|
||||
{task: controller.updateSaveList, displayString: "Updating Save Slots"},
|
||||
@@ -666,6 +766,7 @@ async function executeTasksSequentially() {
|
||||
try {
|
||||
await task();
|
||||
} catch (error) {
|
||||
toast.error(`Error executing task '${displayString}': ${error}`);
|
||||
console.error(`Error executing task '${displayString}':`, error);
|
||||
// Optionally, you can decide whether to continue or break on errors
|
||||
break;
|
||||
@@ -681,6 +782,11 @@ executeTasksSequentially().then(() => {
|
||||
controller.progressview.removeProgress("rebooting");
|
||||
|
||||
window.addEventListener("beforeunload", (event) => {
|
||||
// Only check for unsaved changes if initialConfig has been loaded
|
||||
if (controller.initialConfig === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentConfig = controller.getConfig();
|
||||
|
||||
// Check if the current state differs from the initial configuration
|
||||
|
||||
@@ -85,7 +85,15 @@
|
||||
<input class="mqttvalue" type="text" id="mqtt_password" placeholder="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="subcontainer">
|
||||
<div class="flexcontainer">
|
||||
<div class="subtitle">Wi-Fi Scan Results</div>
|
||||
</div>
|
||||
|
||||
<div id="wifi-results">
|
||||
<p>Scan for available networks to see signal strength</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Controller } from "./main";
|
||||
import {NetworkConfig, SSIDList} from "./api";
|
||||
import {NetworkConfig, SSIDList, WifiScanResult} from "./api";
|
||||
|
||||
export class NetworkConfigView {
|
||||
private wifiResults: HTMLElement;
|
||||
|
||||
setScanResult(ssidList: SSIDList) {
|
||||
this.ssidlist.innerHTML = ''
|
||||
for (const ssid of ssidList.ssids) {
|
||||
@@ -10,6 +12,47 @@ export class NetworkConfigView {
|
||||
this.ssidlist.appendChild(wi);
|
||||
}
|
||||
}
|
||||
|
||||
async scanAndDisplayWifiDetails() {
|
||||
try {
|
||||
const response = await fetch('/wifi_details');
|
||||
if (response.ok) {
|
||||
const data: WifiScanResult[] = await response.json();
|
||||
this.displayWifiResults(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching Wi-Fi details:', error);
|
||||
this.displayWifiResults([]);
|
||||
}
|
||||
}
|
||||
|
||||
displayWifiResults(results: WifiScanResult[]) {
|
||||
const wifiContainer = document.getElementById('wifi-results');
|
||||
if (!wifiContainer) return;
|
||||
|
||||
if (results.length === 0) {
|
||||
wifiContainer.innerHTML = '<p>No Wi-Fi networks found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table style="width:100%; border-collapse: collapse;">';
|
||||
html += '<tr><th style="text-align:left; padding: 8px;">SSID</th>';
|
||||
html += '<th style="text-align:left; padding: 8px;">Signal (RSSI)</th>';
|
||||
html += '<th style="text-align:left; padding: 8px;">Channel</th>';
|
||||
html += '<th style="text-align:left; padding: 8px;">Authentication</th></tr>';
|
||||
|
||||
results.forEach(result => {
|
||||
html += '<tr style="border-bottom: 1px solid #ddd;">';
|
||||
html += `<td style="padding: 8px;">${result.ssid}</td>`;
|
||||
html += `<td style="padding: 8px;">${result.rssi} dBm</td>`;
|
||||
html += `<td style="padding: 8px;">${result.channel}</td>`;
|
||||
html += `<td style="padding: 8px;">${result.auth_method}</td>`;
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
html += '</table>';
|
||||
wifiContainer.innerHTML = html;
|
||||
}
|
||||
private readonly ap_ssid: HTMLInputElement;
|
||||
private readonly ssid: HTMLInputElement;
|
||||
private readonly password: HTMLInputElement;
|
||||
@@ -47,9 +90,14 @@ export class NetworkConfigView {
|
||||
this.ssidlist = document.getElementById("ssidlist") as HTMLElement
|
||||
|
||||
let scanWifiBtn = document.getElementById("scan") as HTMLButtonElement;
|
||||
scanWifiBtn.onclick = function (){
|
||||
scanWifiBtn.onclick = async () => {
|
||||
controller.scanWifi();
|
||||
}
|
||||
// After Wi-Fi scan, fetch and display detailed results
|
||||
await this.scanAndDisplayWifiDetails();
|
||||
};
|
||||
|
||||
// Store wifiResults reference for later use
|
||||
this.wifiResults = document.getElementById('wifi-results') as HTMLElement;
|
||||
}
|
||||
|
||||
setConfig(network: NetworkConfig) {
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
.plantSensorEnabledOnly_ ${plantId} {
|
||||
}
|
||||
|
||||
.plantBothSensorsOnly_ ${plantId} {
|
||||
}
|
||||
|
||||
.plantHidden_ ${plantId} {
|
||||
display: none;
|
||||
}
|
||||
@@ -48,6 +51,14 @@
|
||||
<div class="plantkey">Sensor B installed:</div>
|
||||
<input class="plantcheckbox" id="plant_${plantId}_sensor_b" type="checkbox">
|
||||
</div>
|
||||
<div class="flexcontainer plantBothSensorsOnly_${plantId}">
|
||||
<div class="plantkey">Sensor Combine Mode:</div>
|
||||
<select class="plantvalue" id="plant_${plantId}_sensor_combine_mode">
|
||||
<option value="Min">Min</option>
|
||||
<option value="Max">Max</option>
|
||||
<option value="Avg">Average</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flexcontainer">
|
||||
<div class="plantkey">
|
||||
Mode:
|
||||
|
||||
@@ -36,11 +36,19 @@ export class PlantViews {
|
||||
return rv
|
||||
}
|
||||
|
||||
update(moisture_a: [string], moisture_b: [string]) {
|
||||
update(moisture_a: [string], moisture_b: [string], sensor_a_build_minutes?: Array<number | null>, sensor_b_build_minutes?: Array<number | null>) {
|
||||
for (let plantId = 0; plantId < PLANT_COUNT; plantId++) {
|
||||
const a = moisture_a[plantId]
|
||||
const b = moisture_b[plantId]
|
||||
this.plants[plantId].setMeasurementResult(a, b)
|
||||
|
||||
// Update firmware build timestamps if provided
|
||||
if (sensor_a_build_minutes && sensor_a_build_minutes[plantId] !== undefined) {
|
||||
this.plants[plantId].setFirmwareBuild("sensor_a", sensor_a_build_minutes[plantId])
|
||||
}
|
||||
if (sensor_b_build_minutes && sensor_b_build_minutes[plantId] !== undefined) {
|
||||
this.plants[plantId].setFirmwareBuild("sensor_b", sensor_b_build_minutes[plantId])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +93,7 @@ export class PlantView {
|
||||
private readonly pumpHourEnd: HTMLSelectElement;
|
||||
private readonly sensorAInstalled: HTMLInputElement;
|
||||
private readonly sensorBInstalled: HTMLInputElement;
|
||||
private readonly sensorCombineMode: HTMLSelectElement;
|
||||
private readonly mode: HTMLSelectElement;
|
||||
private readonly moistureA: HTMLElement;
|
||||
private readonly moistureB: HTMLElement;
|
||||
@@ -228,6 +237,14 @@ export class PlantView {
|
||||
controller.configChanged()
|
||||
}
|
||||
|
||||
this.sensorCombineMode = document.getElementById("plant_" + plantId + "_sensor_combine_mode") as HTMLSelectElement;
|
||||
this.sensorCombineMode.onchange = function () {
|
||||
controller.configChanged()
|
||||
}
|
||||
|
||||
// Initial visibility update for sensor combine mode
|
||||
this.updateSensorCombineModeState();
|
||||
|
||||
this.minPumpCurrentMa = document.getElementById("plant_" + plantId + "_min_pump_current_ma") as HTMLInputElement;
|
||||
this.minPumpCurrentMa.onchange = function () {
|
||||
controller.configChanged()
|
||||
@@ -263,6 +280,19 @@ export class PlantView {
|
||||
};
|
||||
}
|
||||
|
||||
updateSensorCombineModeState() {
|
||||
const bothActive = this.sensorAInstalled.checked && this.sensorBInstalled.checked;
|
||||
const bothOnlyElements = document.getElementsByClassName("plantBothSensorsOnly_" + this.plantId);
|
||||
for (const element of Array.from(bothOnlyElements)) {
|
||||
if (bothActive) {
|
||||
element.classList.remove("plantHidden_" + this.plantId);
|
||||
} else {
|
||||
element.classList.add("plantHidden_" + this.plantId);
|
||||
}
|
||||
}
|
||||
this.sensorCombineMode.disabled = !bothActive;
|
||||
}
|
||||
|
||||
updateVisibility(plantConfig: PlantConfig) {
|
||||
let sensorOnly = document.getElementsByClassName("plantSensorEnabledOnly_" + this.plantId)
|
||||
let pumpOnly = document.getElementsByClassName("plantPumpEnabledOnly_" + this.plantId)
|
||||
@@ -316,6 +346,9 @@ export class PlantView {
|
||||
// element.classList.add("plantHidden_" + this.plantId)
|
||||
// }
|
||||
// }
|
||||
|
||||
// Update sensor combine mode visibility based on whether both sensors are active
|
||||
this.updateSensorCombineModeState();
|
||||
}
|
||||
|
||||
setTestResult(result: PumpTestResult) {
|
||||
@@ -346,6 +379,7 @@ export class PlantView {
|
||||
this.pumpHourEnd.value = plantConfig.pump_hour_end.toString();
|
||||
this.sensorBInstalled.checked = plantConfig.sensor_b;
|
||||
this.sensorAInstalled.checked = plantConfig.sensor_a;
|
||||
this.sensorCombineMode.value = plantConfig.sensor_combine_mode || "Min";
|
||||
this.maxConsecutivePumpCount.value = plantConfig.max_consecutive_pump_count.toString();
|
||||
this.minPumpCurrentMa.value = plantConfig.min_pump_current_ma.toString();
|
||||
this.maxPumpCurrentMa.value = plantConfig.max_pump_current_ma.toString();
|
||||
@@ -375,6 +409,7 @@ export class PlantView {
|
||||
pump_hour_end: +this.pumpHourEnd.value,
|
||||
sensor_b: this.sensorBInstalled.checked,
|
||||
sensor_a: this.sensorAInstalled.checked,
|
||||
sensor_combine_mode: this.sensorCombineMode.value,
|
||||
max_consecutive_pump_count: this.maxConsecutivePumpCount.valueAsNumber,
|
||||
moisture_sensor_min_frequency: this.moistureSensorMinFrequency.valueAsNumber || null,
|
||||
moisture_sensor_max_frequency: this.moistureSensorMaxFrequency.valueAsNumber || null,
|
||||
@@ -406,4 +441,12 @@ export class PlantView {
|
||||
this.sensorAFwBuild.innerText = formatBuildMinutes(plantResult.sensor_a);
|
||||
this.sensorBFwBuild.innerText = formatBuildMinutes(plantResult.sensor_b);
|
||||
}
|
||||
|
||||
setFirmwareBuild(sensor: "sensor_a" | "sensor_b", buildMinutes: number | null) {
|
||||
if (sensor === "sensor_a") {
|
||||
this.sensorAFwBuild.innerText = formatBuildMinutes(buildMinutes);
|
||||
} else {
|
||||
this.sensorBFwBuild.innerText = formatBuildMinutes(buildMinutes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Controller} from "./main";
|
||||
import {BackupHeader} from "./api";
|
||||
import {toast} from "./toast";
|
||||
|
||||
export class SubmitView {
|
||||
json: HTMLDivElement;
|
||||
@@ -26,25 +27,28 @@ export class SubmitView {
|
||||
this.submit_status = document.getElementById("submit_status") as HTMLElement
|
||||
this.submitFormBtn.onclick = () => {
|
||||
controller.uploadConfig(this.json.textContent as string, (status: string) => {
|
||||
if (status != "OK") {
|
||||
// Show error toast (click to dismiss only)
|
||||
const {toast} = require('./toast');
|
||||
toast.error(status);
|
||||
} else {
|
||||
// Show info toast (auto hides after 5s, or click to dismiss sooner)
|
||||
const {toast} = require('./toast');
|
||||
toast.info('Config uploaded successfully');
|
||||
}
|
||||
toast.info(status);
|
||||
this.submit_status.innerHTML = status;
|
||||
});
|
||||
}
|
||||
this.backupBtn.onclick = () => {
|
||||
controller.progressview.addIndeterminate("backup", "Backup to EEPROM running")
|
||||
controller.backupConfig(this.json.textContent as string).then(saveStatus => {
|
||||
if (saveStatus === "OK") {
|
||||
toast.success("Configuration backup successful");
|
||||
} else {
|
||||
toast.error(`Backup failed: ${saveStatus}`);
|
||||
}
|
||||
controller.getBackupInfo().then(r => {
|
||||
controller.progressview.removeProgress("backup")
|
||||
this.submit_status.innerHTML = saveStatus;
|
||||
});
|
||||
}).catch(error => {
|
||||
toast.error(`Backup error: ${error}`);
|
||||
controller.getBackupInfo().then(r => {
|
||||
controller.progressview.removeProgress("backup")
|
||||
this.submit_status.innerHTML = "Error";
|
||||
});
|
||||
});
|
||||
}
|
||||
this.restoreBackupBtn.onclick = () => {
|
||||
|
||||
@@ -93,28 +93,28 @@ export class TankConfigView {
|
||||
this.tank_measure_error.innerText = JSON.stringify(tankinfo.sensor_error) ;
|
||||
this.tank_measure_error_container.classList.remove("hidden")
|
||||
}
|
||||
if (tankinfo.left_ml == null){
|
||||
if (tankinfo.volume_ml == null){
|
||||
this.tank_measure_ml_container.classList.add("hidden")
|
||||
} else {
|
||||
this.tank_measure_ml.innerText = tankinfo.left_ml.toString();
|
||||
this.tank_measure_ml.innerText = tankinfo.volume_ml.toString();
|
||||
this.tank_measure_ml_container.classList.remove("hidden")
|
||||
}
|
||||
if (tankinfo.percent == null){
|
||||
if (tankinfo.fill_pct == null){
|
||||
this.tank_measure_percent_container.classList.add("hidden")
|
||||
} else {
|
||||
this.tank_measure_percent.innerText = tankinfo.percent.toString();
|
||||
this.tank_measure_percent.innerText = tankinfo.fill_pct.toString();
|
||||
this.tank_measure_percent_container.classList.remove("hidden")
|
||||
}
|
||||
if (tankinfo.water_temp == null){
|
||||
if (tankinfo.water_temp_c == null){
|
||||
this.tank_measure_temperature_container.classList.add("hidden")
|
||||
} else {
|
||||
this.tank_measure_temperature.innerText = tankinfo.water_temp.toString();
|
||||
this.tank_measure_temperature.innerText = tankinfo.water_temp_c.toString();
|
||||
this.tank_measure_temperature_container.classList.remove("hidden")
|
||||
}
|
||||
if (tankinfo.raw == null){
|
||||
if (tankinfo.fill_raw_v == null){
|
||||
this.tank_measure_rawvolt_container.classList.add("hidden")
|
||||
} else {
|
||||
this.tank_measure_rawvolt.innerText = tankinfo.raw.toString();
|
||||
this.tank_measure_rawvolt.innerText = tankinfo.fill_raw_v.toString();
|
||||
this.tank_measure_rawvolt_container.classList.remove("hidden")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,94 +1,432 @@
|
||||
class ToastService {
|
||||
private container: HTMLElement;
|
||||
private stylesInjected = false;
|
||||
/**
|
||||
* Toast notification service for PlantCtrl embedded web interface
|
||||
* Provides non-blocking notifications with auto-dismiss and click-to-close functionality
|
||||
*/
|
||||
|
||||
constructor() {
|
||||
this.container = this.ensureContainer();
|
||||
this.injectStyles();
|
||||
}
|
||||
const TOAST_container_ID = 'toast-container';
|
||||
const TOAST_STYLES_KEY = 'toast-styles-injected';
|
||||
|
||||
info(message: string, timeoutMs: number = 5000) {
|
||||
const el = this.createToast(message, 'info');
|
||||
this.container.appendChild(el);
|
||||
// Auto-dismiss after timeout
|
||||
const timer = window.setTimeout(() => this.dismiss(el), timeoutMs);
|
||||
// Dismiss on click immediately
|
||||
el.addEventListener('click', () => {
|
||||
window.clearTimeout(timer);
|
||||
this.dismiss(el);
|
||||
});
|
||||
}
|
||||
interface ToastOptions {
|
||||
duration?: number;
|
||||
dismissible?: boolean;
|
||||
}
|
||||
|
||||
error(message: string) {
|
||||
console.error(message);
|
||||
const el = this.createToast(message, 'error');
|
||||
this.container.appendChild(el);
|
||||
// Only dismiss on click
|
||||
el.addEventListener('click', () => this.dismiss(el));
|
||||
}
|
||||
interface ToastData {
|
||||
id: string;
|
||||
type: 'info' | 'success' | 'warning' | 'error';
|
||||
message: string;
|
||||
createdAt: number;
|
||||
element?: HTMLElement;
|
||||
}
|
||||
|
||||
private dismiss(el: HTMLElement) {
|
||||
if (!el.parentElement) return;
|
||||
el.parentElement.removeChild(el);
|
||||
}
|
||||
|
||||
private createToast(message: string, type: 'info' | 'error'): HTMLElement {
|
||||
const div = document.createElement('div');
|
||||
div.className = `toast ${type}`;
|
||||
div.textContent = message;
|
||||
div.setAttribute('role', 'status');
|
||||
div.setAttribute('aria-live', 'polite');
|
||||
return div;
|
||||
}
|
||||
|
||||
private ensureContainer(): HTMLElement {
|
||||
let container = document.getElementById('toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
document.body.appendChild(container);
|
||||
/**
|
||||
* Toast service for displaying notifications
|
||||
*/
|
||||
export class ToastService {
|
||||
private container: HTMLElement | null = null;
|
||||
private activeToasts: Map<string, ToastData> = new Map();
|
||||
private maxToasts: number = 5;
|
||||
|
||||
// Default configuration
|
||||
private defaultDuration: number = 5000; // 5 seconds for info messages
|
||||
private errorDuration: number = 10000; // 10 seconds for error messages
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
private injectStyles() {
|
||||
if (this.stylesInjected) return;
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
|
||||
/**
|
||||
* Initialize the toast container and inject styles
|
||||
*/
|
||||
private init(): void {
|
||||
this.ensureContainer();
|
||||
this.injectStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the toast container element
|
||||
*/
|
||||
private ensureContainer(): HTMLElement {
|
||||
if (this.container) return this.container;
|
||||
|
||||
let container = document.getElementById(TOAST_container_ID);
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = TOAST_container_ID;
|
||||
container.setAttribute('role', 'region');
|
||||
container.setAttribute('aria-label', 'Notifications');
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
this.container = container;
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject toast styles if not already injected
|
||||
*/
|
||||
private injectStyles(): void {
|
||||
if (document.querySelector(`style[data-id="${TOAST_STYLES_KEY}"]`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.setAttribute('data-id', TOAST_STYLES_KEY);
|
||||
style.textContent = `
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
z-index: 9999;
|
||||
max-width: 400px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
max-width: 320px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-family: sans-serif;
|
||||
background: #fff;
|
||||
border-left: 4px solid transparent;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
line-height: 1.4;
|
||||
color: #333;
|
||||
max-width: 100%;
|
||||
pointer-events: auto;
|
||||
animation: toast-slide-in 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
toast-fade-in 0.3s ease-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
background-color: #d4edda; /* green-ish */
|
||||
color: #155724;
|
||||
border-left: 4px solid #28a745;
|
||||
border-color: #3b82f6;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-color: #22c55e;
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||||
}
|
||||
|
||||
.toast.warning {
|
||||
border-color: #f59e0b;
|
||||
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background-color: #f8d7da; /* red-ish */
|
||||
color: #721c24;
|
||||
border-left: 4px solid #dc3545;
|
||||
border-color: #ef4444;
|
||||
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
this.stylesInjected = true;
|
||||
|
||||
.toast:hover {
|
||||
transform: translateX(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex-grow: 1;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.toast-close-btn {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
margin-left: -4px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.toast-close-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes toast-slide-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-dismiss {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique ID for toast messages
|
||||
*/
|
||||
private generateId(): string {
|
||||
return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a toast element
|
||||
*/
|
||||
private createToast(type: 'info' | 'success' | 'warning' | 'error', message: string): HTMLElement {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'toast';
|
||||
div.classList.add(type);
|
||||
|
||||
// Add icon based on type
|
||||
const icon = this.getIconForType(type);
|
||||
div.innerHTML = `
|
||||
<span class="toast-icon">${icon}</span>
|
||||
<span class="toast-message">${this.escapeHtml(message)}</span>
|
||||
<button class="toast-close-btn" aria-label="Dismiss notification">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon based on toast type
|
||||
*/
|
||||
private getIconForType(type: 'info' | 'success' | 'warning' | 'error'): string {
|
||||
const icons: Record<string, string> = {
|
||||
info: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>',
|
||||
success: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>',
|
||||
warning: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>',
|
||||
error: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>'
|
||||
};
|
||||
return icons[type] || icons.info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
private escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
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();
|
||||
|
||||
@@ -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 (0–15)
|
||||
- **Slot B**: Base offset + 16 + plant index (0–15)
|
||||
|
||||
#### 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 (0–15) |
|
||||
| `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 (1–8)
|
||||
|
||||
### 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**: 1–8 (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 1–8 (not 0 or >8)
|
||||
3. Confirm slot selector (A/B) matches expected configuration
|
||||
4. Listen for CAN traffic with a CAN analyzer
|
||||
5. Check error blink codes on the sensor module
|
||||
|
||||
### Address Collision
|
||||
- Two sensors using identical jumper settings will cause collisions
|
||||
- Use the collision detection feature to identify duplicate addresses
|
||||
- Reconfigure one of the conflicting sensors to a different address
|
||||
|
||||
### Communication Errors
|
||||
- **Bus-off**: Check CAN termination resistors (120Ω at each end)
|
||||
- **High error rate**: Verify cable quality and shielding
|
||||
- **Intermittent errors**: Check for electrical noise from pumps or motors
|
||||
|
||||
## Future Extensions
|
||||
|
||||
The protocol is designed to be extensible. New message types can be added by:
|
||||
1. Defining a new offset in `canapi/src/lib.rs`
|
||||
2. Implementing the corresponding message struct with bincode serialization
|
||||
3. Adding receive handlers on both sensor and controller sides
|
||||
4. Documenting the new ID range and format
|
||||
|
||||
The current design supports up to 64 distinct message types (16 plants × 2 slots × 2 directions) while maintaining a clean, plant-indexed addressing scheme.
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: "CAN Bus IDs and Wire Format"
|
||||
date: 2026-05-21
|
||||
draft: false
|
||||
description: "Quick reference for CAN bus identifiers, message formats, and on-the-wire data structures."
|
||||
tags: ["can", "protocol", "wire-format"]
|
||||
---
|
||||
|
||||
# CAN Bus IDs and Wire Format
|
||||
|
||||
A concise technical reference for the PlantCtrl CAN bus protocol.
|
||||
|
||||
## Quick Reference Table
|
||||
|
||||
| CAN ID (hex) | Message Type | Direction | Payload |
|
||||
|--------------|--------------|-----------|----------|
|
||||
| `0x03E8` | Moisture Data - Plant 0, Slot A | Sensor → Controller | u8 plant + u8 slot + u16 hz |
|
||||
| `0x0400` | Identify Command - Plant 0, Slot A | Controller → Sensor | *(empty)* |
|
||||
| `0x042F` | Moisture Data - Plant 15, Slot A | Sensor → Controller | u8 plant + u8 slot + u16 hz |
|
||||
| `0x0467` | Firmware Build - Plant 3, Slot B | Sensor → Controller | u32 build_minutes |
|
||||
|
||||
## ID Calculation Formula
|
||||
|
||||
```
|
||||
ID = 0x03E8 + Message_Offset + Plant_Index (+ Slot_Offset if B)
|
||||
```
|
||||
|
||||
### Constants
|
||||
|
||||
| Constant | Value (hex) | Description |
|
||||
|----------|-------------|-------------|
|
||||
| `SENSOR_BASE_ADDRESS` | `0x03E8` | Base address for all messages |
|
||||
| `MOISTURE_DATA_OFFSET` | `0x00` | Moisture data group |
|
||||
| `IDENTIFY_CMD_OFFSET` | `0x20` | Identify command group |
|
||||
| `FIRMWARE_BUILD_OFFSET` | `0x40` | Firmware build group |
|
||||
| `B_SLOT_OFFSET` | `0x10` | Offset for Slot B within a group |
|
||||
|
||||
### Message Type Offsets
|
||||
|
||||
```rust
|
||||
pub const MOISTURE_DATA_OFFSET: u16 = 0; // sensor → controller
|
||||
pub const IDENTIFY_CMD_OFFSET: u16 = 32; // controller → sensor
|
||||
pub const FIRMWARE_BUILD_OFFSET: u16 = 64; // sensor → controller
|
||||
```
|
||||
|
||||
## On-the-Wire Formats
|
||||
|
||||
### Moisture Data Frame (Sensor → Controller)
|
||||
|
||||
**CAN ID**: `0x03E8 + offset + plant_index` (or `+ 16 + plant_index` for Slot B)
|
||||
|
||||
| Byte | Field | Type |
|
||||
|------|-------|------|
|
||||
| 0 | `plant` | u8 (0–15) |
|
||||
| 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**: 0–15 (decimal) or 1–8 on hardware jumpers (mapped internally as 0–7)
|
||||
- **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.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user