37 Commits

Author SHA1 Message Date
Kai Börnert
30bcdf6f3b Add mode slider with "Simple," "Advanced," and "Expert" modes; dynamically hide/show UI elements based on selected mode. 2026-04-28 17:19:34 +02:00
Kai Börnert
9cd20cd56b Send MQTT "stay_alive" message before deep sleep; add delay to ensure message delivery. 2026-04-28 16:34:57 +02:00
Kai Börnert
3be8dc7f6a Expand and clarify AGENTS.md with detailed repository structure, critical development constraints, build/flash instructions, and validation guidelines. 2026-04-28 16:34:48 +02:00
Kai Börnert
e802af2a7a Switch println! to log::info in water.rs; add dynamic deep-sleep adjustment based on plant state; update heap metrics and OTA UI. 2026-04-28 15:19:56 +02:00
Kai Börnert
e0b8acd55c Add firmware build timestamp support for sensors; update detection workflows and UI accordingly. 2026-04-27 16:46:24 +02:00
Kai Börnert
c04109a76c Rename /version endpoint to /firmware_info; add heap memory statistics to firmware data and UI. 2026-04-27 15:46:29 +02:00
Kai Börnert
f0c9ed4e7f Add live log buffering support and endpoint; enhance log display functionality. 2026-04-27 15:04:05 +02:00
Kai Börnert
3fa8077b81 Update button labels to clarify sensor identification actions in plant.html 2026-04-27 13:56:32 +02:00
Kai Börnert
7f0714914f Add averaging over multiple windows for frequency measurement; optimize task yielding for USB stability. 2026-04-27 13:42:30 +02:00
61806a5fa2 Add mcutie MQTT client implementation and improve library structure
- Integrated `mcutie` library as a core MQTT client for device communication.
- Added support for Home Assistant entities (binary sensor, button) via MQTT.
- Implemented buffer management, async operations, and packet encoding/decoding.
- Introduced structured error handling and device registration features.
- Updated `Cargo.toml` with new dependencies and enabled feature flags for `serde` and `log`.
- Enhanced logging macros with configurable options (`defmt` or `log`).
- Organized codebase into modules (buffer, components, IO, publish, etc.) for better maintainability.
2026-04-27 09:39:29 +02:00
016047ab23 Update Water HAL: enhance GPIO config with drive mode and input settings
- Added `DriveMode` configuration and input enablement for `one_wire_pin`.
- Improved GPIO initialization with `InputConfig` and default pull settings.
2026-04-26 21:24:51 +02:00
eb276cfa68 Refactor HAL modules: update async support in Water module and reorganize detect_sensors logic
- Replaced `Blocking` with `Async` for ADC operations in `Water` module.
- Improved `detect_sensors` implementation with better structure for sensor messages and autodetection.
- Updated tank ADC sampling to yield between readings, improving efficiency.
2026-04-26 21:01:27 +02:00
f1c85d1d74 Migrate serialization from Bincode to Postcard
- Replaced Bincode with Postcard for serialization/deserialization across configs and save operations.
- Simplified struct derives by removing `bincode`-specific traits.
- Updated `Cargo.toml` and `Cargo.lock` to include `postcard` and dependencies.
- Added padding stripping for deserialization and improved error handling.
- Adjusted serialization logic in `savegame_manager.rs` and related modules.
2026-04-26 20:46:52 +02:00
097aff5360 Switch savegame serialization format from Bincode to custom parsing
- Replaced Bincode-based serialization/deserialization with a custom save format for better control.
- Introduced save header with magic bytes, timestamp handling, and UTF-8 validation.
- Enhanced error handling for save parsing and increased format flexibility.
- Removed
2026-04-26 20:31:56 +02:00
fc0e18da56 Integrate mcutie library for MQTT functionality
- Added `mcutie` as a dependency in `Cargo.toml` and updated `Cargo.lock`.
- Replaced commented-out MQTT logic with fully implemented functionality in `esp.rs`.
- Enhanced MQTT publish and subscription handling with configurable topics and error handling.
- Updated MQTT connection logic to improve reliability and logging.
2026-04-26 19:56:16 +02:00
2e4eb283b5 Add AGENTS.md to document repository structure and development guidelines
- Introduced `AGENTS.md` to provide an overview of the repository layout and working conventions.
- Included guidance for firmware, hardware, and website contributions.
- Added validation, file hygiene, and handoff expectations for consistent development practices.
2026-04-26 19:47:07 +02:00
cc92c82ac9 Fix incorrect spawn function call and update dependencies
- Corrected usage of `spawner.spawn` by fixing misplaced error propagation.
- Updated `Cargo.lock` with new and upgraded dependencies, including `base64`, `darling`, and `smoltcp` upgrades.
2026-04-26 19:46:46 +02:00
b8f01f0de9 Remove unused dependencies and imports, cleanup Cargo.lock
- Removed `smoltcp`, `defmt`, and associated dependencies as they are no longer used.
- Updated `Cargo.toml` to exclude unused features from `esp-radio`.
- Cleaned up imports in `esp.rs` for better clarity and consistency.
2026-04-26 19:08:18 +02:00
79daecf97d add lock for now, as otherwise it wont build 2026-04-26 16:06:13 +02:00
6b4fd3f701 Add DeepSleep log message and improve formatting consistency
- Introduced `DeepSleep` log message for tracking system sleep events.
- Updated MQTT topic to use `/state` instead of `/firmware/state`.
- Improved code formatting for enhanced readability and maintainability.
2026-04-17 00:31:21 +02:00
3157ba7e76 Merge branch 'test_new_storage' of ssh://git.mannheim.ccc.de:1337/C3MA/PlantCtrl into test_new_storage 2026-04-16 23:58:38 +02:00
2493507304 Refactor plant state handling and moisture interpretation
- Replaced `read_hardware_state` with `interpret_raw_values` for better abstraction and clarity.
- Enhanced error handling by introducing `NoMessage` and `NotExpectedMessage` states.
- Updated moisture sensor logic to differentiate expected and unexpected messages.
- Renamed and refactored enum fields for consistency (`raw_hz` to `hz`).
- Minor imports and formatting optimizations.
2026-04-16 23:58:23 +02:00
0f6cb5243c feat: add pump corrosion protection feature, extend error handling for pump operations, and enhance configuration options 2026-04-16 21:56:46 +02:00
b740574c68 refactor: add timezone support to wait_infinity, improve MQTT updates in config mode, and minor cleanup 2026-04-16 20:42:08 +02:00
6a71ac4234 Improve flash operation logging and serialization padding
- Added detailed logging for flash write and erase operations.
- Ensured serialized save data is aligned to 4-byte boundaries.
2026-04-14 00:19:18 +02:00
Kai Börnert
8ce00c9d95 Refactor async logging to synchronous; improve error handling consistency across modules. 2026-04-13 17:03:47 +02:00
964bdb0454 fix: handle non-200 responses in config update, ensure progress removal runs only on success 2026-04-13 12:38:00 +02:00
12405d1bef cleanup 2026-04-12 22:15:52 +02:00
0e3786a588 Add InterceptorLogger for async log capturing and enhanced debugging
- Implemented `InterceptorLogger` to enable async and sync log capture.
- Integrated log interception for easier diagnostics and debugging.
- Allowed log redirection to serial output via `esp_println`.
2026-04-12 20:45:36 +02:00
b26206eb96 Introduce watchdog and serialization improvements
- Added watchdog timer for improved system stability and responsiveness.
- Switched save data serialization to Bincode for better efficiency.
- Enhanced compatibility by supporting fallback to older JSON format.
- Improved logging during flash operations for easier debugging.
- Simplified SavegameManager by managing storage directly.
2026-04-12 20:38:52 +02:00
95f7488fa3 Add save timestamp support and log interceptor for enhanced debugging
- Introduced `created_at` metadata for saves, enabling timestamp tracking.
- Added `InterceptorLogger` to capture logs, aiding in error diagnostics.
- Updated web UI to display save creation timestamps.
- Improved save/load functionality to maintain compatibility with older formats.
2026-04-11 22:40:25 +02:00
0d7074bd89 save tests 2026-04-11 21:34:48 +02:00
bc25fef5ec refactor: consolidate logging and time handling, remove TIME_ACCESS and LOG_ACCESS 2026-04-10 18:53:30 +02:00
301298522b remove: eliminate file management and LittleFS-based filesystem, implement savegame management for JSON config slots with wear-leveling 2026-04-08 22:12:55 +02:00
1da6d54d7a new backup adjustments 2026-04-06 19:51:46 +02:00
0ad7a58219 Improve error handling, ensure robust defaults, and eliminate unsafe unwraps/expectations across modules. 2026-04-06 15:26:52 +02:00
4d4fcbe33b store backup now in binary, and let backend serialize/deserialize 2026-04-05 13:30:11 +02:00
64 changed files with 8275 additions and 1680 deletions

95
AGENTS.md Normal file
View File

@@ -0,0 +1,95 @@
# AGENTS.md
## Repository Overview
`PlantCtrl` is a mixed-discipline repo: two embedded Rust firmware targets, a shared crate, KiCad hardware, and a Hugo site.
Top-level layout:
- `Software/MainBoard/rust`: main firmware (`plant-ctrl2`) for ESP32-C6 controller board.
- `Software/CAN_Sensor`: sensor firmware (`bms`) for CH32V203C8T6 (CAN sensor / BMS board).
- `Software/Shared/canapi`: shared crate for CAN protocol/serialization — consumed by both firmware targets.
- `Hardware`: KiCad PCB designs and 3D case files.
- `DataSheets`: reference material; treat as source data, not generated output.
- `website`: Hugo site (Blowfish theme via git submodule at `website/themes/blowfish`).
- `bin`: helper scripts and dev tooling.
## Critical Constraints
- **No Cargo workspace** — each project has its own `Cargo.toml` and `Cargo.lock`. Run checks with `--manifest-path`.
- **Both firmware targets require `nightly` toolchain** (`rust-toolchain.toml`). Main board uses `build-std = ["alloc", "core"]` in `.cargo/config.toml`.
- **`no_std`/`no_main`** — both firmware crates are bare-metal. Do not introduce `std`-only dependencies.
- **Main board clippy rules** (`src/main.rs`): `#![deny(clippy::mem_forget, clippy::unwrap_used, clippy::expect_used, clippy::panic)]`. Do not add `#[allow(...)]` without explicit user request.
- **mcutie** (`src/mcutie_3_0_0/`): vendored sub-crate with `#![deny(unreachable_pub)]` and `#![warn(missing_docs)]`.
- **Flash layout** (main board `partitions.csv`): 16 MB total — `ota_0` (3968K), `ota_1` (3968K), `storage` (8M LittleFS), `nvs` (16K), `otadata` (8K), `phy_init` (4K).
- **CH32V203C8T6 memory** (`CAN_Sensor/memory.x`): 64 KB flash, 20 KB RAM. Tight budget — avoid allocations.
- **ESP IDF config** (`sdkconfig.defaults`): `CONFIG_ESP_MAIN_TASK_STACK_SIZE=50000`, `CONFIG_FREERTOS_HZ=1000`, `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y`.
## Firmware Build & Flash
### Main Board (ESP32-C6)
```
# Quick check (no build):
cargo check --manifest-path Software/MainBoard/rust/Cargo.toml
# Full build + flash + monitor:
bash Software/MainBoard/rust/flash.sh
# Build only (no flash):
cargo build --release --manifest-path Software/MainBoard/rust/Cargo.toml
# Build OTA image without flashing:
bash Software/MainBoard/rust/image_build.sh
# Erase OTA data partition (recover from bad flash):
bash Software/MainBoard/rust/erase_ota.sh
```
`flash.sh` rebuilds the webpack frontend (`src_webpack/`) then flashes via `espflash` at 921600 baud. The frontend is bundled into the firmware binary — do not run `npm` separately unless modifying `src_webpack/`.
### Sensor Board (CH32V203C8T6)
```
cargo check --manifest-path Software/CAN_Sensor/Cargo.toml
cargo build --release --manifest-path Software/CAN_Sensor/Cargo.toml
# Flash:
wchisp flash Software/CAN_Sensor/target/riscv32imc-unknown-none-elf/release/bms
```
Runner is preconfigured in `.cargo/config.toml` as `wchisp flash`. Debug via `openocd` + `gdb` (scripts in `CAN_Sensor/bin/`). See `CAN_Sensor/README.md` for MCU unlock steps.
### Shared Crate
```
cargo check --manifest-path Software/Shared/canapi/Cargo.toml
```
### Cross-impact
Any change to `canapi` (IDs, frame layout, serialization) must be checked against both firmware targets. The crate uses `bincode` for serialization.
## Architecture Notes
- **Main board entry**: `Software/MainBoard/rust/src/main.rs``#[esp_rtos::main]` async entry, Embassy executor, ESP-HAL.
- **HAL layer**: `src/hal/mod.rs` defines `PlantHal` trait; `esp.rs` and `v4_hal.rs` are concrete implementations.
- **mcutie**: `src/mcutie_3_0_0/` is a vendored MQTT/HA client library.
- **Webserver**: `src/webserver/` serves OTA, logs, static files over HTTP. Frontend in `src_webpack/`.
- **Sensor entry**: `Software/CAN_Sensor/src/main.rs``#[embassy_executor::main]` async entry, CH32-HAL, CAN + USB CDC.
- **Both targets**: Embassy executor, `heapless` for heapless data structures, `option-lock` for optional state.
## Website
```
cd website
npm run dev # starts Hugo dev server with Blowfish theme
```
Theme is a git submodule. Do not edit `website/themes/` directly — make customizations in `assets/` or `config/` instead.
## Working Rules
- Keep changes tightly scoped to the user request; this repo spans hardware, firmware, and website code.
- Preserve existing file structure and naming unless the user explicitly asks for restructuring.
- Avoid mass formatting or opportunistic cleanup in KiCad files, lockfiles, generated assets, or vendored dependencies.
- Do not edit dependency directories (`website/themes`, `src_webpack/node_modules`) unless explicitly asked.
- When touching firmware code, keep resource usage and target constraints in mind; avoid unnecessary allocations or feature creep.
- Shared protocol or serialization changes must be checked for impact across both firmware targets.
## Validation
- Use the narrowest relevant check first.
- Embedded firmware may require target-specific toolchains or hardware-adjacent tooling. If you cannot run a meaningful validation step, say so explicitly and describe the likely prerequisite.
## Handoff Expectations
- Summarize what changed, where it changed, and any validation performed.
- Call out follow-up work when a change likely affects both firmware targets, hardware assumptions, or the website.

Binary file not shown.

View File

@@ -8,4 +8,15 @@ fn main() {
std::fs::write(out_dir.join("memory.x"), include_bytes!("memory.x")).unwrap();
println!("cargo:rustc-link-search={}", out_dir.display());
println!("cargo:rerun-if-changed=memory.x");
// Embed firmware build timestamp as minutes since Unix epoch (4 bytes, big-endian).
// Dropping sub-minute precision keeps it in 4 bytes for many years.
let build_seconds = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("System time before UNIX_EPOCH")
.as_secs();
let build_minutes = (build_seconds / 60) as u32;
let bytes = build_minutes.to_be_bytes();
std::fs::write(out_dir.join("build_minutes.bin"), bytes).unwrap();
println!("cargo:rerun-if-changed=build.rs");
}

View File

@@ -3,7 +3,7 @@
extern crate alloc;
use crate::hal::peripherals::CAN1;
use canapi::id::{plant_id, IDENTIFY_CMD_OFFSET, MOISTURE_DATA_OFFSET};
use canapi::id::{plant_id, FIRMWARE_BUILD_OFFSET, IDENTIFY_CMD_OFFSET, MOISTURE_DATA_OFFSET};
use canapi::SensorSlot;
use ch32_hal::adc::{Adc, SampleTime, ADC_MAX};
use ch32_hal::{pac};
@@ -47,6 +47,10 @@ static CAN_TX_CH: Channel<CriticalSectionRawMutex, CanFrame, 4> = Channel::new()
static BEACON: AtomicBool = AtomicBool::new(false);
/// Firmware build timestamp in minutes since Unix epoch, embedded at compile time.
const FIRMWARE_BUILD_MINUTES: u32 =
u32::from_be_bytes(*include_bytes!(concat!(env!("OUT_DIR"), "/build_minutes.bin")));
#[embassy_executor::main(entry = "qingke_rt::entry")]
async fn main(spawner: Spawner) {
ch32_hal::pac::AFIO.pcfr1().write(|w| w.set_can1_rm(2));
@@ -111,6 +115,7 @@ async fn main(spawner: Spawner) {
}
let moisture_id = plant_id(MOISTURE_DATA_OFFSET, slot, addr as u16);
let identify_id = plant_id(IDENTIFY_CMD_OFFSET, slot, addr as u16);
let firmware_build_id = plant_id(FIRMWARE_BUILD_OFFSET, slot, addr as u16);
let standard_identify_id = StandardId::new(identify_id).unwrap();
//is any floating, or invalid addr (only 1-8 are valid)
@@ -269,8 +274,9 @@ async fn main(spawner: Spawner) {
// filter.get(0).unwrap().set(Id::Standard(standard_identify_id), Default::default());
// can.add_filter(filter);
let standard_moisture_id = StandardId::new(moisture_id).unwrap();
let standard_firmware_build_id = StandardId::new(firmware_build_id).unwrap();
spawner
.spawn(can_task(can,info, warn, standard_identify_id, standard_moisture_id))
.spawn(can_task(can, info, warn, standard_identify_id, standard_moisture_id, standard_firmware_build_id))
.unwrap();
// move Q output, LED, ADC and analog input into worker task
@@ -282,6 +288,7 @@ async fn main(spawner: Spawner) {
ain,
standard_moisture_id,
standard_identify_id,
standard_firmware_build_id,
))
.unwrap();
}
@@ -362,6 +369,7 @@ async fn can_task(
warn: &'static mut Output<'static>,
identify_id: StandardId,
moisture_id: StandardId,
firmware_build_id: StandardId,
) {
// Non-blocking beacon blink timing.
// We keep this inside the CAN task so it can't stall other tasks (like `worker`) with `await`s.
@@ -460,65 +468,80 @@ async fn worker(
mut ain: hal::peripherals::PA1,
moisture_id: StandardId,
identify_id: StandardId,
firmware_build_id: StandardId,
) {
// 555 emulation state: Q initially Low
let mut q_high = false;
let low_th: u16 = (ADC_MAX as u16) / 3; // ~1/3 Vref
let high_th: u16 = ((ADC_MAX as u32 * 2) / 3) as u16; // ~2/3 Vref
const AVG_WINDOWS: u32 = 4;
const YIELD_EVERY: u32 = 64;
let probe_duration = Duration::from_millis(100);
loop {
// Count rising edges of Q in a 100 ms window
let start = Instant::now();
let mut pulses: u32 = 0;
let mut last_q = q_high;
let mut total_pulses: u32 = 0;
probe_gnd.set_as_output(Speed::Low);
probe_gnd.set_low();
let probe_duration = Duration::from_millis(100);
while Instant::now()
.checked_duration_since(start)
.unwrap_or(Duration::from_millis(0))
< probe_duration
{
// Sample the analog input (Threshold/Trigger on A1)
let val: u16 = adc.convert(&mut ain, SampleTime::CYCLES28_5);
for _ in 0..AVG_WINDOWS {
// Count rising edges of Q in a 100 ms window
let start = Instant::now();
let mut pulses: u32 = 0;
let mut last_q = q_high;
let mut iter_count: u32 = 0;
// 555 core behavior:
// - If input <= 1/3 Vref => set Q high (trigger)
// - If input >= 2/3 Vref => set Q low (threshold)
// - Otherwise keep previous Q state (hysteresis)
if val <= low_th {
q_high = true;
} else if val >= high_th {
q_high = false;
probe_gnd.set_as_output(Speed::Low);
probe_gnd.set_low();
while Instant::now()
.checked_duration_since(start)
.unwrap_or(Duration::from_millis(0))
< probe_duration
{
// Sample the analog input (Threshold/Trigger on A1)
let val: u16 = adc.convert(&mut ain, SampleTime::CYCLES28_5);
// 555 core behavior:
// - If input <= 1/3 Vref => set Q high (trigger)
// - If input >= 2/3 Vref => set Q low (threshold)
// - Otherwise keep previous Q state (hysteresis)
if val <= low_th {
q_high = true;
} else if val >= high_th {
q_high = false;
}
// Drive output pin accordingly
if q_high {
q.set_high();
} else {
q.set_low();
}
// Count rising edges
if !last_q && q_high {
pulses = pulses.saturating_add(1);
}
last_q = q_high;
// Yield every YIELD_EVERY samples to keep USB alive without
// disrupting per-sample timing
iter_count += 1;
if iter_count % YIELD_EVERY == 0 {
yield_now().await;
}
}
// Drive output pin accordingly
if q_high {
q.set_high();
} else {
q.set_low();
}
// Count rising edges
if !last_q && q_high {
pulses = pulses.saturating_add(1);
}
last_q = q_high;
// Yield to allow USB and other tasks to run
yield_now().await;
probe_gnd.set_as_input(Pull::None);
total_pulses = total_pulses.saturating_add(pulses);
}
probe_gnd.set_as_input(Pull::None);
let freq_hz: u32 = pulses * (1000 / probe_duration.as_millis()) as u32; // pulses per 0.1s => Hz
let avg_pulses = total_pulses / AVG_WINDOWS;
let freq_hz: u32 = avg_pulses * (1000 / probe_duration.as_millis()) as u32;
let mut msg: heapless::String<128> = heapless::String::new();
let _ = write!(
&mut msg,
"555 window={}ms pulses={} freq={} Hz (A1->Q on PB0) id={:?}\r\n",
probe_duration.as_millis(),
pulses,
"555 window={}ms avg_pulses={} freq={} Hz (A1->Q on PB0) id={:?}\r\n",
probe_duration.as_millis() * AVG_WINDOWS as u64,
avg_pulses,
freq_hz,
identify_id.as_raw()
);
@@ -526,6 +549,12 @@ async fn worker(
let moisture = CanFrame::new(moisture_id, &(freq_hz as u32).to_be_bytes()).unwrap();
CAN_TX_CH.send(moisture).await;
// Send firmware build timestamp after each measurement so the controller
// always has up-to-date build info without requiring an identify request.
if let Some(build_frame) = CanFrame::new(firmware_build_id, &FIRMWARE_BUILD_MINUTES.to_be_bytes()) {
CAN_TX_CH.send(build_frame).await;
}
}
}

View File

@@ -23,8 +23,6 @@ target = "riscv32imac-unknown-none-elf"
CHRONO_TZ_TIMEZONE_FILTER = "UTC|America/New_York|America/Chicago|America/Los_Angeles|Europe/London|Europe/Berlin|Europe/Paris|Asia/Tokyo|Asia/Shanghai|Asia/Kolkata|Australia/Sydney|America/Sao_Paulo|Africa/Johannesburg|Asia/Dubai|Pacific/Auckland"
CARGO_WORKSPACE_DIR = { value = "", relative = true }
ESP_LOG = "info"
PATH = { value = "../../../bin:/usr/bin:/usr/local/bin", force = true, relative = true }
[unstable]

2835
Software/MainBoard/rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -46,57 +46,34 @@ canapi = { path = "../../Shared/canapi" }
# Platform and ESP-specific runtime/boot/runtime utilities
log = "0.4.28"
esp-bootloader-esp-idf = { version = "0.4.0", features = ["esp32c6", "log-04"] }
esp-hal = { version = "1.0.0", features = ["esp32c6", "log-04"] }
esp-rtos = { version = "0.2.0", features = ["esp32c6", "embassy", "esp-radio"] }
esp-backtrace = { version = "0.18.1", features = ["esp32c6", "panic-handler", "println", "colors", "custom-halt"] }
esp-println = { version = "0.16.1", features = ["esp32c6", "log-04", "auto"] }
esp-storage = { version = "0.8.1", features = ["esp32c6"] }
esp-radio = { version = "0.17.0", features = ["esp32c6", "log-04", "smoltcp", "wifi", "unstable"] }
esp-alloc = { version = "0.9.0", features = ["esp32c6", "internal-heap-stats"] }
esp-bootloader-esp-idf = { version = "0.5.0", features = ["esp32c6", "log-04"] }
esp-hal = { version = "1.1.0", features = ["esp32c6", "log-04"] }
esp-rtos = { version = "0.3.0", features = ["esp32c6", "embassy", "esp-radio"] }
esp-backtrace = { version = "0.19.0", features = ["esp32c6", "panic-handler", "println", "colors", "custom-halt"] }
esp-println = { version = "0.17.0", features = ["esp32c6", "log-04", "auto"] }
esp-storage = { version = "0.9.0", features = ["esp32c6"] }
esp-radio = { version = "0.18.0", features = ["esp32c6", "log-04", "wifi", "unstable"] }
esp-alloc = { version = "0.10.0", features = ["esp32c6", "internal-heap-stats"] }
# Async runtime (Embassy core)
embassy-executor = { version = "0.9.1", features = ["log", "nightly"] }
embassy-time = { version = "0.5.0", features = ["log"], default-features = false }
embassy-sync = { version = "0.7.2", features = ["log"] }
embassy-executor = { version = "0.10.0", features = ["log", "nightly"] }
embassy-time = { version = "0.5.1", features = ["log"], default-features = false }
embassy-sync = { version = "0.8.0", features = ["log"] }
# Networking and protocol stacks
embassy-net = { version = "0.7.1", features = [
"dhcpv4",
"log",
"medium-ethernet",
"tcp",
"udp",
"proto-ipv4",
"dns"
] }
smoltcp = { version = "0.12.0", default-features = false, features = [
"alloc",
"log",
"medium-ethernet",
"multicast",
"proto-dhcpv4",
"proto-ipv6",
"proto-dns",
"proto-ipv4",
"socket-dns",
"socket-icmp",
"socket-raw",
"socket-tcp",
"socket-udp",
] }
embassy-net = { version = "0.8.0", features = ["dhcpv4", "log", "medium-ethernet", "tcp", "udp", "proto-ipv4", "dns", "proto-ipv6"] }
sntpc = { version = "0.6.1", default-features = false, features = ["log", "embassy-socket", "embassy-socket-ipv6"] }
edge-dhcp = "0.6.0"
edge-nal = "0.5.0"
edge-nal-embassy = "0.6.0"
edge-http = { version = "0.6.1", features = ["log"] }
edge-dhcp = "0.7.0"
edge-nal = "0.6.0"
edge-nal-embassy = "0.8.1"
edge-http = { version = "0.7.0", features = ["log"] }
esp32c6 = { version = "0.22.0" }
esp32c6 = { version = "0.23.2" }
# Hardware abstraction traits and HAL adapters
embedded-hal = "1.0.0"
embedded-storage = "0.3.1"
embassy-embedded-hal = "0.5.0"
embassy-embedded-hal = "0.6.0"
embedded-can = "0.4.1"
nb = "1.1.0"
@@ -109,13 +86,12 @@ pca9535 = { version = "2.0.0" }
ina219 = { version = "0.2.0" }
# Storage and filesystem
littlefs2 = { version = "0.6.1", features = ["c-stubs", "alloc"] }
littlefs2-core = "0.1.2"
embedded-savegame = { version = "0.3.0" }
# Serialization / codecs
serde = { version = "1.0.228", features = ["derive", "alloc"], default-features = false }
serde_json = { version = "1.0.145", default-features = false, features = ["alloc"] }
bincode = { version = "2.0.1", default-features = false, features = ["derive"] }
postcard = { version = "1.1.3", default-features = false, features = ["alloc"] }
# Time and time zones
chrono = { version = "0.4.42", default-features = false, features = ["iana-time-zone", "alloc", "serde"] }
@@ -133,12 +109,8 @@ unit-enum = "1.4.3"
async-trait = "0.1.89"
option-lock = { version = "0.3.1", default-features = false }
measurements = "0.11.1"
mcutie = { path = "src/mcutie_3_0_0", features = ["log"] }
# Project-specific
mcutie = { version = "0.3.0", default-features = false, features = ["log", "homeassistant"] }
[patch.crates-io]
mcutie = { git = 'https://github.com/empirephoenix/mcutie.git' }
#bq34z100 = { path = "../../bq34z100_rust" }
[build-dependencies]

View File

@@ -0,0 +1,2 @@
# This file is used for clippy configuration.
# It shouldn't contain the deny attributes, which belong to the crate root.

View File

@@ -1,8 +1,6 @@
[connection]
format = "EspIdf"
[[usb_device]]
vid = "303a"
pid = "1001"
[idf_format_args]
[flash]
size = "16MB"

View File

@@ -1,17 +1,16 @@
use crate::hal::PLANT_COUNT;
use crate::plant_state::PlantWateringMode;
use alloc::string::String;
use core::str::FromStr;
use alloc::string::{String, ToString};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(default)]
pub struct NetworkConfig {
pub ap_ssid: heapless::String<32>,
pub ssid: Option<heapless::String<32>>,
pub password: Option<heapless::String<64>>,
pub ap_ssid: String,
pub ssid: Option<String>,
pub password: Option<String>,
pub mqtt_url: Option<String>,
pub base_topic: Option<heapless::String<64>>,
pub base_topic: Option<String>,
pub mqtt_user: Option<String>,
pub mqtt_password: Option<String>,
pub max_wait: u32,
@@ -19,7 +18,7 @@ pub struct NetworkConfig {
impl Default for NetworkConfig {
fn default() -> Self {
Self {
ap_ssid: heapless::String::from_str("PlantCtrl Init").unwrap(),
ap_ssid: "PlantCtrl Init".to_string(),
ssid: None,
password: None,
mqtt_url: None,
@@ -96,6 +95,8 @@ pub enum BoardVersion {
pub struct BoardHardware {
pub board: BoardVersion,
pub battery: BatteryBoardVersion,
#[serde(default)]
pub pump_corrosion_protection: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]

View File

@@ -1,5 +1,7 @@
use alloc::format;
use alloc::string::{String, ToString};
use chrono::format::ParseErrorKind;
use chrono_tz::ParseError;
use core::convert::Infallible;
use core::fmt;
use core::fmt::Debug;
@@ -14,28 +16,18 @@ use esp_hal::twai::EspTwaiError;
use esp_radio::wifi::WifiError;
use ina219::errors::{BusVoltageReadError, ShuntVoltageReadError};
use lib_bms_protocol::BmsProtocolError;
use littlefs2_core::PathError;
use onewire::Error;
use pca9535::ExpanderError;
//All error superconstruct
#[derive(Debug)]
pub enum FatError {
BMSError {
error: String,
},
OneWireError {
error: Error<Infallible>,
},
String {
error: String,
},
LittleFSError {
error: littlefs2_core::Error,
},
PathError {
error: PathError,
},
TryLockError {
error: TryLockError,
},
@@ -86,8 +78,6 @@ impl fmt::Display for FatError {
}
FatError::OneWireError { error } => write!(f, "OneWireError {error:?}"),
FatError::String { error } => write!(f, "{error}"),
FatError::LittleFSError { error } => write!(f, "LittleFSError {error:?}"),
FatError::PathError { error } => write!(f, "PathError {error:?}"),
FatError::TryLockError { error } => write!(f, "TryLockError {error:?}"),
FatError::WifiError { error } => write!(f, "WifiError {error:?}"),
FatError::SerdeError { error } => write!(f, "SerdeError {error:?}"),
@@ -106,7 +96,6 @@ impl fmt::Display for FatError {
write!(f, "CanBusError {error:?}")
}
FatError::SNTPError { error } => write!(f, "SNTPError {error:?}"),
FatError::BMSError { error } => write!(f, "BMSError, {error}"),
FatError::OTAError => {
write!(f, "OTA missing partition")
}
@@ -149,23 +138,28 @@ impl<T> ContextExt<T> for Option<T> {
}
}
impl<T, E> ContextExt<T> for Result<T, E>
where
E: fmt::Debug,
{
fn context<C>(self, context: C) -> Result<T, FatError>
where
C: AsRef<str>,
{
match self {
Ok(value) => Ok(value),
Err(err) => Err(FatError::String {
error: format!("{}: {:?}", context.as_ref(), err),
}),
}
}
}
impl From<Error<Infallible>> for FatError {
fn from(error: Error<Infallible>) -> Self {
FatError::OneWireError { error }
}
}
impl From<littlefs2_core::Error> for FatError {
fn from(value: littlefs2_core::Error) -> Self {
FatError::LittleFSError { error: value }
}
}
impl From<PathError> for FatError {
fn from(value: PathError) -> Self {
FatError::PathError { error: value }
}
}
impl From<TryLockError> for FatError {
fn from(value: TryLockError) -> Self {
FatError::TryLockError { error: value }
@@ -236,16 +230,8 @@ impl<E: fmt::Debug> From<ExpanderError<I2cDeviceError<E>>> for FatError {
}
}
impl From<bincode::error::DecodeError> for FatError {
fn from(value: bincode::error::DecodeError) -> Self {
FatError::Eeprom24x {
error: format!("{value:?}"),
}
}
}
impl From<bincode::error::EncodeError> for FatError {
fn from(value: bincode::error::EncodeError) -> Self {
impl From<postcard::Error> for FatError {
fn from(value: postcard::Error) -> Self {
FatError::Eeprom24x {
error: format!("{value:?}"),
}
@@ -283,7 +269,7 @@ impl<E: fmt::Debug> From<ShuntVoltageReadError<I2cDeviceError<E>>> for FatError
impl From<Infallible> for FatError {
fn from(value: Infallible) -> Self {
panic!("Infallible error: {:?}", value)
match value {}
}
}
@@ -336,3 +322,27 @@ impl From<BmsProtocolError> for FatError {
}
}
}
impl From<ParseError> for FatError {
fn from(value: ParseError) -> Self {
FatError::String {
error: format!("Parsing error: {value:?}"),
}
}
}
impl From<ParseErrorKind> for FatError {
fn from(value: ParseErrorKind) -> Self {
FatError::String {
error: format!("Parsing error: {value:?}"),
}
}
}
impl From<chrono::format::ParseError> for FatError {
fn from(value: chrono::format::ParseError) -> Self {
FatError::String {
error: format!("Parsing error: {value:?}"),
}
}
}

View File

@@ -1,24 +1,23 @@
use crate::bail;
use crate::config::{NetworkConfig, PlantControllerConfig};
use crate::hal::{PLANT_COUNT, TIME_ACCESS};
use crate::log::{LogMessage, LOG_ACCESS};
use crate::hal::savegame_manager::SavegameManager;
use crate::hal::PLANT_COUNT;
use crate::log::{log, LogMessage};
use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::fat_error::{ContextExt, FatError, FatResult};
use crate::hal::little_fs2storage_adapter::LittleFs2Filesystem;
use crate::hal::shared_flash::MutexFlashStorage;
use alloc::string::ToString;
use alloc::sync::Arc;
use alloc::{format, string::String, vec, vec::Vec};
use core::net::{IpAddr, Ipv4Addr, SocketAddr};
use core::str::FromStr;
use core::sync::atomic::Ordering;
use embassy_executor::Spawner;
use embassy_net::udp::UdpSocket;
use embassy_net::{DhcpConfig, Ipv4Cidr, Runner, Stack, StackResources, StaticConfigV4};
use embassy_net::dns::DnsQueryType;
use embassy_net::udp::{PacketMetadata, UdpSocket};
use embassy_net::{DhcpConfig, IpAddress, Ipv4Cidr, Runner, Stack, StackResources, StaticConfigV4};
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::{Mutex, MutexGuard};
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};
@@ -35,21 +34,17 @@ use esp_hal::system::software_reset;
use esp_hal::uart::Uart;
use esp_hal::Blocking;
use esp_println::println;
use esp_radio::wifi::{
AccessPointConfig, AccessPointInfo, AuthMethod, ClientConfig, ModeConfig, ScanConfig,
ScanTypeConfig, WifiController, WifiDevice, WifiStaState,
};
use littlefs2::fs::Filesystem;
use littlefs2_core::{FileType, PathBuf, SeekFrom};
use esp_radio::wifi::ap::{AccessPointConfig, 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 smoltcp::socket::udp::PacketMetadata;
use smoltcp::wire::DnsQueryType;
use sntpc::{get_time, NtpContext, NtpTimestampGenerator};
use sntpc::{get_time, NtpContext, NtpTimestampGenerator, NtpUdpSocket};
#[esp_hal::ram(unstable(rtc_fast), unstable(persistent))]
static mut LAST_WATERING_TIMESTAMP: [i64; PLANT_COUNT] = [0; PLANT_COUNT];
@@ -59,8 +54,9 @@ static mut CONSECUTIVE_WATERING_PLANT: [u32; PLANT_COUNT] = [0; PLANT_COUNT];
static mut LOW_VOLTAGE_DETECTED: i8 = 0;
#[esp_hal::ram(unstable(rtc_fast), unstable(persistent))]
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 CONFIG_FILE: &str = "config.json";
const NTP_SERVER: &str = "pool.ntp.org";
static MQTT_CONNECTED_EVENT_RECEIVED: AtomicBool = AtomicBool::new(false);
@@ -68,24 +64,44 @@ 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(Serialize, Debug)]
pub struct FileInfo {
filename: String,
size: usize,
}
#[derive(Serialize, Debug)]
pub struct FileList {
total: usize,
used: usize,
files: Vec<FileInfo>,
}
#[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);
@@ -117,11 +133,11 @@ impl NtpTimestampGenerator for Timestamp {
}
pub struct Esp<'a> {
pub fs: Arc<Mutex<CriticalSectionRawMutex, Filesystem<'static, LittleFs2Filesystem>>>,
pub savegame: SavegameManager,
pub rng: Rng,
//first starter (ap or sta will take these)
pub interface_sta: Option<WifiDevice<'static>>,
pub interface_ap: Option<WifiDevice<'static>>,
pub interface_sta: Option<Interface<'static>>,
pub interface_ap: Option<Interface<'static>>,
pub controller: Arc<Mutex<CriticalSectionRawMutex, WifiController<'static>>>,
pub boot_button: Input<'a>,
@@ -130,6 +146,8 @@ pub struct Esp<'a> {
pub wake_gpio1: esp_hal::peripherals::GPIO1<'static>,
pub uart0: Uart<'a, Blocking>,
pub rtc: Rtc<'a>,
pub ota: Ota<'static, RmwNorFlashStorage<'static, &'static mut MutexFlashStorage>>,
pub ota_target: &'static mut FlashRegion<'static, MutexFlashStorage>,
pub current: AppPartitionSubType,
@@ -155,6 +173,15 @@ macro_rules! mk_static {
}
impl Esp<'_> {
pub fn get_time(&self) -> DateTime<Utc> {
DateTime::from_timestamp_micros(self.rtc.current_time_us() as i64)
.unwrap_or(DateTime::UNIX_EPOCH)
}
pub fn set_time(&mut self, time: DateTime<Utc>) {
self.rtc.set_current_time_us(time.timestamp_micros() as u64);
}
pub(crate) async fn read_serial_line(&mut self) -> FatResult<Option<alloc::string::String>> {
let mut buf = [0u8; 1];
let mut line = String::new();
@@ -185,69 +212,6 @@ impl Esp<'_> {
}
}
}
pub(crate) async fn delete_file(&self, filename: String) -> FatResult<()> {
let file = PathBuf::try_from(filename.as_str())?;
let access = self.fs.lock().await;
access.remove(&file)?;
Ok(())
}
pub(crate) async fn write_file(
&mut self,
filename: String,
offset: u32,
buf: &[u8],
) -> Result<(), FatError> {
let file = PathBuf::try_from(filename.as_str())?;
let access = self.fs.lock().await;
access.open_file_with_options_and_then(
|options| options.read(true).write(true).create(true),
&file,
|file| {
file.seek(SeekFrom::Start(offset))?;
file.write(buf)?;
Ok(())
},
)?;
Ok(())
}
pub async fn get_size(&mut self, filename: String) -> FatResult<usize> {
let file = PathBuf::try_from(filename.as_str())?;
let access = self.fs.lock().await;
let data = access.metadata(&file)?;
Ok(data.len())
}
pub(crate) async fn get_file(
&mut self,
filename: String,
chunk: u32,
) -> FatResult<([u8; 512], usize)> {
use littlefs2::io::Error as lfs2Error;
let file = PathBuf::try_from(filename.as_str())?;
let access = self.fs.lock().await;
let mut buf = [0_u8; 512];
let mut read = 0;
let offset = chunk * buf.len() as u32;
access.open_file_with_options_and_then(
|options| options.read(true),
&file,
|file| {
let length = file.len()? as u32;
if length == 0 {
Err(lfs2Error::IO)
} else if length > offset {
file.seek(SeekFrom::Start(offset))?;
read = file.read(&mut buf)?;
Ok(())
} else {
//exactly at end, do nothing
Ok(())
}
},
)?;
Ok((buf, read))
}
pub(crate) async fn write_ota(&mut self, offset: u32, buf: &[u8]) -> Result<(), FatError> {
let _ = check_erase(self.ota_target, offset, offset + 4096);
@@ -314,23 +278,26 @@ impl Esp<'_> {
&mut tx_meta,
&mut tx_buffer,
);
socket.bind(123).unwrap();
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;
if ntp_addrs.is_err() {
bail!("Failed to resolve DNS");
.await
.context("Failed to resolve DNS")?;
if ntp_addrs.is_empty() {
bail!("No IP addresses found for NTP server");
}
let ntp = ntp_addrs.unwrap()[0];
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)), &socket, context)
let timeout = get_time(SocketAddr::from((addr, 123)), &ntp_socket, context)
.with_timeout(Duration::from_millis((_max_wait_ms / 10) as u64))
.await;
@@ -358,10 +325,10 @@ impl Esp<'_> {
let mut lock = self.controller.try_lock()?;
info!("start wifi scan lock");
let scan_config = ScanConfig::default().with_scan_type(ScanTypeConfig::Active {
min: Default::default(),
max: Default::default(),
min: esp_hal::time::Duration::from_millis(0),
max: esp_hal::time::Duration::from_millis(0),
});
let rv = lock.scan_with_config_async(scan_config).await?;
let rv = lock.scan_async(&scan_config).await?;
info!("end wifi scan lock");
Ok(rv)
}
@@ -409,6 +376,14 @@ impl Esp<'_> {
}
}
}
pub(crate) fn get_last_corrosion_protection_check_day(&self) -> i8 {
unsafe { LAST_CORROSION_PROTECTION_CHECK_DAY }
}
pub(crate) fn set_last_corrosion_protection_check_day(&mut self, day: i8) {
unsafe {
LAST_CORROSION_PROTECTION_CHECK_DAY = day;
}
}
pub(crate) async fn wifi_ap(&mut self, spawner: Spawner) -> FatResult<Stack<'static>> {
let ssid = match self.load_config().await {
@@ -416,9 +391,11 @@ impl Esp<'_> {
Err(_) => "PlantCtrl Emergency Mode".to_string(),
};
let device = self.interface_ap.take().unwrap();
let gw_ip_addr_str = "192.168.71.1";
let gw_ip_addr = Ipv4Addr::from_str(gw_ip_addr_str).expect("failed to parse gateway ip");
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),
@@ -439,15 +416,13 @@ impl Esp<'_> {
let stack = mk_static!(Stack, stack);
let client_config =
ModeConfig::AccessPoint(AccessPointConfig::default().with_ssid(ssid.clone()));
Config::AccessPoint(AccessPointConfig::default().with_ssid(ssid.clone()));
self.controller.lock().await.set_config(&client_config)?;
println!("start new");
self.controller.lock().await.start()?;
println!("start net task");
spawner.spawn(net_task(runner)).ok();
spawner.spawn(net_task(runner)?);
println!("run dhcp");
spawner.spawn(run_dhcp(*stack, gw_ip_addr_str)).ok();
spawner.spawn(run_dhcp(*stack, gw_ip_addr)?);
loop {
if stack.is_link_up() {
@@ -458,7 +433,7 @@ impl Esp<'_> {
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_str}/");
println!("Connect to the AP `${ssid}` and point your browser to http://{gw_ip_addr}/");
stack
.config_v4()
.inspect(|c| println!("ipv4 config: {c:?}"));
@@ -472,18 +447,17 @@ impl Esp<'_> {
spawner: Spawner,
) -> FatResult<Stack<'static>> {
esp_radio::wifi_set_log_verbose();
let ssid = network_config.ssid.clone();
match &ssid {
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")
}
}
let ssid = ssid.unwrap().to_string();
};
info!("attempting to connect wifi {ssid}");
let password = match network_config.password {
Some(ref password) => password.to_string(),
@@ -491,7 +465,10 @@ impl Esp<'_> {
};
let max_wait = network_config.max_wait;
let device = self.interface_sta.take().unwrap();
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;
@@ -505,100 +482,67 @@ impl Esp<'_> {
);
let stack = mk_static!(Stack, stack);
let client_config = ClientConfig::default()
let auth_method = if password.is_empty() {
AuthenticationMethod::None
} else {
AuthenticationMethod::Wpa2Personal
};
let client_config = StationConfig::default()
.with_ssid(ssid)
.with_auth_method(AuthMethod::Wpa2Personal)
.with_auth_method(auth_method)
.with_password(password);
self.controller
.lock()
.await
.set_config(&ModeConfig::Client(client_config))?;
spawner.spawn(net_task(runner)).ok();
self.controller.lock().await.start_async().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 timeout = {
let guard = TIME_ACCESS.get().await.lock().await;
guard.current_time_us()
} + max_wait as u64 * 1000;
loop {
let state = esp_radio::wifi::sta_state();
if state == WifiStaState::Started {
self.controller.lock().await.connect()?;
break;
let res = async {
while !stack.is_link_up() {
Timer::after(Duration::from_millis(500)).await;
}
if {
let guard = TIME_ACCESS.get().await.lock().await;
guard.current_time_us()
} > timeout
{
bail!("Timeout waiting for wifi sta ready")
}
Timer::after(Duration::from_millis(500)).await;
Ok::<(), FatError>(())
}
let timeout = {
let guard = TIME_ACCESS.get().await.lock().await;
guard.current_time_us()
} + max_wait as u64 * 1000;
loop {
let state = esp_radio::wifi::sta_state();
if state == WifiStaState::Connected {
break;
}
if {
let guard = TIME_ACCESS.get().await.lock().await;
guard.current_time_us()
} > timeout
{
bail!("Timeout waiting for wifi sta connected")
}
Timer::after(Duration::from_millis(500)).await;
.with_timeout(Duration::from_millis(max_wait as u64 * 1000))
.await;
if res.is_err() {
bail!("Timeout waiting for wifi link up")
}
let timeout = {
let guard = TIME_ACCESS.get().await.lock().await;
guard.current_time_us()
} + max_wait as u64 * 1000;
while !stack.is_link_up() {
if {
let guard = TIME_ACCESS.get().await.lock().await;
guard.current_time_us()
} > timeout
{
bail!("Timeout waiting for wifi link up")
let res = async {
while !stack.is_config_up() {
Timer::after(Duration::from_millis(100)).await
}
Timer::after(Duration::from_millis(500)).await;
Ok::<(), FatError>(())
}
let timeout = {
let guard = TIME_ACCESS.get().await.lock().await;
guard.current_time_us()
} + max_wait as u64 * 1000;
while !stack.is_config_up() {
if {
let guard = TIME_ACCESS.get().await.lock().await;
guard.current_time_us()
} > timeout
{
bail!("Timeout waiting for wifi config up")
}
Timer::after(Duration::from_millis(100)).await
.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,
mut rtc: MutexGuard<CriticalSectionRawMutex, Rtc>,
) -> ! {
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() {
if cur == OtaImageState::PendingVerify {
info!("Marking OTA image as valid");
self.ota
.set_current_ota_state(Valid)
.expect("Could not set image to valid");
if let Err(err) = self.ota.set_current_ota_state(Valid) {
error!("Could not set image to valid: {:?}", err);
}
}
} else {
info!("No OTA image to mark as valid");
@@ -611,52 +555,54 @@ impl Esp<'_> {
let mut wake_pins: [(&mut dyn RtcPinWithResistors, WakeupLevel); 1] =
[(&mut self.wake_gpio1, WakeupLevel::Low)];
let ext1 = esp_hal::rtc_cntl::sleep::Ext1WakeupSource::new(&mut wake_pins);
rtc.sleep_deep(&[&timer, &ext1]);
self.rtc.sleep_deep(&[&timer, &ext1]);
}
}
/// Load the most recently saved config from flash.
pub(crate) async fn load_config(&mut self) -> FatResult<PlantControllerConfig> {
let cfg = PathBuf::try_from(CONFIG_FILE)?;
let config_exist = self.fs.lock().await.exists(&cfg);
if !config_exist {
bail!("No config file stored")
}
let data = self.fs.lock().await.read::<4096>(&cfg)?;
let config: PlantControllerConfig = serde_json::from_slice(&data)?;
Ok(config)
}
pub(crate) async fn save_config(&mut self, config: Vec<u8>) -> FatResult<()> {
let filesystem = self.fs.lock().await;
let cfg = PathBuf::try_from(CONFIG_FILE)?;
filesystem.write(&cfg, &config)?;
Ok(())
}
pub(crate) async fn list_files(&self) -> FatResult<FileList> {
let path = PathBuf::new();
let fs = self.fs.lock().await;
let free_size = fs.available_space()?;
let total_size = fs.total_space();
let mut result = FileList {
total: total_size,
used: total_size - free_size,
files: Vec::new(),
};
fs.read_dir_and_then(&path, |dir| {
for entry in dir {
let e = entry?;
if e.file_type() == FileType::File {
result.files.push(FileInfo {
filename: e.path().to_string(),
size: e.metadata().len(),
});
}
match self.savegame.load_latest()? {
None => bail!("No config stored"),
Some(data) => {
let config: PlantControllerConfig = serde_json::from_slice(&data)?;
Ok(config)
}
Ok(())
})?;
Ok(result)
}
}
/// Load a config from a specific save slot.
pub(crate) async fn load_config_slot(&mut self, idx: usize) -> FatResult<String> {
match self.savegame.load_slot(idx)? {
None => bail!("Slot {idx} is empty or invalid"),
Some(data) => Ok(String::from_utf8_lossy(&data).to_string()),
}
}
/// Persist a JSON config blob to the next wear-leveling slot.
/// Retries once on flash error.
pub(crate) async fn save_config(&mut self, config: Vec<u8>) -> FatResult<()> {
let timestamp = self.get_time().to_rfc3339();
self.savegame.save(config.as_slice(), &timestamp)?;
match self.savegame.load_latest()? {
None => bail!("Config save verification failed: no latest save found"),
Some(data) => {
let _: PlantControllerConfig = serde_json::from_slice(&data)?;
Ok(())
}
}
}
/// Delete a specific save slot by erasing it on flash.
pub(crate) async fn delete_save_slot(&mut self, idx: usize) -> FatResult<()> {
self.savegame.delete_slot(idx)
}
/// Return metadata about all valid save slots.
pub(crate) async fn list_saves(
&mut self,
) -> FatResult<alloc::vec::Vec<crate::hal::savegame_manager::SaveInfo>> {
self.savegame.list_saves()
}
pub(crate) async fn init_rtc_deepsleep_memory(
@@ -674,34 +620,27 @@ impl Esp<'_> {
} else {
RESTART_TO_CONF = 0;
}
LAST_CORROSION_PROTECTION_CHECK_DAY = -1;
};
} else {
unsafe {
if to_config_mode {
RESTART_TO_CONF = 1;
}
LOG_ACCESS
.lock()
.await
.log(
LogMessage::RestartToConfig,
RESTART_TO_CONF as u32,
0,
"",
"",
)
.await;
LOG_ACCESS
.lock()
.await
.log(
LogMessage::LowVoltage,
LOW_VOLTAGE_DETECTED as u32,
0,
"",
"",
)
.await;
log(
LogMessage::RestartToConfig,
RESTART_TO_CONF as u32,
0,
"",
"",
);
log(
LogMessage::LowVoltage,
LOW_VOLTAGE_DETECTED as u32,
0,
"",
"",
);
// is executed before main, no other code will alter these values during printing
#[allow(static_mut_refs)]
for (i, time) in LAST_WATERING_TIMESTAMP.iter().enumerate() {
@@ -749,11 +688,11 @@ impl Esp<'_> {
let mut builder: McutieBuilder<'_, String, PublishDisplay<String, &str>, 0> =
McutieBuilder::new(stack, "plant ctrl", mqtt_url);
if network_config.mqtt_user.is_some() && network_config.mqtt_password.is_some() {
builder = builder.with_authentication(
network_config.mqtt_user.as_ref().unwrap().as_str(),
network_config.mqtt_password.as_ref().unwrap().as_str(),
);
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");
}
@@ -777,52 +716,47 @@ impl Esp<'_> {
receiver,
round_trip_topic.clone(),
stay_alive_topic.clone(),
))?;
spawner.spawn(mqtt_runner(task))?;
)?);
spawner.spawn(mqtt_runner(task)?);
LOG_ACCESS
.lock()
.await
.log(LogMessage::StayAlive, 0, 0, "", &stay_alive_topic)
.await;
log(LogMessage::StayAlive, 0, 0, "", &stay_alive_topic);
LOG_ACCESS
.lock()
.await
.log(LogMessage::MqttInfo, 0, 0, "", mqtt_url)
.await;
log(LogMessage::MqttInfo, 0, 0, "", mqtt_url);
let mqtt_timeout = 15000;
let timeout = {
let guard = TIME_ACCESS.get().await.lock().await;
guard.current_time_us()
} + mqtt_timeout as u64 * 1000;
while !MQTT_CONNECTED_EVENT_RECEIVED.load(Ordering::Relaxed) {
let cur = TIME_ACCESS.get().await.lock().await.current_time_us();
if cur > timeout {
bail!("Timeout waiting MQTT connect event")
let res = async {
while !MQTT_CONNECTED_EVENT_RECEIVED.load(Ordering::Relaxed) {
crate::hal::PlantHal::feed_watchdog();
Timer::after(Duration::from_millis(100)).await;
}
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")
}
Topic::General(round_trip_topic.clone())
let _ = Topic::General(round_trip_topic.clone())
.with_display("online_text")
.publish()
.await
.unwrap();
.await;
let timeout = {
let guard = TIME_ACCESS.get().await.lock().await;
guard.current_time_us()
} + mqtt_timeout as u64 * 1000;
while !MQTT_ROUND_TRIP_RECEIVED.load(Ordering::Relaxed) {
let cur = TIME_ACCESS.get().await.lock().await.current_time_us();
if cur > timeout {
//ensure we do not further try to publish
MQTT_CONNECTED_EVENT_RECEIVED.store(false, Ordering::Relaxed);
bail!("Timeout waiting MQTT roundtrip")
let res = async {
while !MQTT_ROUND_TRIP_RECEIVED.load(Ordering::Relaxed) {
crate::hal::PlantHal::feed_watchdog();
Timer::after(Duration::from_millis(100)).await;
}
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(())
}
@@ -855,6 +789,7 @@ impl Esp<'_> {
Error::TooLarge => false,
Error::PacketError => false,
Error::Invalid => false,
Error::Rejected => false,
};
if !retry {
bail!(
@@ -929,18 +864,10 @@ async fn mqtt_incoming_task(
true => 1,
false => 0,
};
LOG_ACCESS
.lock()
.await
.log(LogMessage::MqttStayAliveRec, a, 0, "", "")
.await;
log(LogMessage::MqttStayAliveRec, a, 0, "", "");
MQTT_STAY_ALIVE.store(value, Ordering::Relaxed);
} else {
LOG_ACCESS
.lock()
.await
.log(LogMessage::UnknownTopic, 0, 0, "", &topic)
.await;
log(LogMessage::UnknownTopic, 0, 0, "", &topic);
}
}
},
@@ -948,21 +875,18 @@ async fn mqtt_incoming_task(
MQTT_CONNECTED_EVENT_RECEIVED.store(false, Ordering::Relaxed);
info!("Mqtt disconnected");
}
MqttMessage::HomeAssistantOnline => {
info!("Home assistant is online");
}
}
}
}
#[embassy_executor::task(pool_size = 2)]
async fn net_task(mut runner: Runner<'static, WifiDevice<'static>>) {
async fn net_task(mut runner: Runner<'static, Interface<'static>>) {
runner.run().await;
}
#[embassy_executor::task]
async fn run_dhcp(stack: Stack<'static>, gw_ip_addr: &'static str) {
use core::net::{Ipv4Addr, SocketAddrV4};
async fn run_dhcp(stack: Stack<'static>, ip: Ipv4Addr) {
use core::net::SocketAddrV4;
use edge_dhcp::{
io::{self, DEFAULT_SERVER_PORT},
@@ -971,21 +895,25 @@ async fn run_dhcp(stack: Stack<'static>, gw_ip_addr: &'static str) {
use edge_nal::UdpBind;
use edge_nal_embassy::{Udp, UdpBuffers};
let ip = Ipv4Addr::from_str(gw_ip_addr).expect("dhcp task failed to parse gw ip");
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 = unbound_socket
let mut bound_socket = match unbound_socket
.bind(SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::UNSPECIFIED,
DEFAULT_SERVER_PORT,
)))
.await
.unwrap();
{
Ok(s) => s,
Err(e) => {
error!("dhcp task failed to bind socket: {:?}", e);
return;
}
};
loop {
_ = io::server::run(

View File

@@ -1,88 +0,0 @@
use crate::hal::shared_flash::MutexFlashStorage;
use embedded_storage::nor_flash::{check_erase, NorFlash, ReadNorFlash};
use esp_bootloader_esp_idf::partitions::FlashRegion;
use littlefs2::consts::U4096 as lfsCache;
use littlefs2::consts::U512 as lfsLookahead;
use littlefs2::driver::Storage as lfs2Storage;
use littlefs2::io::Error as lfs2Error;
use littlefs2::io::Result as lfs2Result;
use log::error;
pub struct LittleFs2Filesystem {
pub(crate) storage: &'static mut FlashRegion<'static, MutexFlashStorage>,
}
impl lfs2Storage for LittleFs2Filesystem {
const READ_SIZE: usize = 4096;
const WRITE_SIZE: usize = 4096;
const BLOCK_SIZE: usize = 4096; //usually optimal for flash access
const BLOCK_COUNT: usize = 8 * 1000 * 1000 / 4096; //8Mb in 4k blocks + a little space for stupid calculation errors
const BLOCK_CYCLES: isize = 100;
type CACHE_SIZE = lfsCache;
type LOOKAHEAD_SIZE = lfsLookahead;
fn read(&mut self, off: usize, buf: &mut [u8]) -> lfs2Result<usize> {
let read_size: usize = Self::READ_SIZE;
if off % read_size != 0 {
error!("Littlefs2Filesystem read error: offset not aligned to read size offset: {off} read_size: {read_size}");
return Err(lfs2Error::IO);
}
if buf.len() % read_size != 0 {
error!("Littlefs2Filesystem read error: length not aligned to read size length: {} read_size: {}", buf.len(), read_size);
return Err(lfs2Error::IO);
}
match self.storage.read(off as u32, buf) {
Ok(..) => Ok(buf.len()),
Err(err) => {
error!("Littlefs2Filesystem read error: {err:?}");
Err(lfs2Error::IO)
}
}
}
fn write(&mut self, off: usize, data: &[u8]) -> lfs2Result<usize> {
let write_size: usize = Self::WRITE_SIZE;
if off % write_size != 0 {
error!("Littlefs2Filesystem write error: offset not aligned to write size offset: {off} write_size: {write_size}");
return Err(lfs2Error::IO);
}
if data.len() % write_size != 0 {
error!("Littlefs2Filesystem write error: length not aligned to write size length: {} write_size: {}", data.len(), write_size);
return Err(lfs2Error::IO);
}
match self.storage.write(off as u32, data) {
Ok(..) => Ok(data.len()),
Err(err) => {
error!("Littlefs2Filesystem write error: {err:?}");
Err(lfs2Error::IO)
}
}
}
fn erase(&mut self, off: usize, len: usize) -> lfs2Result<usize> {
let block_size: usize = Self::BLOCK_SIZE;
if off % block_size != 0 {
error!("Littlefs2Filesystem erase error: offset not aligned to block size offset: {off} block_size: {block_size}");
return Err(lfs2Error::IO);
}
if len % block_size != 0 {
error!("Littlefs2Filesystem erase error: length not aligned to block size length: {len} block_size: {block_size}");
return Err(lfs2Error::IO);
}
match check_erase(self.storage, off as u32, (off + len) as u32) {
Ok(_) => {}
Err(err) => {
error!("Littlefs2Filesystem check erase error: {err:?}");
return Err(lfs2Error::IO);
}
}
match self.storage.erase(off as u32, (off + len) as u32) {
Ok(..) => Ok(len),
Err(err) => {
error!("Littlefs2Filesystem erase error: {err:?}");
Err(lfs2Error::IO)
}
}
}
}

View File

@@ -3,14 +3,14 @@ use lib_bms_protocol::BmsReadable;
pub(crate) mod battery;
// mod can_api; // replaced by external canapi crate
pub mod esp;
mod little_fs2storage_adapter;
pub(crate) mod rtc;
pub(crate) mod savegame_manager;
mod shared_flash;
mod v4_hal;
mod water;
use crate::alloc::string::ToString;
use crate::hal::rtc::{DS3231Module, RTCModuleInteraction};
use crate::hal::rtc::{BackupHeader, DS3231Module, RTCModuleInteraction};
use esp_hal::peripherals::Peripherals;
use esp_hal::peripherals::ADC1;
use esp_hal::peripherals::GPIO0;
@@ -44,14 +44,13 @@ use crate::{
battery::{BatteryInteraction, NoBatteryMonitor},
esp::Esp,
},
log::log,
log::LogMessage,
BOARD_ACCESS,
};
use alloc::boxed::Box;
use alloc::format;
use alloc::sync::Arc;
use async_trait::async_trait;
use bincode::{Decode, Encode};
use canapi::SensorSlot;
use chrono::{DateTime, FixedOffset, Utc};
use core::cell::RefCell;
@@ -75,9 +74,8 @@ use measurements::{Current, Voltage};
use crate::fat_error::{ContextExt, FatError, FatResult};
use crate::hal::battery::WCHI2CSlave;
use crate::hal::little_fs2storage_adapter::LittleFs2Filesystem;
use crate::hal::savegame_manager::SavegameManager;
use crate::hal::water::TankSensor;
use crate::log::LOG_ACCESS;
use embassy_sync::mutex::Mutex;
use embassy_sync::once_lock::OnceLock;
use embedded_storage::nor_flash::RmwNorFlashStorage;
@@ -86,39 +84,40 @@ use esp_alloc as _;
use esp_backtrace as _;
use esp_bootloader_esp_idf::ota::{Ota, OtaImageState};
use esp_hal::delay::Delay;
use esp_hal::i2c::master::{BusTimeout, Config, I2c};
use esp_hal::i2c::master::{BusTimeout, Config, FsmTimeout, I2c};
use esp_hal::interrupt::software::SoftwareInterruptControl;
use esp_hal::pcnt::unit::Unit;
use esp_hal::pcnt::Pcnt;
use esp_hal::rng::Rng;
use esp_hal::rtc_cntl::{Rtc, SocResetReason};
use esp_hal::system::reset_reason;
use esp_hal::time::Rate;
use esp_hal::timer::timg::TimerGroup;
use esp_hal::timer::timg::{MwdtStage, TimerGroup, Wdt};
use esp_hal::uart::Uart;
use esp_hal::Blocking;
use esp_radio::{init, Controller};
use esp_storage::FlashStorage;
use littlefs2::fs::{Allocation, Filesystem as lfs2Filesystem};
use littlefs2::object_safe::DynStorage;
use log::{error, info, warn};
use log::{info, warn};
use portable_atomic::AtomicBool;
use serde::{Deserialize, Serialize};
use shared_flash::MutexFlashStorage;
pub static TIME_ACCESS: OnceLock<Mutex<CriticalSectionRawMutex, Rtc>> = OnceLock::new();
//Only support for 8 right now!
pub const PLANT_COUNT: usize = 8;
pub static PROGRESS_ACTIVE: AtomicBool = AtomicBool::new(false);
pub static WATCHDOG: OnceLock<
embassy_sync::blocking_mutex::Mutex<
CriticalSectionRawMutex,
RefCell<Wdt<esp_hal::peripherals::TIMG0>>,
>,
> = OnceLock::new();
const TANK_MULTI_SAMPLE: usize = 11;
pub static I2C_DRIVER: OnceLock<
embassy_sync::blocking_mutex::Mutex<CriticalSectionRawMutex, RefCell<I2c<Blocking>>>,
> = OnceLock::new();
#[derive(Debug, PartialEq, Clone, Copy, Encode, Decode)]
#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)]
pub enum Sensor {
A,
B,
@@ -147,6 +146,8 @@ pub trait BoardInteraction<'a> {
fn get_config(&mut self) -> &PlantControllerConfig;
fn get_battery_monitor(&mut self) -> &mut Box<dyn BatteryInteraction + Send>;
fn get_rtc_module(&mut self) -> &mut Box<dyn RTCModuleInteraction + Send>;
async fn get_time(&mut self) -> DateTime<Utc>;
async fn set_time(&mut self, time: &DateTime<FixedOffset>) -> FatResult<()>;
async fn set_charge_indicator(&mut self, charging: bool) -> Result<(), FatError>;
async fn deep_sleep(&mut self, duration_in_ms: u64) -> !;
@@ -164,15 +165,32 @@ pub trait BoardInteraction<'a> {
async fn get_mptt_current(&mut self) -> FatResult<Current>;
async fn can_power(&mut self, state: bool) -> FatResult<()>;
async fn backup_config(&mut self, config: &PlantControllerConfig) -> FatResult<()>;
async fn read_backup(&mut self) -> FatResult<PlantControllerConfig>;
async fn backup_info(&mut self) -> FatResult<BackupHeader>;
// Return JSON string with autodetected sensors per plant. Default: not supported.
async fn detect_sensors(&mut self, _request: Detection) -> FatResult<Detection> {
async fn detect_sensors(&mut self, _request: DetectionRequest) -> FatResult<Detection> {
bail!("Autodetection is only available on v4 HAL with CAN bus");
}
/// Return the last known firmware build timestamps per sensor, set during detect_sensors.
fn get_sensor_build_minutes(
&self,
) -> (
[Option<u32>; PLANT_COUNT],
[Option<u32>; PLANT_COUNT],
) {
([None; PLANT_COUNT], [None; PLANT_COUNT])
}
async fn progress(&mut self, counter: u32) {
// Indicate progress is active to suppress default wait_infinity blinking
PROGRESS_ACTIVE.store(true, core::sync::atomic::Ordering::Relaxed);
// Feed watchdog during long-running webserver operations
PlantHal::feed_watchdog();
let current = counter % PLANT_COUNT as u32;
for led in 0..PLANT_COUNT {
if let Err(err) = self.fault(led, current == led as u32).await {
@@ -244,17 +262,23 @@ impl PlantHal {
esp_alloc::heap_allocator!(size: 64 * 1024);
esp_alloc::heap_allocator!(#[link_section = ".dram2_uninit"] size: 64000);
let rtc: Rtc = Rtc::new(peripherals.LPWR);
TIME_ACCESS
.init(Mutex::new(rtc))
.map_err(|_| FatError::String {
error: "Init error rct".to_string(),
})?;
let mut rtc_peripheral: Rtc = Rtc::new(peripherals.LPWR);
rtc_peripheral.rwdt.disable();
let timg0 = TimerGroup::new(peripherals.TIMG0);
let sw_int = SoftwareInterruptControl::new(peripherals.SW_INTERRUPT);
esp_rtos::start(timg0.timer0, sw_int.software_interrupt0);
// Initialize and enable the watchdog with 30 second timeout
let mut wdt = timg0.wdt;
wdt.set_timeout(MwdtStage::Stage0, esp_hal::time::Duration::from_secs(30));
wdt.enable();
WATCHDOG
.init(embassy_sync::blocking_mutex::Mutex::new(RefCell::new(wdt)))
.map_err(|_| FatError::String {
error: "Watchdog already initialized".to_string(),
})?;
let boot_button = Input::new(
peripherals.GPIO9,
InputConfig::default().with_pull(Pull::None),
@@ -264,14 +288,11 @@ impl PlantHal {
let wake_gpio1 = peripherals.GPIO1;
let rng = Rng::new();
let esp_wifi_ctrl = &*mk_static!(
Controller<'static>,
init().expect("Could not init wifi controller")
);
let (controller, interfaces) =
esp_radio::wifi::new(esp_wifi_ctrl, peripherals.WIFI, Default::default())
.expect("Could not init wifi");
let (controller, interfaces) = esp_radio::wifi::new(peripherals.WIFI, Default::default())
.map_err(|e| FatError::String {
error: format!("Could not init wifi: {:?}", e),
})?;
let pcnt_module = Pcnt::new(peripherals.PCNT);
@@ -325,19 +346,16 @@ impl PlantHal {
pt.find_partition(esp_bootloader_esp_idf::partitions::PartitionType::Data(
DataPartitionSubType::Ota,
))?
.expect("No OTA data partition found")
.context("No OTA data partition found")?
);
let ota_data = mk_static!(
FlashRegion<RmwNorFlashStorage<&mut MutexFlashStorage>>,
ota_data.as_embedded_storage(mk_static!(
RmwNorFlashStorage<&mut MutexFlashStorage>,
RmwNorFlashStorage::new(flash_storage_2, mk_static!([u8; 4096], [0_u8; 4096]))
))
);
let mut ota_data = ota_data.as_embedded_storage(mk_static!(
RmwNorFlashStorage<&mut MutexFlashStorage>,
RmwNorFlashStorage::new(flash_storage_2, mk_static!([u8; 4096], [0_u8; 4096]))
));
let state_0 = ota_state(AppPartitionSubType::Ota0, ota_data);
let state_1 = ota_state(AppPartitionSubType::Ota1, ota_data);
let state_0 = ota_state(AppPartitionSubType::Ota0, &mut ota_data);
let state_1 = ota_state(AppPartitionSubType::Ota1, &mut ota_data);
let mut ota = Ota::new(ota_data, 2)?;
let running = get_current_slot(&pt, &mut ota)?;
let target = next_partition(running)?;
@@ -370,42 +388,30 @@ impl PlantHal {
.find_partition(esp_bootloader_esp_idf::partitions::PartitionType::Data(
DataPartitionSubType::LittleFs,
))?
.expect("Data partition with littlefs not found");
.context("Storage data partition not found")?;
let data_partition = mk_static!(PartitionEntry, data_partition);
let data = mk_static!(
FlashRegion<MutexFlashStorage>,
FlashRegion<'static, MutexFlashStorage>,
data_partition.as_embedded_storage(flash_storage_3)
);
let lfs2filesystem = mk_static!(LittleFs2Filesystem, LittleFs2Filesystem { storage: data });
let alloc = mk_static!(Allocation<LittleFs2Filesystem>, lfs2Filesystem::allocate());
if lfs2filesystem.is_mountable() {
info!("Littlefs2 filesystem is mountable");
} else {
match lfs2filesystem.format() {
Ok(..) => {
info!("Littlefs2 filesystem is formatted");
}
Err(err) => {
error!("Littlefs2 filesystem could not be formatted: {err:?}");
}
}
}
#[allow(clippy::arc_with_non_send_sync)]
let fs = Arc::new(Mutex::new(
lfs2Filesystem::mount(alloc, lfs2filesystem).expect("Could not mount lfs2 filesystem"),
));
let savegame = SavegameManager::new(data);
info!(
"Savegame storage initialized ({} slots × {} KB)",
savegame_manager::SAVEGAME_SLOT_COUNT,
savegame_manager::SAVEGAME_SLOT_SIZE / 1024
);
let uart0 =
Uart::new(peripherals.UART0, UartConfig::default()).map_err(|_| FatError::String {
error: "Uart creation failed".to_string(),
})?;
let ap = interfaces.ap;
let sta = interfaces.sta;
let ap = interfaces.access_point;
let sta = interfaces.station;
let mut esp = Esp {
fs,
savegame,
rng,
controller: Arc::new(Mutex::new(controller)),
interface_sta: Some(sta),
@@ -418,6 +424,7 @@ impl PlantHal {
slot0_state: state_0,
slot1_state: state_1,
uart0,
rtc: rtc_peripheral,
};
//init,reset rtc memory depending on cause
@@ -453,17 +460,13 @@ impl PlantHal {
SocResetReason::Cpu0JtagCpu => "cpu0 jtag cpu",
},
};
LOG_ACCESS
.lock()
.await
.log(
LogMessage::ResetReason,
init_rtc_store as u32,
to_config_mode as u32,
"",
&format!("{reasons:?}"),
)
.await;
log(
LogMessage::ResetReason,
init_rtc_store as u32,
to_config_mode as u32,
"",
&format!("{reasons:?}"),
);
esp.init_rtc_deepsleep_memory(init_rtc_store, to_config_mode)
.await;
@@ -475,11 +478,16 @@ impl PlantHal {
let sda = peripherals.GPIO20;
let scl = peripherals.GPIO19;
// Configure I2C with 1-second timeout
// At 100 Hz I2C clock, one bus cycle = 10ms
// For 1 second timeout: 100 bus cycles
let i2c = I2c::new(
peripherals.I2C0,
Config::default()
.with_frequency(Rate::from_hz(100))
.with_timeout(BusTimeout::Maximum),
//.with_frequency(Rate::from_hz(100))
//1s at 100khz
.with_timeout(BusTimeout::BusCycles(100_000))
.with_scl_main_st_timeout(FsmTimeout::new(21)?),
)?
.with_scl(scl)
.with_sda(sda);
@@ -488,7 +496,9 @@ impl PlantHal {
RefCell<I2c<Blocking>>,
> = CriticalSectionMutex::new(RefCell::new(i2c));
I2C_DRIVER.init(i2c_bus).expect("Could not init i2c driver");
I2C_DRIVER.init(i2c_bus).map_err(|_| FatError::String {
error: "Could not init i2c driver".to_string(),
})?;
let i2c_bus = I2C_DRIVER.get().await;
let rtc_device = I2cDevice::new(i2c_bus);
@@ -555,17 +565,13 @@ impl PlantHal {
HAL { board_hal }
}
Err(err) => {
LOG_ACCESS
.lock()
.await
.log(
LogMessage::ConfigModeMissingConfig,
0,
0,
"",
&err.to_string(),
)
.await;
log(
LogMessage::ConfigModeMissingConfig,
0,
0,
"",
&err.to_string(),
);
HAL {
board_hal: v4_hal::create_v4(
free_pins,
@@ -581,6 +587,15 @@ impl PlantHal {
Ok(Mutex::new(hal))
}
/// Feed the watchdog timer to prevent system reset
pub fn feed_watchdog() {
if let Some(wdt_mutex) = WATCHDOG.try_get() {
wdt_mutex.lock(|cell| {
cell.borrow_mut().feed();
});
}
}
}
fn ota_state(
@@ -648,39 +663,38 @@ pub fn next_partition(current: AppPartitionSubType) -> FatResult<AppPartitionSub
Ok(next)
}
pub async fn esp_time() -> DateTime<Utc> {
let guard = TIME_ACCESS.get().await.lock().await;
DateTime::from_timestamp_micros(guard.current_time_us() as i64).unwrap()
}
pub async fn esp_set_time(time: DateTime<FixedOffset>) -> FatResult<()> {
{
let guard = TIME_ACCESS.get().await.lock().await;
guard.set_current_time_us(time.timestamp_micros() as u64);
}
BOARD_ACCESS
.get()
.await
.lock()
.await
.board_hal
.get_rtc_module()
.set_rtc_time(&time.to_utc())
.await
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize)]
pub struct Moistures {
pub sensor_a_hz: [Option<f32>; PLANT_COUNT],
pub sensor_b_hz: [Option<f32>; PLANT_COUNT],
pub sensor_a_build_minutes: [Option<u32>; PLANT_COUNT],
pub sensor_b_build_minutes: [Option<u32>; PLANT_COUNT],
}
/// Request: which sensors to send IDENTIFY_CMD to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct DetectionRequest {
pub plant: [SensorRequest; PLANT_COUNT],
}
/// Per-sensor portion of a detection request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct SensorRequest {
pub sensor_a: bool,
pub sensor_b: bool,
}
/// Response: detection result per plant.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Detection {
plant: [DetectionSensorResult; PLANT_COUNT],
pub plant: [DetectionSensorResult; PLANT_COUNT],
}
/// Per-sensor detection result.
/// `Some(build_minutes)` = sensor responded; value is its firmware build timestamp
/// (minutes since Unix epoch, or 0 if not reported). `None` = not detected.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct DetectionSensorResult {
sensor_a: bool,
sensor_b: bool,
pub sensor_a: Option<u32>,
pub sensor_b: Option<u32>,
}

View File

@@ -1,8 +1,6 @@
use crate::fat_error::FatResult;
use crate::hal::Box;
use async_trait::async_trait;
use bincode::config::Configuration;
use bincode::{config, Decode, Encode};
use chrono::{DateTime, Utc};
use ds323x::ic::DS3231;
use ds323x::interface::I2cInterface;
@@ -19,24 +17,21 @@ use esp_hal::Blocking;
use serde::{Deserialize, Serialize};
pub const X25: crc::Crc<u16> = crc::Crc::<u16>::new(&crc::CRC_16_IBM_SDLC);
const CONFIG: Configuration = config::standard();
pub const EEPROM_PAGE: usize = 32;
//
#[async_trait(?Send)]
pub trait RTCModuleInteraction {
async fn get_backup_info(&mut self) -> FatResult<BackupHeader>;
async fn get_backup_config(&mut self, chunk: usize) -> FatResult<([u8; 32], usize, u16)>;
async fn backup_config(&mut self, offset: usize, bytes: &[u8]) -> FatResult<()>;
async fn backup_config_finalize(&mut self, crc: u16, length: usize) -> FatResult<()>;
async fn get_rtc_time(&mut self) -> FatResult<DateTime<Utc>>;
async fn set_rtc_time(&mut self, time: &DateTime<Utc>) -> FatResult<()>;
}
//
const BACKUP_HEADER_MAX_SIZE: usize = 64;
#[derive(Serialize, Deserialize, PartialEq, Debug, Default, Encode, Decode)]
fn write(&mut self, offset: u32, data: &[u8]) -> FatResult<()>;
fn read(&mut self, offset: u32, data: &mut [u8]) -> FatResult<()>;
}
#[derive(Serialize, Deserialize, PartialEq, Debug, Default)]
pub struct BackupHeader {
pub timestamp: i64,
crc16: u16,
pub(crate) crc16: u16,
pub size: u16,
}
//
@@ -46,7 +41,7 @@ pub struct DS3231Module {
DS3231,
>,
pub(crate) storage: eeprom24x::Storage<
pub storage: eeprom24x::Storage<
I2cDevice<'static, CriticalSectionRawMutex, I2c<'static, Blocking>>,
B32,
TwoBytes,
@@ -57,67 +52,6 @@ pub struct DS3231Module {
#[async_trait(?Send)]
impl RTCModuleInteraction for DS3231Module {
async fn get_backup_info(&mut self) -> FatResult<BackupHeader> {
let mut header_page_buffer = [0_u8; BACKUP_HEADER_MAX_SIZE];
self.storage.read(0, &mut header_page_buffer)?;
let (header, len): (BackupHeader, usize) =
bincode::decode_from_slice(&header_page_buffer[..], CONFIG)?;
log::info!("Raw header is {header_page_buffer:?} with size {len}");
Ok(header)
}
async fn get_backup_config(&mut self, chunk: usize) -> FatResult<([u8; 32], usize, u16)> {
let mut header_page_buffer = [0_u8; BACKUP_HEADER_MAX_SIZE];
self.storage.read(0, &mut header_page_buffer)?;
let (header, _header_size): (BackupHeader, usize) =
bincode::decode_from_slice(&header_page_buffer[..], CONFIG)?;
let mut buf = [0_u8; 32];
let offset = chunk * buf.len() + BACKUP_HEADER_MAX_SIZE;
let end: usize = header.size as usize + BACKUP_HEADER_MAX_SIZE;
let current_end = offset + buf.len();
let chunk_size = if current_end > end {
end - offset
} else {
buf.len()
};
if chunk_size == 0 {
Ok((buf, 0, header.crc16))
} else {
self.storage.read(offset as u32, &mut buf)?;
//&buf[..chunk_size];
Ok((buf, chunk_size, header.crc16))
}
}
async fn backup_config(&mut self, offset: usize, bytes: &[u8]) -> FatResult<()> {
//skip header and write after
self.storage
.write((BACKUP_HEADER_MAX_SIZE + offset) as u32, bytes)?;
Ok(())
}
async fn backup_config_finalize(&mut self, crc: u16, length: usize) -> FatResult<()> {
let mut header_page_buffer = [0_u8; BACKUP_HEADER_MAX_SIZE];
let time = self.get_rtc_time().await?.timestamp_millis();
let header = BackupHeader {
crc16: crc,
timestamp: time,
size: length as u16,
};
let config = config::standard();
let encoded = bincode::encode_into_slice(&header, &mut header_page_buffer, config)?;
log::info!("Raw header is {header_page_buffer:?} with size {encoded}");
self.storage.write(0, &header_page_buffer)?;
Ok(())
}
async fn get_rtc_time(&mut self) -> FatResult<DateTime<Utc>> {
Ok(self.rtc.datetime()?.and_utc())
}
@@ -126,4 +60,14 @@ impl RTCModuleInteraction for DS3231Module {
let naive_time = time.naive_utc();
Ok(self.rtc.set_datetime(&naive_time)?)
}
fn write(&mut self, offset: u32, data: &[u8]) -> FatResult<()> {
self.storage.write(offset, data)?;
Ok(())
}
fn read(&mut self, offset: u32, data: &mut [u8]) -> FatResult<()> {
self.storage.read(offset, data)?;
Ok(())
}
}

View File

@@ -0,0 +1,265 @@
use alloc::string::ToString;
use alloc::vec::Vec;
use embedded_savegame::storage::{Flash, Storage};
use embedded_storage::nor_flash::{NorFlash, ReadNorFlash};
use esp_bootloader_esp_idf::partitions::{Error as PartitionError, FlashRegion};
use log::{error, info};
use serde::Serialize;
use crate::fat_error::{FatError, FatResult};
use crate::hal::shared_flash::MutexFlashStorage;
/// Size of each save slot in bytes (16 KB).
pub const SAVEGAME_SLOT_SIZE: usize = 16384;
//keep a little of space at the end due to partition table offsets
const SAFETY: usize = 5;
/// Number of slots in the 8 MB storage partition.
pub const SAVEGAME_SLOT_COUNT: usize = (8 * 1024 * 1024) / SAVEGAME_SLOT_SIZE - SAFETY; // 507
/// Metadata about a single existing save slot, returned by [`SavegameManager::list_saves`].
#[derive(Serialize, Debug, Clone)]
pub struct SaveInfo {
pub idx: usize,
pub len: u32,
/// UTC timestamp in RFC3339 format when the save was created
pub created_at: Option<alloc::string::String>,
}
const SAVE_MAGIC: [u8; 4] = *b"SGM1";
const SAVE_HEADER_LEN: usize = 6; // magic (4) + timestamp_len (u16)
struct ParsedSave<'a> {
created_at: &'a str,
data: &'a [u8],
}
fn strip_padding(data: &[u8]) -> &[u8] {
let mut end = data.len();
while end > 0 {
let b = data[end - 1];
if b == 0x00 || b == 0xFF {
end -= 1;
} else {
break;
}
}
&data[..end]
}
fn parse_save(data: &[u8]) -> FatResult<ParsedSave<'_>> {
if data.len() < SAVE_HEADER_LEN {
return Err(FatError::String {
error: "Save payload too short".into(),
});
}
if data[..4] != SAVE_MAGIC {
return Err(FatError::String {
error: "Save payload has invalid magic".into(),
});
}
let timestamp_len = u16::from_le_bytes([data[4], data[5]]) as usize;
let timestamp_end = SAVE_HEADER_LEN + timestamp_len;
if timestamp_end > data.len() {
return Err(FatError::String {
error: "Save payload timestamp length exceeds data".into(),
});
}
let created_at = core::str::from_utf8(&data[SAVE_HEADER_LEN..timestamp_end]).map_err(|e| {
FatError::String {
error: alloc::format!("Save payload contains invalid timestamp UTF-8: {e:?}"),
}
})?;
Ok(ParsedSave {
created_at,
data: strip_padding(&data[timestamp_end..]),
})
}
// ── Flash adapter ──────────────────────────────────────────────────────────────
/// Newtype wrapper around a [`PartitionError`] so we can implement the
/// [`core::fmt::Debug`] bound required by [`embedded_savegame::storage::Flash`].
#[derive(Debug)]
pub struct SavegameFlashError(#[allow(dead_code)] PartitionError);
/// Adapts a `&mut FlashRegion<'static, MutexFlashStorage>` to the
/// [`embedded_savegame::storage::Flash`] trait.
///
/// `erase(addr)` erases exactly one slot (`SAVEGAME_SLOT_SIZE` bytes) starting
/// at `addr`, which is what embedded-savegame expects for NOR flash.
pub struct SavegameFlashAdapter<'a> {
region: &'a mut FlashRegion<'static, MutexFlashStorage>,
}
impl Flash for SavegameFlashAdapter<'_> {
type Error = SavegameFlashError;
fn read(&mut self, addr: u32, buf: &mut [u8]) -> Result<(), Self::Error> {
ReadNorFlash::read(self.region, addr, buf).map_err(SavegameFlashError)
}
fn write(&mut self, addr: u32, data: &mut [u8]) -> Result<(), Self::Error> {
info!(
"Relative writing to flash at 0x{:x} with length {}",
addr,
data.len()
);
let error = NorFlash::write(self.region, addr, data);
if let Err(err) = error {
error!("error {:?}", err);
}
error.map_err(SavegameFlashError)
}
/// Erase one full slot at `addr`.
/// embedded-savegame calls this before writing to a slot, so we erase
/// the entire `SAVEGAME_SLOT_SIZE` bytes so subsequent writes land on
/// pre-erased (0xFF) pages.
/// Ensures addresses are aligned to ERASE_SIZE (4KB) boundaries.
fn erase(&mut self, addr: u32) -> Result<(), Self::Error> {
const ERASE_SIZE: u32 = 4096;
// Align start address down to erase boundary
let aligned_start = (addr / ERASE_SIZE) * ERASE_SIZE;
// Align end address up to erase boundary
let end = addr + SAVEGAME_SLOT_SIZE as u32;
let aligned_end = end.div_ceil(ERASE_SIZE) * ERASE_SIZE;
info!(
"Relative erasing flash at 0x{:x} (aligned to 0x{:x}-0x{:x})",
addr, aligned_start, aligned_end
);
if aligned_start != addr || aligned_end != end {
log::warn!("Flash erase address not aligned: addr=0x{:x}, slot_size=0x{:x}. Aligned to 0x{:x}-0x{:x}", addr, SAVEGAME_SLOT_SIZE, aligned_start, aligned_end);
}
match NorFlash::erase(self.region, aligned_start, aligned_end) {
Ok(_) => Ok(()),
Err(err) => {
error!(
"Flash erase failed: {:?}. 0x{:x}-0x{:x}",
err, aligned_start, aligned_end
);
Err(SavegameFlashError(err))
}
}
}
}
impl From<SavegameFlashError> for FatError {
fn from(e: SavegameFlashError) -> Self {
FatError::String {
error: alloc::format!("Savegame flash error: {:?}", e),
}
}
}
// ── SavegameManager ────────────────────────────────────────────────────────────
/// High-level save-game manager that stores JSON config blobs on the storage
/// partition using [`embedded_savegame`] for wear leveling and power-fail safety.
pub struct SavegameManager {
storage: Storage<SavegameFlashAdapter<'static>, SAVEGAME_SLOT_SIZE, SAVEGAME_SLOT_COUNT>,
}
impl SavegameManager {
pub fn new(region: &'static mut FlashRegion<'static, MutexFlashStorage>) -> Self {
Self {
storage: Storage::new(SavegameFlashAdapter { region }),
}
}
/// Persist `data` (JSON bytes) to the next available slot with a UTC timestamp.
///
/// `scan()` advances the internal wear-leveling pointer to the latest valid
/// slot before `append()` writes to the next free one.
/// Both operations are performed atomically on the same Storage instance.
pub fn save(&mut self, data: &[u8], timestamp: &str) -> FatResult<()> {
let timestamp_bytes = timestamp.as_bytes();
let timestamp_len: u16 =
timestamp_bytes
.len()
.try_into()
.map_err(|_| FatError::String {
error: "Timestamp too long for save header".into(),
})?;
let mut serialized =
Vec::with_capacity(SAVE_HEADER_LEN + timestamp_bytes.len() + data.len());
serialized.extend_from_slice(&SAVE_MAGIC);
serialized.extend_from_slice(&timestamp_len.to_le_bytes());
serialized.extend_from_slice(timestamp_bytes);
serialized.extend_from_slice(data);
// Flash storage often requires length to be a multiple of 4.
let padding = (4 - (serialized.len() % 4)) % 4;
if padding > 0 {
serialized.extend_from_slice(&[0u8; 4][..padding]);
}
info!("Serialized config with size {} (padded)", serialized.len());
self.storage.append(&mut serialized)?;
Ok(())
}
/// Load the most recently saved data. Returns `None` if no valid save exists.
/// Unwraps the SaveWrapper and returns only the config data.
pub fn load_latest(&mut self) -> FatResult<Option<Vec<u8>>> {
let slot = self.storage.scan()?;
match slot {
None => Ok(None),
Some(slot) => self.load_slot(slot.idx),
}
}
/// Load a specific save by slot index. Returns `None` if the slot is
/// empty or contains an invalid checksum.
/// Unwraps the SaveWrapper and returns only the config data.
pub fn load_slot(&mut self, idx: usize) -> FatResult<Option<Vec<u8>>> {
let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE];
match self.storage.read(idx, &mut buf)? {
None => Ok(None),
Some(data) => {
let parsed = parse_save(data)?;
Ok(Some(parsed.data.to_vec()))
}
}
}
/// Erase a specific slot by index, effectively deleting it.
pub fn delete_slot(&mut self, idx: usize) -> FatResult<()> {
self.storage.erase(idx).map_err(Into::into)
}
/// Iterate all slots and return metadata for every slot that contains a
/// valid save, using the Storage read API to avoid assuming internal slot structure.
/// Extracts timestamps from SaveWrapper if available.
pub fn list_saves(&mut self) -> FatResult<Vec<SaveInfo>> {
let mut saves = Vec::new();
let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE];
for idx in 0..SAVEGAME_SLOT_COUNT {
if let Some(data) = self.storage.read(idx, &mut buf)? {
match parse_save(data) {
Ok(save) => {
saves.push(SaveInfo {
idx,
len: save.data.len() as u32,
created_at: Some(alloc::string::String::from(save.created_at)),
});
}
Err(err) => {
saves.push(SaveInfo {
idx,
len: 0,
created_at: Some(err.to_string()),
});
}
}
}
}
Ok(saves)
}
}

View File

@@ -3,18 +3,20 @@ use crate::config::PlantControllerConfig;
use crate::fat_error::{ContextExt, FatError, FatResult};
use crate::hal::battery::BatteryInteraction;
use crate::hal::esp::{hold_disable, hold_enable, Esp};
use crate::hal::rtc::RTCModuleInteraction;
use crate::hal::rtc::{BackupHeader, RTCModuleInteraction, EEPROM_PAGE, X25};
use crate::hal::water::TankSensor;
use crate::hal::{
BoardInteraction, Detection, FreePeripherals, Moistures, Sensor, I2C_DRIVER, PLANT_COUNT,
TIME_ACCESS,
BoardInteraction, Detection, DetectionRequest, FreePeripherals, Moistures, Sensor, I2C_DRIVER,
PLANT_COUNT,
};
use crate::log::{LogMessage, LOG_ACCESS};
use crate::log::{log, LogMessage};
use alloc::boxed::Box;
use alloc::string::ToString;
use async_trait::async_trait;
use canapi::id::{classify, plant_id, MessageKind, IDENTIFY_CMD_OFFSET};
use canapi::SensorSlot;
use chrono::{DateTime, FixedOffset, Utc};
use core::cmp::min;
use embassy_embedded_hal::shared_bus::blocking::i2c::I2cDevice;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_time::{Duration, Timer, WithTimeout};
@@ -30,8 +32,11 @@ use ina219::SyncIna219;
use log::{error, info, warn};
use measurements::Resistance;
use measurements::{Current, Voltage};
// use no_panic::no_panic;
use pca9535::{GPIOBank, Pca9535Immediate, StandardExpanderInterface};
pub const BACKUP_HEADER_MAX_SIZE: usize = 64;
const MPPT_CURRENT_SHUNT_OHMS: f64 = 0.05_f64;
const TWAI_BAUDRATE: twai::BaudRate = twai::BaudRate::Custom(twai::TimingConfig {
baud_rate_prescaler: 200, // 40MHz / 200 * 2 = 100 on C6, 100 * 20 = 2000 divisor, 40MHz / 2000 = 20kHz
@@ -139,6 +144,11 @@ pub struct V4<'a> {
extra1: Output<'a>,
extra2: Output<'a>,
twai_config: Option<TwaiConfiguration<'static, Blocking>>,
/// Last known firmware build timestamps per sensor (minutes since Unix epoch).
/// Updated during detect_sensors; preserved across normal measurement cycles.
sensor_a_build_minutes: [Option<u32>; PLANT_COUNT],
sensor_b_build_minutes: [Option<u32>; PLANT_COUNT],
}
pub(crate) async fn create_v4(
@@ -268,6 +278,8 @@ pub(crate) async fn create_v4(
extra2,
can_power,
twai_config,
sensor_a_build_minutes: [None; PLANT_COUNT],
sensor_b_build_minutes: [None; PLANT_COUNT],
};
Ok(Box::new(v))
}
@@ -294,6 +306,16 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
&mut self.rtc_module
}
async fn get_time(&mut self) -> DateTime<Utc> {
self.esp.get_time()
}
async fn set_time(&mut self, time: &DateTime<FixedOffset>) -> FatResult<()> {
self.rtc_module.set_rtc_time(&time.to_utc()).await?;
self.esp.set_time(time.to_utc());
Ok(())
}
async fn set_charge_indicator(&mut self, charging: bool) -> Result<(), FatError> {
self.charger.set_charge_indicator(charging)
}
@@ -301,8 +323,7 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
async fn deep_sleep(&mut self, duration_in_ms: u64) -> ! {
self.awake.set_low();
self.charger.power_save();
let rtc = TIME_ACCESS.get().await.lock().await;
self.esp.deep_sleep(duration_in_ms, rtc);
self.esp.deep_sleep(duration_in_ms);
}
fn is_day(&self) -> bool {
@@ -334,19 +355,11 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
bail!("pump current sensor not available");
}
Some(pump_ina) => {
let v = pump_ina
.shunt_voltage()
.map_err(|e| FatError::String {
error: alloc::format!("{e:?}"),
})
.map(|v| {
let shunt_voltage =
Voltage::from_microvolts(v.shunt_voltage_uv().abs() as f64);
let shut_value = Resistance::from_ohms(0.05_f64);
let current = shunt_voltage.as_volts() / shut_value.as_ohms();
Current::from_amperes(current)
})?;
Ok(v)
let raw = pump_ina.shunt_voltage()?;
let shunt_voltage = Voltage::from_microvolts(raw.shunt_voltage_uv().abs() as f64);
let shut_value = Resistance::from_ohms(0.05_f64);
let current = shunt_voltage.as_volts() / shut_value.as_ohms();
Ok(Current::from_amperes(current))
}
}
}
@@ -364,7 +377,7 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
async fn measure_moisture_hz(&mut self) -> FatResult<Moistures> {
self.can_power.set_high();
Timer::after_millis(500).await;
let config = self.twai_config.take().expect("twai config not set");
let config = self.twai_config.take().context("twai config not set")?;
let mut twai = config.into_async().start();
if twai.is_bus_off() {
@@ -388,13 +401,166 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
self.twai_config.replace(config);
self.can_power.set_low();
// Persist any firmware build timestamps received alongside moisture data.
if let Ok(ref moistures) = res {
for (i, v) in moistures.sensor_a_build_minutes.iter().enumerate() {
if v.is_some() {
self.sensor_a_build_minutes[i] = *v;
}
}
for (i, v) in moistures.sensor_b_build_minutes.iter().enumerate() {
if v.is_some() {
self.sensor_b_build_minutes[i] = *v;
}
}
}
res
}
async fn detect_sensors(&mut self, request: Detection) -> FatResult<Detection> {
async fn general_fault(&mut self, enable: bool) {
hold_disable(23);
self.general_fault.set_level(enable.into());
hold_enable(23);
}
async fn test(&mut self) -> Result<(), FatError> {
self.general_fault(true).await;
Timer::after_millis(100).await;
self.general_fault(false).await;
Timer::after_millis(500).await;
self.extra1.set_high();
Timer::after_millis(500).await;
self.extra1.set_low();
Timer::after_millis(500).await;
self.extra2.set_high();
Timer::after_millis(500).await;
self.extra2.set_low();
Timer::after_millis(500).await;
self.light(true).await?;
Timer::after_millis(500).await;
self.light(false).await?;
Timer::after_millis(500).await;
for i in 0..PLANT_COUNT {
self.fault(i, true).await?;
Timer::after_millis(500).await;
self.fault(i, false).await?;
Timer::after_millis(500).await;
}
for i in 0..PLANT_COUNT {
self.pump(i, true).await?;
Timer::after_millis(100).await;
self.pump(i, false).await?;
Timer::after_millis(100).await;
}
let moisture = self.measure_moisture_hz().await?;
for plant in 0..PLANT_COUNT {
let a = moisture.sensor_a_hz[plant].unwrap_or(0.0) as u32;
let b = moisture.sensor_b_hz[plant].unwrap_or(0.0) as u32;
log(LogMessage::TestSensor, a, b, &(plant + 1).to_string(), "");
}
Timer::after_millis(10).await;
Ok(())
}
fn set_config(&mut self, config: PlantControllerConfig) {
self.config = config;
}
async fn get_mptt_voltage(&mut self) -> FatResult<Voltage> {
self.charger.get_mptt_voltage()
}
async fn get_mptt_current(&mut self) -> FatResult<Current> {
self.charger.get_mppt_current()
}
async fn can_power(&mut self, state: bool) -> FatResult<()> {
if state && self.can_power.is_set_low() {
self.can_power.set_high();
} else {
self.can_power.set_low();
}
Ok(())
}
async fn backup_config(&mut self, controller_config: &PlantControllerConfig) -> FatResult<()> {
let mut buffer: [u8; 4096 - BACKUP_HEADER_MAX_SIZE] = [0; 4096 - BACKUP_HEADER_MAX_SIZE];
let length = postcard::to_slice(controller_config, &mut buffer)?.len();
info!("Writing backup config of size {}", length);
let mut checksum = X25.digest();
checksum.update(&buffer[..length]);
let mut header_page_buffer = [0_u8; BACKUP_HEADER_MAX_SIZE];
let time = self.rtc_module.get_rtc_time().await?.timestamp_millis();
let header = BackupHeader {
crc16: checksum.finalize(),
timestamp: time,
size: length as u16,
};
info!("Header is {:?}", header);
postcard::to_slice(&header, &mut header_page_buffer)?;
info!("Header is serialized");
self.get_rtc_module().write(0, &header_page_buffer)?;
info!("Header written");
let mut to_write = length;
let mut chunk: usize = 0;
while to_write > 0 {
self.progress(chunk as u32).await;
let start = chunk * EEPROM_PAGE;
let end = start + min(EEPROM_PAGE, to_write);
let part = &buffer[start..end];
info!(
"Writing chunk {} of size {} to offset {}",
chunk,
part.len(),
start
);
to_write -= part.len();
self.get_rtc_module()
.write((BACKUP_HEADER_MAX_SIZE + chunk * EEPROM_PAGE) as u32, part)?;
chunk += 1;
}
info!("Backup complete");
self.clear_progress().await;
Ok(())
}
async fn read_backup(&mut self) -> FatResult<PlantControllerConfig> {
let info = self.backup_info().await?;
let mut store = alloc::vec![0_u8; info.size as usize];
self.rtc_module
.read(BACKUP_HEADER_MAX_SIZE as u32, store.as_mut_slice())?;
info!("Read backup data of size {}", store.len());
let mut checksum = X25.digest();
info!("Calculating CRC");
checksum.update(&store[..]);
let crc = checksum.finalize();
info!("CRC is {:04x}", crc);
if crc != info.crc16 {
warn!("CRC mismatch in backup data");
bail!("CRC mismatch in backup data")
}
info!("CRC is correct");
let decoded = postcard::from_bytes(&store[..])?;
info!("Backup data decoded");
Ok(decoded)
}
async fn backup_info(&mut self) -> FatResult<BackupHeader> {
let mut header_page_buffer = [0_u8; BACKUP_HEADER_MAX_SIZE];
self.get_rtc_module().read(0, &mut header_page_buffer)?;
info!("Read header page");
let info = postcard::take_from_bytes::<BackupHeader>(&header_page_buffer[..]);
info!("decoding header: {:?}", info);
let (header, _) = info.context("Could not read backup header")?;
Ok(header)
}
async fn detect_sensors(&mut self, request: DetectionRequest) -> FatResult<Detection> {
self.can_power.set_high();
Timer::after_millis(500).await;
let config = self.twai_config.take().expect("twai config not set");
let config = self.twai_config.take().context("twai config not set")?;
let mut twai = config.into_async().start();
if twai.is_bus_off() {
@@ -414,6 +580,7 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
} else {
request.plant[plant].sensor_b
};
if !detect {
continue;
}
@@ -458,7 +625,7 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
let result: Detection = moistures.into();
info!("Autodetection result: {result:?}");
Ok(result)
Ok((result, moistures.sensor_a_build_minutes, moistures.sensor_b_build_minutes))
})
.await;
@@ -466,77 +633,20 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
self.twai_config.replace(config);
self.can_power.set_low();
res
}
async fn general_fault(&mut self, enable: bool) {
hold_disable(23);
self.general_fault.set_level(enable.into());
hold_enable(23);
}
async fn test(&mut self) -> Result<(), FatError> {
self.general_fault(true).await;
Timer::after_millis(100).await;
self.general_fault(false).await;
Timer::after_millis(500).await;
self.extra1.set_high();
Timer::after_millis(500).await;
self.extra1.set_low();
Timer::after_millis(500).await;
self.extra2.set_high();
Timer::after_millis(500).await;
self.extra2.set_low();
Timer::after_millis(500).await;
self.light(true).await?;
Timer::after_millis(500).await;
self.light(false).await?;
Timer::after_millis(500).await;
for i in 0..PLANT_COUNT {
self.fault(i, true).await?;
Timer::after_millis(500).await;
self.fault(i, false).await?;
Timer::after_millis(500).await;
match res {
Ok((detection, a_builds, b_builds)) => {
self.sensor_a_build_minutes = a_builds;
self.sensor_b_build_minutes = b_builds;
Ok(detection)
}
Err(e) => Err(e),
}
for i in 0..PLANT_COUNT {
self.pump(i, true).await?;
Timer::after_millis(100).await;
self.pump(i, false).await?;
Timer::after_millis(100).await;
}
let moisture = self.measure_moisture_hz().await?;
for plant in 0..PLANT_COUNT {
let a = moisture.sensor_a_hz[plant].unwrap_or(0.0) as u32;
let b = moisture.sensor_b_hz[plant].unwrap_or(0.0) as u32;
LOG_ACCESS
.lock()
.await
.log(LogMessage::TestSensor, a, b, &(plant + 1).to_string(), "")
.await;
}
Timer::after_millis(10).await;
Ok(())
}
fn set_config(&mut self, config: PlantControllerConfig) {
self.config = config;
}
async fn get_mptt_voltage(&mut self) -> FatResult<Voltage> {
self.charger.get_mptt_voltage()
}
async fn get_mptt_current(&mut self) -> FatResult<Current> {
self.charger.get_mppt_current()
}
async fn can_power(&mut self, state: bool) -> FatResult<()> {
if state && self.can_power.is_set_low() {
self.can_power.set_high();
} else {
self.can_power.set_low();
}
Ok(())
fn get_sensor_build_minutes(
&self,
) -> ([Option<u32>; PLANT_COUNT], [Option<u32>; PLANT_COUNT]) {
(self.sensor_a_build_minutes, self.sensor_b_build_minutes)
}
}
@@ -557,10 +667,10 @@ async fn wait_for_can_measurements(
"received message of kind {:?} (plant: {}, sensor: {:?})",
msg.0, msg.1, msg.2
);
let plant = msg.1 as usize;
let sensor = msg.2;
let data = can_frame.data();
if msg.0 == MessageKind::MoistureData {
let plant = msg.1 as usize;
let sensor = msg.2;
let data = can_frame.data();
info!("Received moisture data: {:?}", data);
if let Ok(bytes) = data.try_into() {
let frequency = u32::from_be_bytes(bytes);
@@ -577,6 +687,23 @@ async fn wait_for_can_measurements(
} else {
error!("Received moisture data with invalid length: {} (expected 4)", data.len());
}
} else if msg.0 == MessageKind::FirmwareBuild {
info!("Received firmware build data: {:?}", data);
if let Ok(bytes) = data.try_into() {
let build_minutes = u32::from_be_bytes(bytes);
match sensor {
SensorSlot::A => {
moistures.sensor_a_build_minutes[plant - 1] =
Some(build_minutes);
}
SensorSlot::B => {
moistures.sensor_b_build_minutes[plant - 1] =
Some(build_minutes);
}
}
} else {
error!("Received firmware build data with invalid length: {} (expected 4)", data.len());
}
}
}
}
@@ -603,10 +730,17 @@ impl From<Moistures> for Detection {
fn from(value: Moistures) -> Self {
let mut result = Detection::default();
for (plant, sensor) in value.sensor_a_hz.iter().enumerate() {
result.plant[plant].sensor_a = sensor.is_some();
if sensor.is_some() {
// Sensor responded; include build timestamp (0 = timestamp not reported)
result.plant[plant].sensor_a =
Some(value.sensor_a_build_minutes[plant].unwrap_or(0));
}
}
for (plant, sensor) in value.sensor_b_hz.iter().enumerate() {
result.plant[plant].sensor_b = sensor.is_some();
if sensor.is_some() {
result.plant[plant].sensor_b =
Some(value.sensor_b_build_minutes[plant].unwrap_or(0));
}
}
result
}

View File

@@ -4,18 +4,21 @@ use crate::hal::{ADC1, TANK_MULTI_SAMPLE};
use embassy_time::Timer;
use esp_hal::analog::adc::{Adc, AdcCalLine, AdcConfig, AdcPin, Attenuation};
use esp_hal::delay::Delay;
use esp_hal::gpio::{Flex, Input, Output, OutputConfig, Pull};
use esp_hal::gpio::{DriveMode, Flex, Input, InputConfig, Output, OutputConfig, Pull};
use esp_hal::pcnt::channel::CtrlMode::Keep;
use esp_hal::pcnt::channel::EdgeMode::{Hold, Increment};
use esp_hal::pcnt::unit::Unit;
use esp_hal::peripherals::GPIO5;
use esp_hal::Blocking;
use esp_hal::Async;
use esp_println::println;
use log::info;
use onewire::{ds18b20, Device, DeviceSearch, OneWire, DS18B20};
unsafe impl Send for TankSensor<'_> {}
pub struct TankSensor<'a> {
one_wire_bus: OneWire<Flex<'a>>,
tank_channel: Adc<'a, ADC1<'a>, Blocking>,
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>,
@@ -30,11 +33,20 @@ impl<'a> TankSensor<'a> {
flow_sensor: Input,
pcnt1: Unit<'a, 1>,
) -> Result<TankSensor<'a>, FatError> {
one_wire_pin.apply_output_config(&OutputConfig::default().with_pull(Pull::None));
one_wire_pin.apply_output_config(
&OutputConfig::default()
.with_drive_mode(DriveMode::OpenDrain)
.with_pull(Pull::None),
);
one_wire_pin.apply_input_config(&InputConfig::default().with_pull(Pull::None));
one_wire_pin.set_high();
one_wire_pin.set_input_enable(true);
one_wire_pin.set_output_enable(true);
let mut adc1_config = AdcConfig::new();
let tank_pin = adc1_config.enable_pin_with_cal::<_, AdcCalLine<_>>(gpio5, Attenuation::_11dB);
let tank_channel = Adc::new(adc1, adc1_config);
let tank_pin =
adc1_config.enable_pin_with_cal::<_, AdcCalLine<_>>(gpio5, Attenuation::_11dB);
let tank_channel = Adc::new(adc1, adc1_config).into_async();
let one_wire_bus = OneWire::new(one_wire_pin, false);
@@ -77,18 +89,36 @@ impl<'a> TankSensor<'a> {
//multisample should be moved to water_temperature_c
let mut attempt = 1;
let mut delay = Delay::new();
self.one_wire_bus.reset(&mut delay)?;
let presence = self.one_wire_bus.reset(&mut delay)?;
info!("OneWire: reset presence pulse = {}", presence);
if !presence {
info!("OneWire: no device responded to reset — check pull-up resistor and wiring");
}
let mut search = DeviceSearch::new();
let mut water_temp_sensor: Option<Device> = None;
let mut devices_found = 0u8;
while let Some(device) = self.one_wire_bus.search_next(&mut search, &mut delay)? {
devices_found += 1;
info!(
"OneWire: found device #{} family=0x{:02X} addr={:02X?}",
devices_found, device.address[0], device.address
);
if device.address[0] == ds18b20::FAMILY_CODE {
water_temp_sensor = Some(device);
break;
} else {
info!("OneWire: skipping device — not a DS18B20 (family 0x{:02X} != 0x{:02X})", device.address[0], ds18b20::FAMILY_CODE);
}
}
if devices_found == 0 {
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 {
@@ -138,15 +168,14 @@ impl<'a> TankSensor<'a> {
let mut store = [0_u16; TANK_MULTI_SAMPLE];
for sample in store.iter_mut() {
let value = self.tank_channel.read_oneshot(&mut self.tank_pin);
//force yield
*sample = self.tank_channel.read_oneshot(&mut self.tank_pin).await;
//force yield between successful samples
Timer::after_millis(10).await;
*sample = value.unwrap();
}
self.tank_power.set_low();
store.sort();
let median_mv = store[TANK_MULTI_SAMPLE / 2] as f32;
Ok(median_mv/1000.0)
Ok(median_mv / 1000.0)
}
}

View File

@@ -0,0 +1,108 @@
use alloc::string::String;
use alloc::vec::Vec;
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;
struct LiveLogBuffer {
entries: Vec<(u64, String)>,
next_seq: u64,
}
impl LiveLogBuffer {
const fn new() -> Self {
Self {
entries: Vec::new(),
next_seq: 0,
}
}
fn push(&mut self, text: String) {
if self.entries.len() >= MAX_LIVE_LOG_ENTRIES {
self.entries.remove(0);
}
self.entries.push((self.next_seq, text));
self.next_seq += 1;
}
fn get_after(&self, after: Option<u64>) -> (Vec<(u64, String)>, bool, u64) {
let next_seq = self.next_seq;
match after {
None => (self.entries.clone(), false, next_seq),
Some(after_seq) => {
let result: Vec<_> = self.entries
.iter()
.filter(|(seq, _)| *seq > after_seq)
.cloned()
.collect();
// Dropped if there are entries that should exist (seq > after_seq) but
// the oldest retained entry has a higher seq than after_seq + 1.
let dropped = if next_seq > after_seq.saturating_add(1) {
if let Some((oldest_seq, _)) = self.entries.first() {
*oldest_seq > after_seq.saturating_add(1)
} else {
// Buffer empty but entries were written — all dropped
true
}
} else {
false
};
(result, dropped, next_seq)
}
}
}
}
pub struct InterceptorLogger {
live_log: BlockingMutex<CriticalSectionRawMutex, core::cell::RefCell<LiveLogBuffer>>,
}
impl InterceptorLogger {
pub const fn new() -> Self {
Self {
live_log: BlockingMutex::new(core::cell::RefCell::new(LiveLogBuffer::new())),
}
}
/// Returns (entries_after, dropped, next_seq).
/// Pass `after = None` to retrieve the entire current buffer.
/// Pass `after = Some(seq)` to retrieve only entries with seq > that value.
pub fn get_live_logs(&self, after: Option<u64>) -> (Vec<(u64, String)>, bool, u64) {
self.live_log.lock(|buf| buf.borrow().get_after(after))
}
pub fn init(&'static self) {
match log::set_logger(self).map(|()| log::set_max_level(LevelFilter::Info)) {
Ok(()) => {}
Err(_e) => {
esp_println::println!("ERROR: Logger already set");
}
}
}
}
impl Log for InterceptorLogger {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() <= log::Level::Info
}
fn log(&self, record: &Record) {
if self.enabled(record.metadata()) {
let message = alloc::format!("{}: {}", record.level(), record.args());
// Print to serial
esp_println::println!("{}", message);
// Store in live log ring buffer
self.live_log.lock(|buf| {
buf.borrow_mut().push(message);
});
}
}
fn flush(&self) {}
}

View File

@@ -1,10 +1,11 @@
use crate::hal::TIME_ACCESS;
use crate::vec;
use crate::BOARD_ACCESS;
use alloc::string::ToString;
use alloc::vec::Vec;
use bytemuck::{AnyBitPattern, Pod, Zeroable};
use deranged::RangedU8;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::channel::Channel;
use embassy_sync::mutex::Mutex;
use esp_hal::Persistable;
use log::{info, warn};
@@ -32,6 +33,40 @@ static mut LOG_ARRAY: LogArray = LogArray {
pub static LOG_ACCESS: Mutex<CriticalSectionRawMutex, &'static mut LogArray> =
unsafe { Mutex::new(&mut LOG_ARRAY) };
mod interceptor;
pub use interceptor::InterceptorLogger;
pub static INTERCEPTOR: InterceptorLogger = InterceptorLogger::new();
pub struct LogRequest {
pub message_key: LogMessage,
pub number_a: u32,
pub number_b: u32,
pub txt_short: heapless::String<TXT_SHORT_LENGTH>,
pub txt_long: heapless::String<TXT_LONG_LENGTH>,
}
static LOG_CHANNEL: Channel<CriticalSectionRawMutex, LogRequest, 16> = Channel::new();
#[embassy_executor::task]
pub async fn log_task() {
loop {
let request = LOG_CHANNEL.receive().await;
LOG_ACCESS
.lock()
.await
.log(
request.message_key,
request.number_a,
request.number_b,
request.txt_short.as_str(),
request.txt_long.as_str(),
)
.await;
}
}
const TXT_SHORT_LENGTH: usize = 8;
const TXT_LONG_LENGTH: usize = 32;
@@ -80,24 +115,31 @@ impl From<LogEntryInner> for LogEntry {
}
}
pub async fn log(
message_key: LogMessage,
number_a: u32,
number_b: u32,
txt_short: &str,
txt_long: &str,
) {
LOG_ACCESS
.lock()
.await
.log(message_key, number_a, number_b, txt_short, txt_long)
.await
pub fn log(message_key: LogMessage, number_a: u32, number_b: u32, txt_short: &str, txt_long: &str) {
let mut txt_short_stack: heapless::String<TXT_SHORT_LENGTH> = heapless::String::new();
let mut txt_long_stack: heapless::String<TXT_LONG_LENGTH> = heapless::String::new();
limit_length(txt_short, &mut txt_short_stack);
limit_length(txt_long, &mut txt_long_stack);
match LOG_CHANNEL.try_send(LogRequest {
message_key,
number_a,
number_b,
txt_short: txt_short_stack,
txt_long: txt_long_stack,
}) {
Ok(_) => {}
Err(_) => {
warn!("Log channel full, dropping log entry");
}
}
}
impl LogArray {
pub fn get(&mut self) -> Vec<LogEntry> {
let head: RangedU8<0, MAX_LOG_ARRAY_INDEX> =
RangedU8::new(self.head).unwrap_or(RangedU8::new(0).unwrap());
RangedU8::new(self.head).unwrap_or(RangedU8::new_saturating(0));
let mut rv: Vec<LogEntry> = Vec::new();
let mut index = head.wrapping_sub(1);
@@ -120,17 +162,11 @@ impl LogArray {
txt_long: &str,
) {
let mut head: RangedU8<0, MAX_LOG_ARRAY_INDEX> =
RangedU8::new(self.head).unwrap_or(RangedU8::new(0).unwrap());
let mut txt_short_stack: heapless::String<TXT_SHORT_LENGTH> = heapless::String::new();
let mut txt_long_stack: heapless::String<TXT_LONG_LENGTH> = heapless::String::new();
limit_length(txt_short, &mut txt_short_stack);
limit_length(txt_long, &mut txt_long_stack);
RangedU8::new(self.head).unwrap_or(RangedU8::new_saturating(0));
let time = {
let guard = TIME_ACCESS.get().await.lock().await;
guard.current_time_us()
let mut guard = BOARD_ACCESS.get().await.lock().await;
guard.board_hal.get_esp().rtc.current_time_us()
} / 1000;
let ordinal = message_key.ordinal() as u16;
@@ -148,12 +184,8 @@ impl LogArray {
to_modify.message_id = ordinal;
to_modify.a = number_a;
to_modify.b = number_b;
to_modify
.txt_short
.clone_from_slice(txt_short_stack.as_bytes());
to_modify
.txt_long
.clone_from_slice(txt_long_stack.as_bytes());
to_modify.txt_short.clone_from_slice(txt_short.as_bytes());
to_modify.txt_long.clone_from_slice(txt_long.as_bytes());
head = head.wrapping_add(1);
self.head = head.get();
}
@@ -281,6 +313,14 @@ pub enum LogMessage {
PumpMissingSensorCurrent,
#[strum(serialize = "MPPT Current sensor could not be reached")]
MPPTError,
#[strum(
serialize = "Trace: a: ${number_a} b: ${number_b} txt_s ${txt_short} long ${txt_long}"
)]
Trace,
#[strum(serialize = "Parsing error reading message")]
UnknownMessage,
#[strum(serialize = "Going to deep sleep for ${number_a} minutes")]
DeepSleep,
}
#[derive(Serialize)]
@@ -301,7 +341,7 @@ impl From<&LogMessage> for MessageTranslation {
impl LogMessage {
pub fn log_localisation_config() -> Vec<MessageTranslation> {
Vec::from_iter((0..LogMessage::len()).map(|i| {
let msg_type = LogMessage::from_ordinal(i).unwrap();
let msg_type = LogMessage::from_ordinal(i).unwrap_or(LogMessage::UnknownMessage);
(&msg_type).into()
}))
}

View File

@@ -8,6 +8,7 @@
reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
holding buffers for the duration of a data transfer."
)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
//TODO insert version here and read it in other parts, also read this for the ota webview
esp_bootloader_esp_idf::esp_app_desc!();
@@ -17,8 +18,7 @@ use crate::config::{NetworkConfig, PlantConfig, PlantControllerConfig};
use crate::fat_error::FatResult;
use crate::hal::esp::MQTT_STAY_ALIVE;
use crate::hal::PROGRESS_ACTIVE;
use crate::hal::{esp_time, TIME_ACCESS};
use crate::log::{log, LOG_ACCESS};
use crate::log::log;
use crate::tank::{determine_tank_state, TankError, TankState, WATER_FROZEN_THRESH};
use crate::webserver::http_server;
use crate::{
@@ -39,10 +39,10 @@ use embassy_net::Stack;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::{Mutex, MutexGuard};
use embassy_sync::once_lock::OnceLock;
use embassy_time::{Duration, Instant, Timer, WithTimeout};
use embassy_time::{Duration, Instant, Timer};
use esp_hal::rom::ets_delay_us;
use esp_hal::system::software_reset;
use esp_println::{logger, println};
use esp_println::println;
use hal::battery::BatteryState;
use log::LogMessage;
use option_lock::OptionLock;
@@ -122,6 +122,7 @@ struct PumpInfo {
median_current_ma: u16,
max_current_ma: u16,
min_current_ma: u16,
error: String,
}
#[derive(Serialize)]
@@ -129,7 +130,7 @@ pub struct PumpResult {
median_current_ma: u16,
max_current_ma: u16,
min_current_ma: u16,
error: bool,
error: String,
flow_value_ml: f32,
flow_value_count: i16,
pump_time_s: u16,
@@ -167,26 +168,25 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
let cur = match board.board_hal.get_rtc_module().get_rtc_time().await {
Ok(value) => {
{
let guard = TIME_ACCESS.get().await.lock().await;
guard.set_current_time_us(value.timestamp_micros() as u64);
board
.board_hal
.get_esp()
.rtc
.set_current_time_us(value.timestamp_micros() as u64);
}
value
}
Err(err) => {
info!("rtc module error: {err:?}");
board.board_hal.general_fault(true).await;
esp_time().await
board.board_hal.get_time().await
}
};
//check if we know the time current > 2020 (plausibility checks, this code is newer than 2020)
if cur.year() < 2020 {
to_config = true;
LOG_ACCESS
.lock()
.await
.log(LogMessage::YearInplausibleForceConfig, 0, 0, "", "")
.await;
log(LogMessage::YearInplausibleForceConfig, 0, 0, "", "");
}
info!("cur is {cur}");
match update_charge_indicator(&mut board).await {
@@ -194,16 +194,12 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
Err(error) => {
board.board_hal.general_fault(true).await;
error!("Error updating charge indicator: {error}");
log(LogMessage::MPPTError, 0, 0, "", "").await;
log(LogMessage::MPPTError, 0, 0, "", "");
let _ = board.board_hal.set_charge_indicator(false).await;
}
}
if board.board_hal.get_esp().get_restart_to_conf() {
LOG_ACCESS
.lock()
.await
.log(LogMessage::ConfigModeSoftwareOverride, 0, 0, "", "")
.await;
log(LogMessage::ConfigModeSoftwareOverride, 0, 0, "", "");
for _i in 0..2 {
board.board_hal.general_fault(true).await;
Timer::after_millis(100).await;
@@ -215,11 +211,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
board.board_hal.get_esp().set_restart_to_conf(false);
} else if board.board_hal.get_esp().mode_override_pressed() {
board.board_hal.general_fault(true).await;
LOG_ACCESS
.lock()
.await
.log(LogMessage::ConfigModeButtonOverride, 0, 0, "", "")
.await;
log(LogMessage::ConfigModeButtonOverride, 0, 0, "", "");
for _i in 0..5 {
board.board_hal.general_fault(true).await;
Timer::after_millis(100).await;
@@ -247,8 +239,8 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
let reboot_now = Arc::new(AtomicBool::new(false));
println!("starting webserver");
spawner.spawn(http_server(reboot_now.clone(), stack))?;
wait_infinity(board, WaitType::MissingConfig, reboot_now.clone()).await;
let _ = http_server(reboot_now.clone(), stack);
wait_infinity(board, WaitType::MissingConfig, reboot_now.clone(), UTC).await;
}
let mut stack: OptionLock<Stack> = OptionLock::empty();
@@ -285,7 +277,6 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
}),
None => UTC, // Fallback to UTC if no timezone is set
};
let _timezone = UTC;
let timezone_time = cur.with_timezone(&timezone);
info!(
@@ -297,46 +288,45 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
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;
publish_battery_state(&mut board).await.unwrap_or_else(|e| {
error!("Error publishing battery state {e}");
});
let _ = publish_mppt_state(&mut board).await;
}
LOG_ACCESS
.lock()
.await
.log(
LogMessage::StartupInfo,
matches!(network_mode, NetworkMode::Wifi { .. }) as u32,
matches!(
network_mode,
NetworkMode::Wifi {
sntp: SntpMode::Sync { .. },
..
}
) as u32,
matches!(network_mode, NetworkMode::Wifi { mqtt: true, .. })
.to_string()
.as_str(),
"",
)
.await;
log(
LogMessage::StartupInfo,
matches!(network_mode, NetworkMode::Wifi { .. }) as u32,
matches!(
network_mode,
NetworkMode::Wifi {
sntp: SntpMode::Sync { .. },
..
}
) as u32,
matches!(network_mode, NetworkMode::Wifi { mqtt: true, .. })
.to_string()
.as_str(),
"",
);
if to_config {
//check if client or ap mode and init Wi-Fi
info!("executing config mode override");
//config upload will trigger reboot!
let reboot_now = Arc::new(AtomicBool::new(false));
spawner.spawn(http_server(reboot_now.clone(), stack.take().unwrap()))?;
wait_infinity(board, WaitType::ConfigButton, reboot_now.clone()).await;
let stack_val = stack.take();
if let Some(s) = stack_val {
spawner.spawn(http_server(reboot_now.clone(), s)?);
} else {
bail!("Network stack missing, hard abort")
}
wait_infinity(board, WaitType::ConfigButton, reboot_now.clone(), timezone).await;
} else {
LOG_ACCESS
.lock()
.await
.log(LogMessage::NormalRun, 0, 0, "", "")
.await;
log(LogMessage::NormalRun, 0, 0, "", "");
}
let dry_run = false;
let dry_run = MQTT_STAY_ALIVE.load(Ordering::Relaxed);
let tank_state = determine_tank_state(&mut board).await;
@@ -344,38 +334,22 @@ 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_ACCESS
.lock()
.await
.log(
LogMessage::TankSensorMissing,
raw_value_mv as u32,
0,
"",
"",
)
.await
}
TankError::SensorValueError { value, min, max } => {
LOG_ACCESS
.lock()
.await
.log(
LogMessage::TankSensorValueRangeError,
min as u32,
max as u32,
&format!("{value}"),
"",
)
.await
}
TankError::SensorMissing(raw_value_mv) => log(
LogMessage::TankSensorMissing,
raw_value_mv as u32,
0,
"",
"",
),
TankError::SensorValueError { value, min, max } => log(
LogMessage::TankSensorValueRangeError,
min as u32,
max as u32,
&format!("{value}"),
"",
),
TankError::BoardError(err) => {
LOG_ACCESS
.lock()
.await
.log(LogMessage::TankSensorBoardError, 0, 0, "", &err.to_string())
.await
log(LogMessage::TankSensorBoardError, 0, 0, "", &err.to_string())
}
}
// disabled cannot trigger this because of wrapping if is_enabled
@@ -384,11 +358,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
.warn_level(&board.board_hal.get_config().tank)
.is_ok_and(|warn| warn)
{
LOG_ACCESS
.lock()
.await
.log(LogMessage::TankWaterLevelLow, 0, 0, "", "")
.await;
log(LogMessage::TankWaterLevelLow, 0, 0, "", "");
board.board_hal.general_fault(true).await;
}
}
@@ -406,22 +376,30 @@ 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).await;
publish_tank_state(&mut board, &tank_state, water_temp)
.await
.unwrap_or_else(|e| {
error!("Error publishing tank state {e}");
});
let moisture = board.board_hal.measure_moisture_hz().await?;
let plantstate: [PlantState; PLANT_COUNT] = [
PlantState::read_hardware_state(moisture, 0, &mut board).await,
PlantState::read_hardware_state(moisture, 1, &mut board).await,
PlantState::read_hardware_state(moisture, 2, &mut board).await,
PlantState::read_hardware_state(moisture, 3, &mut board).await,
PlantState::read_hardware_state(moisture, 4, &mut board).await,
PlantState::read_hardware_state(moisture, 5, &mut board).await,
PlantState::read_hardware_state(moisture, 6, &mut board).await,
PlantState::read_hardware_state(moisture, 7, &mut board).await,
PlantState::interpret_raw_values(moisture, 0, &mut board).await,
PlantState::interpret_raw_values(moisture, 1, &mut board).await,
PlantState::interpret_raw_values(moisture, 2, &mut board).await,
PlantState::interpret_raw_values(moisture, 3, &mut board).await,
PlantState::interpret_raw_values(moisture, 4, &mut board).await,
PlantState::interpret_raw_values(moisture, 5, &mut board).await,
PlantState::interpret_raw_values(moisture, 6, &mut board).await,
PlantState::interpret_raw_values(moisture, 7, &mut board).await,
];
publish_plant_states(&mut board, &timezone_time.clone(), &plantstate).await;
publish_plant_states(&mut board, &timezone_time.clone(), &plantstate)
.await
.unwrap_or_else(|e| {
error!("Error publishing plant states {e}");
});
let pump_required = plantstate
.iter()
@@ -429,11 +407,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
.any(|(it, conf)| it.needs_to_be_watered(conf, &timezone_time))
&& !water_frozen;
if pump_required {
LOG_ACCESS
.lock()
.await
.log(LogMessage::EnableMain, dry_run as u32, 0, "", "")
.await;
log(LogMessage::EnableMain, dry_run as u32, 0, "", "");
for (plant_id, (state, plant_config)) in plantstate
.iter()
.zip(&board.board_hal.get_config().plants.clone())
@@ -454,8 +428,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
plant_config.max_consecutive_pump_count as u32,
&(plant_id + 1).to_string(),
"",
)
.await;
);
board.board_hal.fault(plant_id, true).await?;
}
log(
@@ -464,8 +437,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
plant_config.pump_time_s as u32,
&dry_run.to_string(),
"",
)
.await;
);
board
.board_hal
.get_esp()
@@ -473,21 +445,49 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
board.board_hal.get_esp().last_pump_time(plant_id);
//state.active = true;
pump_info(plant_id, true, pump_ineffective, 0, 0, 0, false).await;
let result = do_secure_pump(&mut board, plant_id, plant_config, dry_run).await?;
//stop pump regardless of prior result//todo refactor to inner?
board.board_hal.pump(plant_id, false).await?;
pump_info(
&mut board,
plant_id,
false,
true,
pump_ineffective,
result.median_current_ma,
result.max_current_ma,
result.min_current_ma,
result.error,
0,
0,
0,
String::new(),
)
.await;
let result = do_secure_pump(&mut board, plant_id, plant_config, dry_run).await;
match result {
Ok(state) => {
pump_info(
&mut board,
plant_id,
false,
pump_ineffective,
state.median_current_ma,
state.max_current_ma,
state.min_current_ma,
state.error,
)
.await;
}
Err(err) => {
pump_info(
&mut board,
plant_id,
false,
pump_ineffective,
0,
0,
0,
format!("{err:?}"),
)
.await;
}
}
//stop pump regardless of prior result//todo refactor to inner?
board.board_hal.pump(plant_id, false).await?;
} else if !state.pump_in_timeout(plant_config, &timezone_time) {
// plant does not need to be watered and is not in timeout
// -> reset consecutive pump count
@@ -497,6 +497,54 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
.store_consecutive_pump_count(plant_id, 0);
}
}
} else {
// Pump corrosion protection: pulses each pump once a week for 2s around midday.
let last_check_day = board
.board_hal
.get_esp()
.get_last_corrosion_protection_check_day();
if board
.board_hal
.get_config()
.hardware
.pump_corrosion_protection
{
let current_day = timezone_time.weekday().number_from_monday() as i8;
let current_hour = timezone_time.hour();
// Monday (1) and around midday (11-13)
if current_day == 1 && (11..14).contains(&current_hour) {
if last_check_day != current_day {
info!("Running pump corrosion protection");
for plant_id in 0..PLANT_COUNT {
let mut plant_config =
board.board_hal.get_config().plants[plant_id].clone();
plant_config.pump_time_s = 2;
plant_config.pump_limit_ml = 1000; // high limit to ensure it runs for 2s
log(
LogMessage::PumpPlant,
(plant_id + 1) as u32,
2,
"corrosion_prot",
"",
);
let _ = do_secure_pump(&mut board, plant_id, &plant_config, dry_run).await;
let _ = board.board_hal.pump(plant_id, false).await;
}
board
.board_hal
.get_esp()
.set_last_corrosion_protection_check_day(current_day);
}
} else if last_check_day != current_day && current_day != 1 {
// Reset check day if it's a different day (and not Monday), so it can trigger again next week
board
.board_hal
.get_esp()
.set_last_corrosion_protection_check_day(-1);
}
}
}
info!("state of charg");
@@ -593,42 +641,78 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
.get_esp()
.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;
20
} else {
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/deepsleep", "night 1h").await;
60
let base_duration: u32 = if is_day { 20 } else { 60 };
// Shorten sleep if any plant has a pending action sooner than the base duration
let min_plant_wakeup: Option<u32> = plantstate
.iter()
.zip(&board.board_hal.get_config().plants)
.filter_map(|(state, conf)| {
state.minutes_until_actionable(conf, &timezone_time)
})
.min();
let duration = min_plant_wakeup
.map(|m| base_duration.min(m.max(1)))
.unwrap_or(base_duration);
if duration < base_duration {
let msg = format!("reduced {}m", duration);
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/deepsleep", &msg)
.await;
} else if is_day {
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/deepsleep", "normal 20m")
.await;
} else {
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/deepsleep", "night 1h")
.await;
}
duration
};
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/state", "sleep")
.await;
info!("Go to sleep for {deep_sleep_duration_minutes} minutes");
//determine next event
//is light out of work trigger soon?
//is battery low ??
//is deep sleep
//TODO
//mark_app_valid();
let stay_alive = MQTT_STAY_ALIVE.load(Ordering::Relaxed);
info!("Check stay alive, current state is {stay_alive}");
if stay_alive {
let reboot_now = Arc::new(AtomicBool::new(false));
let _webserver = http_server(reboot_now.clone(), stack.take().unwrap());
wait_infinity(board, WaitType::MqttConfig, reboot_now.clone()).await;
if let Some(s) = stack.take() {
spawner.spawn(http_server(reboot_now.clone(), s)?);
wait_infinity(board, WaitType::MqttConfig, reboot_now.clone(), timezone).await;
} else {
bail!("Network Stack missing, hard abort");
}
} else {
//TODO wait for all mqtt publishes?
log(
LogMessage::DeepSleep,
deep_sleep_duration_minutes,
0,
"",
"",
);
Timer::after_millis(5000).await;
board.board_hal.get_esp().set_restart_to_conf(false);
@@ -652,7 +736,7 @@ pub async fn do_secure_pump(
let mut current_collector = vec![0_u16; steps_in_50ms];
let mut flow_collector = vec![0_i16; steps_in_50ms];
let mut error = false;
let mut error = String::new();
let mut first_error = true;
let mut pump_time_ms: u32 = 0;
@@ -686,46 +770,46 @@ pub async fn do_secure_pump(
&& high_current
&& current_ma > STARTUP_ABORT_CURRENT_MA
{
let err_msg = format!("OverCurrent startup: {}mA", current_ma);
log(
LogMessage::PumpOverCurrent,
plant_id as u32 + 1,
current_ma as u32,
plant_config.max_pump_current_ma.to_string().as_str(),
step.to_string().as_str(),
)
.await;
error = true;
);
error = err_msg;
} else if high_current && first_error {
let err_msg = format!("OverCurrent: {}mA", current_ma);
log(
LogMessage::PumpOverCurrent,
plant_id as u32 + 1,
current_ma as u32,
plant_config.max_pump_current_ma.to_string().as_str(),
step.to_string().as_str(),
)
.await;
);
board.board_hal.general_fault(true).await;
board.board_hal.fault(plant_id, true).await?;
if !plant_config.ignore_current_error {
error = true;
error = err_msg;
break;
}
first_error = false;
}
let low_current = current_ma < plant_config.min_pump_current_ma;
if low_current && first_error {
let err_msg = format!("OpenLoop: {}mA", current_ma);
log(
LogMessage::PumpOpenLoopCurrent,
plant_id as u32 + 1,
current_ma as u32,
plant_config.min_pump_current_ma.to_string().as_str(),
step.to_string().as_str(),
)
.await;
);
board.board_hal.general_fault(true).await;
board.board_hal.fault(plant_id, true).await?;
if !plant_config.ignore_current_error {
error = true;
error = err_msg;
break;
}
first_error = false;
@@ -734,15 +818,15 @@ pub async fn do_secure_pump(
Err(err) => {
if !plant_config.ignore_current_error {
info!("Error getting pump current: {err}");
let err_msg = format!("MissingSensor: {err:?}");
log(
LogMessage::PumpMissingSensorCurrent,
plant_id as u32,
0,
"",
"",
)
.await;
error = true;
);
error = err_msg;
break;
} else {
error!("Error getting pump current: {err}");
@@ -764,9 +848,13 @@ pub async fn do_secure_pump(
}
None => Duration::from_millis(1),
};
hal::PlantHal::feed_watchdog();
Timer::after(sleep_time).await;
pump_time_ms += 50;
}
} else {
//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();
@@ -801,30 +889,29 @@ 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),
)
.unwrap();
)?;
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)).unwrap();
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
@@ -832,6 +919,7 @@ async fn publish_plant_states(
.mqtt_publish(&plant_topic, &state)
.await;
}
Ok(())
}
async fn publish_firmware_info(
@@ -907,13 +995,9 @@ async fn try_connect_wifi_sntp_mqtt(
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")
}
}
None => match stack.config_v6() {
Some(config) => config.address.address().to_string(),
None => String::from("No IP"),
},
};
NetworkMode::Wifi {
@@ -931,13 +1015,14 @@ async fn try_connect_wifi_sntp_mqtt(
}
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: bool,
error: String,
) {
let pump_info = PumpInfo {
enabled: pump_active,
@@ -945,16 +1030,13 @@ async fn pump_info(
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_ACCESS
.get()
.await
.lock()
.await
board
.board_hal
.get_esp()
.mqtt_publish(&pump_topic, &state)
@@ -987,11 +1069,11 @@ async fn publish_mppt_state(
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).unwrap().to_owned();
let json = serde_json::to_string(&state)?.to_owned();
json.to_owned()
}
Err(_) => "error".to_owned(),
@@ -1003,12 +1085,14 @@ async fn publish_battery_state(
.mqtt_publish("/battery", &value)
.await;
}
Ok(())
}
async fn wait_infinity(
board: MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
wait_type: WaitType,
reboot_now: Arc<AtomicBool>,
timezone: Tz,
) -> ! {
//since we force to have the lock when entering, we can release it to ensure the caller does not forget to dispose of it
drop(board);
@@ -1018,6 +1102,7 @@ async fn wait_infinity(
let mut pattern_step = 0;
let serial_config_receive = AtomicBool::new(false);
let mut suppress_further_mppt_error = false;
let mut last_mqtt_update: Option<Instant> = None;
// Long-press exit (for webserver config modes): hold boot button for 5 seconds.
let mut exit_hold_started: Option<Instant> = None;
@@ -1049,8 +1134,7 @@ async fn wait_infinity(
exit_hold_blink = !exit_hold_blink;
let progress = core::cmp::min(elapsed, exit_hold_duration);
let lit = ((progress.as_millis() as u64 * 8)
/ exit_hold_duration.as_millis() as u64)
let lit = ((progress.as_millis() * 8) / exit_hold_duration.as_millis())
.saturating_add(1)
.min(8) as usize;
@@ -1095,6 +1179,22 @@ async fn wait_infinity(
}
}
// MQTT updates in config mode
let now = Instant::now();
if last_mqtt_update.is_none()
|| now.duration_since(last_mqtt_update.unwrap_or(Instant::from_secs(0)))
>= Duration::from_secs(60)
{
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;
last_mqtt_update = Some(now);
}
// Skip default blink code when a progress display is active
if !PROGRESS_ACTIVE.load(Ordering::Relaxed) {
match wait_type {
@@ -1142,6 +1242,8 @@ async fn wait_infinity(
Timer::after_millis(delay).await;
hal::PlantHal::feed_watchdog();
if wait_type == WaitType::MqttConfig && !MQTT_STAY_ALIVE.load(Ordering::Relaxed) {
reboot_now.store(true, Ordering::Relaxed);
}
@@ -1149,14 +1251,15 @@ async fn wait_infinity(
info!("Rebooting now");
//ensure clean http answer
Timer::after_millis(500).await;
BOARD_ACCESS
let mut board = BOARD_ACCESS
.get()
.await
.lock()
.await
.board_hal
.deep_sleep(0)
.await;
board.board_hal.get_esp().mqtt_publish("/stay_alive", "0").await;
//give time to mqtt to send the last message
Timer::after_millis(500).await;
board.board_hal.deep_sleep(0).await;
}
}
}
@@ -1200,10 +1303,13 @@ async fn handle_serial_config(
}
}
use embassy_time::WithTimeout;
#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)]
#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
// intialize embassy
logger::init_logger_from_env();
crate::log::INTERCEPTOR.init();
spawner.spawn(log::log_task().unwrap());
//force init here!
match BOARD_ACCESS.init(
PlantHal::create()
@@ -1248,12 +1354,16 @@ async fn get_version(
let hash = &env!("VERGEN_GIT_SHA")[0..8];
let board = board.board_hal.get_esp();
let heap = esp_alloc::HEAP.stats();
VersionInfo {
git_hash: branch + "@" + hash,
build_time: env!("VERGEN_BUILD_TIMESTAMP").to_owned(),
current: format!("{:?}", board.current),
slot0_state: format!("{:?}", board.slot0_state),
slot1_state: format!("{:?}", board.slot1_state),
heap_total: heap.size,
heap_used: heap.current_usage,
heap_max_used: heap.max_usage,
}
}
@@ -1264,4 +1374,7 @@ struct VersionInfo {
current: String,
slot0_state: String,
slot1_state: String,
heap_total: usize,
heap_used: usize,
heap_max_used: usize,
}

View File

@@ -0,0 +1,34 @@
[package]
name = "mcutie"
version = "3.0.0"
edition = "2021"
[lib]
path = "lib.rs"
[features]
default = []
homeassistant = []
serde = ["dep:serde", "heapless/serde"]
defmt = []
log = ["dep:log"]
[dependencies]
embassy-net = { version = "0.8.0", default-features = false, features = ["tcp", "dns", "proto-ipv4", "proto-ipv6", "medium-ethernet"] }
embassy-sync = { version = "0.8.0", default-features = false }
embassy-time = { version = "0.5.1", default-features = false }
embassy-futures = { version = "0.1.2", default-features = false }
embedded-io = { version = "0.7.1", default-features = false }
embedded-io-async = { version = "0.7.0", default-features = false }
heapless = { version = "0.7.17", default-features = false }
mqttrs = { version = "0.4.1", default-features = false }
once_cell = { version = "1.21.3", default-features = false, features = ["critical-section"] }
pin-project = { version = "1.1.10", default-features = false }
hex = { version = "0.4.3", default-features = false }
serde = { version = "1.0.228", default-features = false, features = ["derive"], optional = true }
log = { version = "0.4.28", default-features = false, optional = true }
[dev-dependencies]
futures-executor = "0.3.31"
futures-timer = "3.0.3"
futures-util = "0.3.31"

View File

@@ -0,0 +1,124 @@
use core::{cmp, fmt, ops::Deref};
use embedded_io::{SliceWriteError, Write};
use mqttrs::{encode_slice, Packet};
use crate::Error;
/// A stack allocated buffer that can be written to and then read back from.
/// Dereferencing as a [`u8`] slice allows access to previously written data.
///
/// Can be written to with [`write!`] and supports [`embedded_io::Write`] and
/// [`embedded_io_async::Write`].
pub struct Buffer<const N: usize> {
bytes: [u8; N],
cursor: usize,
}
impl<const N: usize> Default for Buffer<N> {
fn default() -> Self {
Self::new()
}
}
impl<const N: usize> Buffer<N> {
/// Creates a new buffer.
pub(crate) const fn new() -> Self {
Self {
bytes: [0; N],
cursor: 0,
}
}
/// Creates a new buffer and writes the given data into it.
pub(crate) fn from(buf: &[u8]) -> Result<Self, Error> {
let mut buffer = Self::new();
match buffer.write_all(buf) {
Ok(()) => Ok(buffer),
Err(_) => Err(Error::TooLarge),
}
}
pub(crate) fn encode_packet(&mut self, packet: &Packet<'_>) -> Result<(), mqttrs::Error> {
let len = encode_slice(packet, &mut self.bytes[self.cursor..])?;
self.cursor += len;
Ok(())
}
#[cfg(feature = "serde")]
/// Serializes a value into this buffer using JSON.
pub(crate) fn serialize_json<T: serde::Serialize>(
&mut self,
value: &T,
) -> Result<(), serde_json_core::ser::Error> {
let len = serde_json_core::to_slice(value, &mut self.bytes[self.cursor..])?;
self.cursor += len;
Ok(())
}
#[cfg(feature = "serde")]
/// Deserializes this buffer using JSON into the given type.
pub fn deserialize_json<'a, T: serde::Deserialize<'a>>(
&'a self,
) -> Result<T, serde_json_core::de::Error> {
let (result, _) = serde_json_core::from_slice(self)?;
Ok(result)
}
/// The number of bytes available for writing into this buffer.
pub fn available(&self) -> usize {
N - self.cursor
}
}
impl<const N: usize> Deref for Buffer<N> {
type Target = [u8];
fn deref(&self) -> &Self::Target {
&self.bytes[0..self.cursor]
}
}
impl<const N: usize> fmt::Write for Buffer<N> {
fn write_str(&mut self, s: &str) -> fmt::Result {
self.write_all(s.as_bytes()).map_err(|_| fmt::Error)
}
}
impl<const N: usize> embedded_io::ErrorType for Buffer<N> {
type Error = SliceWriteError;
}
impl<const N: usize> embedded_io::Write for Buffer<N> {
fn write(&mut self, buf: &[u8]) -> Result<usize, Self::Error> {
if buf.is_empty() {
return Ok(0);
}
let writable = cmp::min(self.available(), buf.len());
if writable == 0 {
Err(SliceWriteError::Full)
} else {
self.bytes[self.cursor..self.cursor + writable].copy_from_slice(buf);
self.cursor += writable;
Ok(writable)
}
}
fn flush(&mut self) -> Result<(), Self::Error> {
Ok(())
}
}
impl<const N: usize> embedded_io_async::Write for Buffer<N> {
async fn write(&mut self, buf: &[u8]) -> Result<usize, Self::Error> {
<Self as embedded_io::Write>::write(self, buf)
}
async fn flush(&mut self) -> Result<(), Self::Error> {
Ok(())
}
}

View File

@@ -0,0 +1,80 @@
#![macro_use]
#[cfg(all(feature = "defmt", feature = "log"))]
compile_error!("The `defmt` and `log` features cannot both be enabled at the same time.");
#[cfg(not(feature = "defmt"))]
use core::fmt;
#[cfg(feature = "defmt")]
pub(crate) use ::defmt::Debug2Format;
#[cfg(not(feature = "defmt"))]
pub(crate) struct Debug2Format<D: fmt::Debug>(pub(crate) D);
#[cfg(feature = "log")]
impl<D: fmt::Debug> fmt::Debug for Debug2Format<D> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[collapse_debuginfo(yes)]
macro_rules! trace {
($s:literal $(, $x:expr)* $(,)?) => {
#[cfg(feature = "defmt")]
::defmt::trace!($s $(, $x)*);
#[cfg(feature = "log")]
::log::trace!($s $(, $x)*);
#[cfg(not(any(feature="defmt", feature="log")))]
let _ = ($( & $x ),*);
};
}
#[collapse_debuginfo(yes)]
macro_rules! debug {
($s:literal $(, $x:expr)* $(,)?) => {
#[cfg(feature = "defmt")]
::defmt::debug!($s $(, $x)*);
#[cfg(feature = "log")]
::log::debug!($s $(, $x)*);
#[cfg(not(any(feature="defmt", feature="log")))]
let _ = ($( & $x ),*);
};
}
#[collapse_debuginfo(yes)]
macro_rules! info {
($s:literal $(, $x:expr)* $(,)?) => {
#[cfg(feature = "defmt")]
::defmt::info!($s $(, $x)*);
#[cfg(feature = "log")]
::log::info!($s $(, $x)*);
#[cfg(not(any(feature="defmt", feature="log")))]
let _ = ($( & $x ),*);
};
}
#[collapse_debuginfo(yes)]
macro_rules! warn {
($s:literal $(, $x:expr)* $(,)?) => {
#[cfg(feature = "defmt")]
::defmt::warn!($s $(, $x)*);
#[cfg(feature = "log")]
::log::warn!($s $(, $x)*);
#[cfg(not(any(feature="defmt", feature="log")))]
let _ = ($( & $x ),*);
};
}
#[collapse_debuginfo(yes)]
macro_rules! error {
($s:literal $(, $x:expr)* $(,)?) => {
#[cfg(feature = "defmt")]
::defmt::error!($s $(, $x)*);
#[cfg(feature = "log")]
::log::error!($s $(, $x)*);
#[cfg(not(any(feature="defmt", feature="log")))]
let _ = ($( & $x ),*);
};
}

View File

@@ -0,0 +1,120 @@
//! Tools for publishing a [Home Assistant binary sensor](https://www.home-assistant.io/integrations/binary_sensor.mqtt/).
use core::ops::Deref;
use serde::{Deserialize, Serialize};
use crate::{homeassistant::Component, Error, Publishable, Topic};
/// The state of the sensor. Can be easily converted to or from a [`bool`].
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "&str", into = "&'static str")]
#[allow(missing_docs)]
pub enum BinarySensorState {
On,
Off,
}
impl From<BinarySensorState> for &'static str {
fn from(state: BinarySensorState) -> Self {
match state {
BinarySensorState::On => "ON",
BinarySensorState::Off => "OFF",
}
}
}
impl<'a> From<&'a str> for BinarySensorState {
fn from(st: &'a str) -> Self {
if st == "ON" {
Self::On
} else {
Self::Off
}
}
}
impl From<bool> for BinarySensorState {
fn from(val: bool) -> Self {
if val {
BinarySensorState::On
} else {
BinarySensorState::Off
}
}
}
impl From<BinarySensorState> for bool {
fn from(val: BinarySensorState) -> Self {
match val {
BinarySensorState::On => true,
BinarySensorState::Off => true,
}
}
}
impl AsRef<[u8]> for BinarySensorState {
fn as_ref(&self) -> &'static [u8] {
match self {
Self::On => "ON".as_bytes(),
Self::Off => "OFF".as_bytes(),
}
}
}
/// The type of sensor.
#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
#[allow(missing_docs)]
pub enum BinarySensorClass {
Battery,
BatteryCharging,
CarbonMonoxide,
Cold,
Connectivity,
Door,
GarageDoor,
Gas,
Heat,
Light,
Lock,
Moisture,
Motion,
Moving,
Occupancy,
Opening,
Plug,
Power,
Presence,
Problem,
Running,
Safety,
Smoke,
Sound,
Tamper,
Update,
Vibration,
Window,
}
/// A binary sensor that can publish a [`BinarySensorState`] status.
#[derive(Serialize)]
pub struct BinarySensor {
/// The type of sensor
pub device_class: Option<BinarySensorClass>,
}
impl Component for BinarySensor {
type State = BinarySensorState;
fn platform() -> &'static str {
"binary_sensor"
}
async fn publish_state<T: Deref<Target = str>>(
&self,
topic: &Topic<T>,
state: Self::State,
) -> Result<(), Error> {
topic.with_bytes(state).publish().await
}
}

View File

@@ -0,0 +1,40 @@
//! Tools for publishing a [Home Assistant button](https://www.home-assistant.io/integrations/button.mqtt/).
use core::ops::Deref;
use serde::Serialize;
use crate::{homeassistant::Component, Error, Topic};
/// The type of button.
#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
#[allow(missing_docs)]
pub enum ButtonClass {
Identify,
Restart,
Update,
}
/// A button that can be pressed.
#[derive(Serialize)]
pub struct Button {
/// The type of button.
pub device_class: Option<ButtonClass>,
}
impl Component for Button {
type State = ();
fn platform() -> &'static str {
"button"
}
async fn publish_state<T: Deref<Target = str>>(
&self,
_topic: &Topic<T>,
_state: Self::State,
) -> Result<(), Error> {
// Buttons don't have a state
Err(Error::Invalid)
}
}

View File

@@ -0,0 +1,384 @@
//! Tools for publishing a [Home Assistant light](https://www.home-assistant.io/integrations/light.mqtt/).
use core::{ops::Deref, str};
use serde::{ser::SerializeStruct, Deserialize, Serialize, Serializer};
use crate::{
fmt::Debug2Format,
homeassistant::{binary_sensor::BinarySensorState, ser::List, Component},
Error, Payload, Publishable, Topic,
};
#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
#[allow(missing_docs)]
pub enum SupportedColorMode {
OnOff,
Brightness,
#[serde(rename = "color_temp")]
ColorTemp,
Hs,
Xy,
Rgb,
Rgbw,
Rgbww,
White,
}
#[derive(Serialize, Deserialize, Default)]
struct SerializedColor {
#[serde(default, skip_serializing_if = "Option::is_none")]
h: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
s: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
x: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
y: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
r: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
g: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
b: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
w: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
c: Option<u8>,
}
#[derive(Deserialize)]
struct LedPayload<'a> {
state: BinarySensorState,
#[serde(default)]
brightness: Option<u8>,
#[serde(default)]
color_temp: Option<u32>,
#[serde(default)]
color: Option<SerializedColor>,
#[serde(default)]
effect: Option<&'a str>,
}
/// The color of the light in various forms.
#[derive(Serialize)]
#[serde(rename_all = "lowercase", tag = "color_mode", content = "color")]
#[allow(missing_docs)]
pub enum Color {
None,
Brightness(u8),
ColorTemp(u32),
Hs {
#[serde(rename = "h")]
hue: f32,
#[serde(rename = "s")]
saturation: f32,
},
Xy {
x: f32,
y: f32,
},
Rgb {
#[serde(rename = "r")]
red: u8,
#[serde(rename = "g")]
green: u8,
#[serde(rename = "b")]
blue: u8,
},
Rgbw {
#[serde(rename = "r")]
red: u8,
#[serde(rename = "g")]
green: u8,
#[serde(rename = "b")]
blue: u8,
#[serde(rename = "w")]
white: u8,
},
Rgbww {
#[serde(rename = "r")]
red: u8,
#[serde(rename = "g")]
green: u8,
#[serde(rename = "b")]
blue: u8,
#[serde(rename = "c")]
cool_white: u8,
#[serde(rename = "w")]
warm_white: u8,
},
}
/// The state of the light. This can be sent to the broker and received as a
/// command from Home Assistant.
pub struct LightState<'a> {
/// Whether the light is on or off.
pub state: BinarySensorState,
/// The color of the light.
pub color: Color,
/// Any effect that is applied.
pub effect: Option<&'a str>,
}
impl<'a> LightState<'a> {
/// Parses the state from a command payload.
pub fn from_payload(payload: &'a Payload) -> Result<Self, Error> {
let parsed: LedPayload<'a> = match payload.deserialize_json() {
Ok(p) => p,
Err(e) => {
warn!("Failed to deserialize packet: {:?}", Debug2Format(&e));
if let Ok(s) = str::from_utf8(payload) {
trace!("{}", s);
}
return Err(Error::PacketError);
}
};
let color = if let Some(color) = parsed.color {
if let Some(x) = color.x {
Color::Xy {
x,
y: color.y.unwrap_or_default(),
}
} else if let Some(h) = color.h {
Color::Hs {
hue: h,
saturation: color.s.unwrap_or_default(),
}
} else if let Some(c) = color.c {
Color::Rgbww {
red: color.r.unwrap_or_default(),
green: color.g.unwrap_or_default(),
blue: color.b.unwrap_or_default(),
cool_white: c,
warm_white: color.w.unwrap_or_default(),
}
} else if let Some(w) = color.w {
Color::Rgbw {
red: color.r.unwrap_or_default(),
green: color.g.unwrap_or_default(),
blue: color.b.unwrap_or_default(),
white: w,
}
} else {
Color::Rgb {
red: color.r.unwrap_or_default(),
green: color.g.unwrap_or_default(),
blue: color.b.unwrap_or_default(),
}
}
} else if let Some(color_temp) = parsed.color_temp {
Color::ColorTemp(color_temp)
} else if let Some(brightness) = parsed.brightness {
Color::Brightness(brightness)
} else {
Color::None
};
Ok(LightState {
state: parsed.state,
color,
effect: parsed.effect,
})
}
}
impl Serialize for LightState<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut len = 1;
if self.effect.is_some() {
len += 1;
}
match self.color {
Color::None => {}
Color::Brightness(_) | Color::ColorTemp(_) => len += 1,
_ => len += 2,
}
let mut serializer = serializer.serialize_struct("LightState", len)?;
serializer.serialize_field("state", &self.state)?;
if let Some(effect) = self.effect {
serializer.serialize_field("effect", effect)?;
} else {
serializer.skip_field("effect")?;
}
match self.color {
Color::None => {
serializer.skip_field("brightness")?;
serializer.skip_field("color_temp")?;
serializer.skip_field("color")?;
}
Color::Brightness(b) => {
serializer.skip_field("color_temp")?;
serializer.skip_field("color")?;
serializer.serialize_field("brightness", &b)?
}
Color::ColorTemp(c) => {
serializer.skip_field("brightness")?;
serializer.skip_field("color")?;
serializer.serialize_field("color_temp", &c)?
}
Color::Hs { hue, saturation } => {
serializer.skip_field("brightness")?;
serializer.skip_field("color_temp")?;
serializer.serialize_field("color_mode", "hs")?;
let color = SerializedColor {
h: Some(hue),
s: Some(saturation),
..Default::default()
};
serializer.serialize_field("color", &color)?
}
Color::Xy { x, y } => {
serializer.skip_field("brightness")?;
serializer.skip_field("color_temp")?;
serializer.serialize_field("color_mode", "xy")?;
let color = SerializedColor {
x: Some(x),
y: Some(y),
..Default::default()
};
serializer.serialize_field("color", &color)?
}
Color::Rgb { red, green, blue } => {
serializer.skip_field("brightness")?;
serializer.skip_field("color_temp")?;
serializer.serialize_field("color_mode", "rgb")?;
let color = SerializedColor {
r: Some(red),
g: Some(green),
b: Some(blue),
..Default::default()
};
serializer.serialize_field("color", &color)?
}
Color::Rgbw {
red,
green,
blue,
white,
} => {
serializer.skip_field("brightness")?;
serializer.skip_field("color_temp")?;
serializer.serialize_field("color_mode", "rgbw")?;
let color = SerializedColor {
r: Some(red),
g: Some(green),
b: Some(blue),
w: Some(white),
..Default::default()
};
serializer.serialize_field("color", &color)?
}
Color::Rgbww {
red,
green,
blue,
cool_white,
warm_white,
} => {
serializer.skip_field("brightness")?;
serializer.skip_field("color_temp")?;
serializer.serialize_field("color_mode", "rgbww")?;
let color = SerializedColor {
r: Some(red),
g: Some(green),
b: Some(blue),
c: Some(cool_white),
w: Some(warm_white),
..Default::default()
};
serializer.serialize_field("color", &color)?
}
}
serializer.end()
}
}
/// A light entity
pub struct Light<'a, const C: usize, const E: usize> {
/// The color modes supported by the light.
pub supported_color_modes: [SupportedColorMode; C],
/// Any effects that can be used.
pub effects: [&'a str; E],
}
impl<const C: usize, const E: usize> Serialize for Light<'_, C, E> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut len = 2;
if C > 0 {
len += 1;
}
if E > 0 {
len += 2;
}
let mut serializer = serializer.serialize_struct("Light", len)?;
serializer.serialize_field("schema", "json")?;
if C > 0 {
serializer.serialize_field("sup_clrm", &List::new(&self.supported_color_modes))?;
} else {
serializer.skip_field("sup_clrm")?;
}
if E > 0 {
serializer.serialize_field("effect", &true)?;
serializer.serialize_field("fx_list", &List::new(&self.effects))?;
} else {
serializer.skip_field("effect")?;
serializer.skip_field("fx_list")?;
}
serializer.end()
}
}
impl<const C: usize, const E: usize> Component for Light<'_, C, E> {
type State = LightState<'static>;
fn platform() -> &'static str {
"light"
}
async fn publish_state<T: Deref<Target = str>>(
&self,
topic: &Topic<T>,
state: Self::State,
) -> Result<(), Error> {
topic.with_json(state).publish().await
}
}

View File

@@ -0,0 +1,295 @@
//! Home Assistant auto-discovery and related messages.
//!
//! Normally you would declare your entities statically in your binary. It is
//! then trivial to send out discovery messages or state changes.
//!
//! ```
//! # use mcutie::{Publishable, Topic};
//! # use mcutie::homeassistant::{Entity, Device, Origin, AvailabilityState, AvailabilityTopics};
//! # use mcutie::homeassistant::binary_sensor::{BinarySensor, BinarySensorClass, BinarySensorState};
//! const DEVICE_AVAILABILITY_TOPIC: Topic<&'static str> = Topic::Device("status");
//! const MOTION_STATE_TOPIC: Topic<&'static str> = Topic::Device("motion/status");
//!
//! const DEVICE: Device<'static> = Device::new();
//! const ORIGIN: Origin<'static> = Origin::new();
//!
//! const MOTION_SENSOR: Entity<'static, 1, BinarySensor> = Entity {
//! device: DEVICE,
//! origin: ORIGIN,
//! object_id: "motion",
//! unique_id: Some("motion"),
//! name: "Motion",
//! availability: AvailabilityTopics::All([DEVICE_AVAILABILITY_TOPIC]),
//! state_topic: Some(MOTION_STATE_TOPIC),
//! command_topic: None,
//! component: BinarySensor {
//! device_class: Some(BinarySensorClass::Motion),
//! },
//! };
//!
//! async fn send_discovery_messages() {
//! MOTION_SENSOR.publish_discovery().await.unwrap();
//! DEVICE_AVAILABILITY_TOPIC.with_bytes(AvailabilityState::Online).publish().await.unwrap();
//! }
//!
//! async fn send_state(state: BinarySensorState) {
//! MOTION_SENSOR.publish_state(state).await.unwrap();
//! }
//! ```
use core::{future::Future, ops::Deref};
use mqttrs::QoS;
use serde::{
ser::{Error as _, SerializeStruct},
Serialize, Serializer,
};
use crate::{
device_id, device_type, homeassistant::ser::DiscoverySerializer, io::publish, Error,
McutieTask, MqttMessage, Payload, Publishable, Topic, TopicString, DATA_CHANNEL,
};
pub mod binary_sensor;
pub mod button;
pub mod light;
pub mod sensor;
mod ser;
const HA_STATUS_TOPIC: Topic<&'static str> = Topic::General("homeassistant/status");
const STATE_ONLINE: &str = "online";
const STATE_OFFLINE: &str = "offline";
/// A trait representing a specific type of entity in Home Assistant
pub trait Component: Serialize {
/// The state to publish.
type State;
/// The platform identifier for this entity. Internal.
fn platform() -> &'static str;
/// Publishes this entity's state to the MQTT broker.
fn publish_state<T: Deref<Target = str>>(
&self,
topic: &Topic<T>,
state: Self::State,
) -> impl Future<Output = Result<(), Error>>;
}
impl<'t, T, L, const S: usize> McutieTask<'t, T, L, S>
where
T: Deref<Target = str> + 't,
L: Publishable + 't,
{
pub(super) async fn ha_after_connected(&self) {
let _ = HA_STATUS_TOPIC.subscribe(false).await;
}
pub(super) async fn ha_handle_update(
&self,
topic: &Topic<TopicString>,
payload: &Payload,
) -> bool {
if topic == &HA_STATUS_TOPIC {
if payload.as_ref() == STATE_ONLINE.as_bytes() {
DATA_CHANNEL.send(MqttMessage::HomeAssistantOnline).await;
}
true
} else {
false
}
}
}
impl<T: Deref<Target = str>> Serialize for Topic<T> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut topic = TopicString::new();
self.to_string(&mut topic)
.map_err(|_| S::Error::custom("topic was too large to serialize"))?;
serializer.serialize_str(&topic)
}
}
fn name_or_device<S>(name: &Option<&str>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(name.unwrap_or_else(|| device_type()))
}
/// Represents the device in Home Assistant.
///
/// Can just be the default in which case useful properties such as the ID are
/// automatically included.
#[derive(Clone, Copy, Default)]
pub struct Device<'a> {
/// A name to identify the device. If not provided the default device type is
/// used.
pub name: Option<&'a str>,
/// An optional configuration URL for the device.
pub configuration_url: Option<&'a str>,
}
impl Device<'_> {
/// Creates a new default device.
pub const fn new() -> Self {
Self {
name: None,
configuration_url: None,
}
}
}
impl Serialize for Device<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut len = 2;
if self.configuration_url.is_some() {
len += 1;
}
let mut serializer = serializer.serialize_struct("Device", len)?;
serializer.serialize_field("name", self.name.unwrap_or_else(|| device_type()))?;
serializer.serialize_field("ids", device_id())?;
if let Some(cu) = self.configuration_url {
serializer.serialize_field("cu", cu)?;
} else {
serializer.skip_field("cu")?;
}
serializer.end()
}
}
/// Represents the device's origin in Home Assistant.
///
/// Can just be the default in which case useful properties are automatically
/// included.
#[derive(Clone, Copy, Default, Serialize)]
pub struct Origin<'a> {
/// A name to identify the device's origin. If not provided the default
/// device type is used.
#[serde(serialize_with = "name_or_device")]
pub name: Option<&'a str>,
}
impl Origin<'_> {
/// Creates a new default origin.
pub const fn new() -> Self {
Self { name: None }
}
}
/// A single entity for Home Assistant.
///
/// Calling [`Entity::publish_discovery`] will publish the discovery message to
/// allow Home Assistant to detect this entity. Read the
/// [Home Assistant MQTT docs](https://www.home-assistant.io/integrations/mqtt/)
/// for information on what some of these properties mean.
pub struct Entity<'a, const A: usize, C: Component> {
/// The device this entity is a part of.
pub device: Device<'a>,
/// The origin of the device.
pub origin: Origin<'a>,
/// An object identifier to allow for entity ID customisation in Home Assistant.
pub object_id: &'a str,
/// An optional unique identifier for the entity.
pub unique_id: Option<&'a str>,
/// A friendly name for the entity.
pub name: &'a str,
/// Specifies the availability topics that Home Assistant will listen to to
/// determine this entity's availability.
pub availability: AvailabilityTopics<'a, A>,
/// The state topic that this entity's state is published to.
pub state_topic: Option<Topic<&'a str>>,
/// The command topic that this entity receives commands from.
pub command_topic: Option<Topic<&'a str>>,
/// The specific entity.
pub component: C,
}
impl<const A: usize, C: Component> Entity<'_, A, C> {
/// Publishes the discovery message for this entity to the broker.
pub async fn publish_discovery(&self) -> Result<(), Error> {
let mut topic = TopicString::new();
topic
.push_str(option_env!("HA_DISCOVERY_PREFIX").unwrap_or("homeassistant"))
.map_err(|_| Error::TooLarge)?;
topic.push('/').map_err(|_| Error::TooLarge)?;
topic.push_str(C::platform()).map_err(|_| Error::TooLarge)?;
topic.push('/').map_err(|_| Error::TooLarge)?;
topic
.push_str(self.object_id)
.map_err(|_| Error::TooLarge)?;
topic.push_str("/config").map_err(|_| Error::TooLarge)?;
let mut payload = Payload::new();
payload.serialize_json(self).map_err(|_| Error::TooLarge)?;
publish(&topic, &payload, QoS::AtMostOnce, false).await
}
/// Publishes this entity's state to the broker.
///
/// # Errors
///
/// - [`Error::Invalid`] if the entity doesn't have a state topic.
pub async fn publish_state(&self, state: C::State) -> Result<(), Error> {
if let Some(topic) = self.state_topic {
self.component.publish_state(&topic, state).await
} else {
Err(Error::Invalid)
}
}
}
/// A payload representing a device or entity's availability.
#[allow(missing_docs)]
pub enum AvailabilityState {
Online,
Offline,
}
impl AsRef<[u8]> for AvailabilityState {
fn as_ref(&self) -> &'static [u8] {
match self {
Self::Online => STATE_ONLINE.as_bytes(),
Self::Offline => STATE_OFFLINE.as_bytes(),
}
}
}
/// The availiabity topics that home assistant will use to determine an entity's
/// availability.
pub enum AvailabilityTopics<'a, const A: usize> {
/// The entity is always available.
None,
/// The entity is available if all of the topics are publishes as online.
All([Topic<&'a str>; A]),
/// The entity is available if any of the topics are publishes as online.
Any([Topic<&'a str>; A]),
/// The entity is available based on the most recent of the topics to
/// publish state.
Latest([Topic<&'a str>; A]),
}
impl<const A: usize, C: Component> Serialize for Entity<'_, A, C> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let outer = DiscoverySerializer {
discovery: self,
inner: serializer,
};
self.component.serialize(outer)
}
}

View File

@@ -0,0 +1,103 @@
//! Tools for publishing a [Home Assistant sensor](https://www.home-assistant.io/integrations/sensor.mqtt/).
use core::ops::Deref;
use serde::Serialize;
use crate::{homeassistant::Component, Error, Publishable, Topic};
/// The type of sensor.
#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
#[allow(missing_docs)]
pub enum SensorClass {
ApparentPower,
Aqi,
AtmosphericPressure,
Battery,
CarbonDioxide,
CarbonMonoxide,
Current,
DataRate,
DataSize,
Date,
Distance,
Duration,
Energy,
EnergyStorage,
Enum,
Frequency,
Gas,
Humidity,
Illuminance,
Irradiance,
Moisture,
Monetary,
NitrogenDioxide,
NitrogenMonoxide,
NitrousOxide,
Ozone,
Ph,
Pm1,
Pm25,
Pm10,
PowerFactor,
Power,
Precipitation,
PrecipitationIntensity,
Pressure,
ReactivePower,
SignalStrength,
SoundPressure,
Speed,
SulphurDioxide,
Temperature,
Timestamp,
VolatileOrganicCompounds,
VolatileOrganicCompoundsParts,
Voltage,
Volume,
VolumeFlowRate,
VolumeStorage,
Water,
Weight,
WindSpeed,
}
/// The type of measurement that this entity publishes.
#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SensorStateClass {
/// A measurement at a singe point in time.
Measurement,
/// A cumulative total that can increase or decrease over time.
Total,
/// A cumulative total that can only increase.
TotalIncreasing,
}
/// A binary sensor that can publish a [`f32`] value.
#[derive(Serialize)]
pub struct Sensor<'u> {
/// The type of sensor.
pub device_class: Option<SensorClass>,
/// The type of measurement that this sensor reports.
pub state_class: Option<SensorStateClass>,
/// The unit of measurement for this sensor.
pub unit_of_measurement: Option<&'u str>,
}
impl Component for Sensor<'_> {
type State = f32;
fn platform() -> &'static str {
"sensor"
}
async fn publish_state<T: Deref<Target = str>>(
&self,
topic: &Topic<T>,
state: Self::State,
) -> Result<(), Error> {
topic.with_display(state).publish().await
}
}

View File

@@ -0,0 +1,333 @@
use core::ops::Deref;
use serde::{
ser::{SerializeSeq, SerializeStruct},
Serialize, Serializer,
};
use crate::{
homeassistant::{AvailabilityTopics, Component, Entity},
Topic,
};
#[derive(Serialize)]
pub(super) struct AvailabilityTopicItem<'a> {
topic: Topic<&'a str>,
}
struct AvailabilityTopicList<'a, T: Deref<Target = str>, const N: usize> {
list: &'a [Topic<T>; N],
}
impl<'a, const N: usize, T: Deref<Target = str>> AvailabilityTopicList<'a, T, N> {
pub(super) fn new(list: &'a [Topic<T>; N]) -> Self {
Self { list }
}
}
impl<T: Deref<Target = str>, const N: usize> Serialize for AvailabilityTopicList<'_, T, N> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut serializer = serializer.serialize_seq(Some(N))?;
for topic in self.list {
serializer.serialize_element(&AvailabilityTopicItem {
topic: topic.as_ref(),
})?;
}
serializer.end()
}
}
pub(super) struct List<'a, T: Serialize, const N: usize> {
list: &'a [T; N],
}
impl<'a, T: Serialize, const N: usize> List<'a, T, N> {
pub(super) fn new(list: &'a [T; N]) -> Self {
Self { list }
}
}
impl<T: Serialize, const N: usize> Serialize for List<'_, T, N> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut serializer = serializer.serialize_seq(Some(N))?;
for item in self.list {
serializer.serialize_element(item)?;
}
serializer.end()
}
}
pub(super) struct DiscoverySerializer<'a, const A: usize, C: Component, S: Serializer> {
pub(super) discovery: &'a Entity<'a, A, C>,
pub(super) inner: S,
}
impl<const A: usize, C: Component, S: Serializer> Serializer for DiscoverySerializer<'_, A, C, S> {
type Ok = S::Ok;
type Error = S::Error;
type SerializeSeq = S::SerializeSeq;
type SerializeTuple = S::SerializeTuple;
type SerializeTupleStruct = S::SerializeTupleStruct;
type SerializeTupleVariant = S::SerializeTupleVariant;
type SerializeMap = S::SerializeMap;
type SerializeStruct = S::SerializeStruct;
type SerializeStructVariant = S::SerializeStructVariant;
fn serialize_struct(
self,
name: &'static str,
mut len: usize,
) -> Result<Self::SerializeStruct, Self::Error> {
len += 5;
if self.discovery.state_topic.is_some() {
len += 1;
}
if self.discovery.command_topic.is_some() {
len += 1;
}
if self.discovery.unique_id.is_some() {
len += 1;
}
if !matches!(self.discovery.availability, AvailabilityTopics::None) {
len += 2;
}
let mut serializer = self.inner.serialize_struct(name, len)?;
serializer.serialize_field("dev", &self.discovery.device)?;
serializer.serialize_field("o", &self.discovery.origin)?;
serializer.serialize_field("p", C::platform())?;
serializer.serialize_field("obj_id", self.discovery.object_id)?;
serializer.serialize_field("name", self.discovery.name)?;
if let Some(t) = self.discovery.state_topic {
serializer.serialize_field("stat_t", &t)?;
} else {
serializer.skip_field("stat_t")?;
}
if let Some(t) = self.discovery.command_topic {
serializer.serialize_field("cmd_t", &t)?;
} else {
serializer.skip_field("cmd_t")?;
}
match &self.discovery.availability {
AvailabilityTopics::None => {
serializer.skip_field("avty")?;
serializer.skip_field("avty_mode")?;
}
AvailabilityTopics::All(topics) => {
serializer.serialize_field("avty_mode", "all")?;
serializer.serialize_field("avty", &AvailabilityTopicList::new(topics))?;
}
AvailabilityTopics::Any(topics) => {
serializer.serialize_field("avty_mode", "any")?;
serializer.serialize_field("avty", &AvailabilityTopicList::new(topics))?;
}
AvailabilityTopics::Latest(topics) => {
serializer.serialize_field("avty_mode", "latest")?;
serializer.serialize_field("avty", &AvailabilityTopicList::new(topics))?;
}
}
if let Some(v) = self.discovery.unique_id {
serializer.serialize_field("uniq_id", v)?;
} else {
serializer.skip_field("uniq_id")?;
}
Ok(serializer)
}
fn serialize_bool(self, _: bool) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_i8(self, _: i8) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_i16(self, _: i16) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_i32(self, _: i32) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_i64(self, _: i64) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_u8(self, _: u8) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_u16(self, _: u16) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_u32(self, _: u32) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_u64(self, _: u64) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_f32(self, _: f32) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_f64(self, _: f64) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_char(self, _: char) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_str(self, _: &str) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_bytes(self, _: &[u8]) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_none(self) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_some<T>(self, _: &T) -> Result<Self::Ok, Self::Error>
where
T: ?Sized + Serialize,
{
unimplemented!()
}
fn serialize_unit(self) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_unit_struct(self, _: &'static str) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_unit_variant(
self,
_: &'static str,
_: u32,
_: &'static str,
) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_newtype_struct<T>(self, _: &'static str, _: &T) -> Result<Self::Ok, Self::Error>
where
T: ?Sized + Serialize,
{
unimplemented!()
}
fn serialize_newtype_variant<T>(
self,
_: &'static str,
_: u32,
_: &'static str,
_: &T,
) -> Result<Self::Ok, Self::Error>
where
T: ?Sized + Serialize,
{
unimplemented!()
}
fn serialize_seq(self, _: Option<usize>) -> Result<Self::SerializeSeq, Self::Error> {
unimplemented!()
}
fn serialize_tuple(self, _: usize) -> Result<Self::SerializeTuple, Self::Error> {
unimplemented!()
}
fn serialize_tuple_struct(
self,
_: &'static str,
_: usize,
) -> Result<Self::SerializeTupleStruct, Self::Error> {
unimplemented!()
}
fn serialize_tuple_variant(
self,
_: &'static str,
_: u32,
_: &'static str,
_: usize,
) -> Result<Self::SerializeTupleVariant, Self::Error> {
unimplemented!()
}
fn serialize_map(self, _: Option<usize>) -> Result<Self::SerializeMap, Self::Error> {
unimplemented!()
}
fn serialize_struct_variant(
self,
_: &'static str,
_: u32,
_: &'static str,
_: usize,
) -> Result<Self::SerializeStructVariant, Self::Error> {
unimplemented!()
}
fn serialize_i128(self, _: i128) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn serialize_u128(self, _: u128) -> Result<Self::Ok, Self::Error> {
unimplemented!()
}
fn collect_seq<I>(self, _: I) -> Result<Self::Ok, Self::Error>
where
I: IntoIterator,
<I as IntoIterator>::Item: Serialize,
{
unimplemented!()
}
fn collect_map<K, V, I>(self, _: I) -> Result<Self::Ok, Self::Error>
where
K: Serialize,
V: Serialize,
I: IntoIterator<Item = (K, V)>,
{
unimplemented!()
}
fn collect_str<T>(self, _: &T) -> Result<Self::Ok, Self::Error>
where
T: ?Sized + core::fmt::Display,
{
unimplemented!()
}
fn is_human_readable(&self) -> bool {
unimplemented!()
}
}

View File

@@ -0,0 +1,483 @@
use core::ops::Deref;
pub(crate) use atomic16::assign_pid;
use embassy_futures::select::{select, select4, Either};
use embassy_net::{
dns::DnsQueryType,
tcp::{TcpReader, TcpSocket, TcpWriter},
Stack,
};
use embassy_sync::{
blocking_mutex::raw::CriticalSectionRawMutex,
pubsub::{PubSubChannel, Subscriber, WaitResult},
};
use embassy_time::Timer;
use embedded_io_async::Write;
use mqttrs::{
decode_slice, Connect, ConnectReturnCode, LastWill, Packet, Pid, Protocol, Publish, QoS, QosPid,
};
use crate::{
device_id, fmt::Debug2Format, pipe::ConnectedPipe, ControlMessage, Error, MqttMessage, Payload,
Publishable, Topic, TopicString, CONFIRMATION_TIMEOUT, DATA_CHANNEL, DEFAULT_BACKOFF,
RESET_BACKOFF,
};
static SEND_QUEUE: ConnectedPipe<CriticalSectionRawMutex, Payload, 10> = ConnectedPipe::new();
pub(crate) static CONTROL_CHANNEL: PubSubChannel<CriticalSectionRawMutex, ControlMessage, 2, 5, 0> =
PubSubChannel::new();
type ControlSubscriber = Subscriber<'static, CriticalSectionRawMutex, ControlMessage, 2, 5, 0>;
pub(crate) async fn subscribe() -> ControlSubscriber {
loop {
if let Ok(sub) = CONTROL_CHANNEL.subscriber() {
return sub;
}
Timer::after_millis(50).await;
}
}
#[cfg(target_has_atomic = "16")]
mod atomic16 {
use core::sync::atomic::{AtomicU16, Ordering};
use mqttrs::Pid;
static PID: AtomicU16 = AtomicU16::new(0);
pub(crate) async fn assign_pid() -> Pid {
Pid::new() + PID.fetch_add(1, Ordering::SeqCst)
}
}
#[cfg(not(target_has_atomic = "16"))]
mod atomic16 {
use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, mutex::Mutex};
use mqttrs::Pid;
static PID_MUTEX: Mutex<CriticalSectionRawMutex, u16> = Mutex::new(0);
pub(crate) async fn assign_pid() -> Pid {
let mut locked = PID_MUTEX.lock().await;
*locked += 1;
Pid::new() + *locked
}
}
pub(crate) async fn send_packet(packet: Packet<'_>) -> Result<(), Error> {
let mut buffer = Payload::new();
match buffer.encode_packet(&packet) {
Ok(()) => {
debug!(
"Sending packet to broker: {:?}",
Debug2Format(&packet.get_type())
);
SEND_QUEUE.push(buffer).await;
Ok(())
}
Err(_) => {
error!("Failed to send packet");
Err(Error::PacketError)
}
}
}
pub(crate) async fn wait_for_publish(
mut subscriber: ControlSubscriber,
expected_pid: Pid,
) -> Result<(), Error> {
match select(
async {
loop {
match subscriber.next_message().await {
WaitResult::Lagged(_) => {
// Maybe we missed the message?
}
WaitResult::Message(ControlMessage::Published(published_pid)) => {
if published_pid == expected_pid {
return Ok(());
}
}
_ => {}
}
}
},
Timer::after_millis(CONFIRMATION_TIMEOUT),
)
.await
{
Either::First(r) => r,
Either::Second(_) => Err(Error::TimedOut),
}
}
pub(crate) async fn publish(
topic_name: &str,
payload: &[u8],
qos: QoS,
retain: bool,
) -> Result<(), Error> {
let subscriber = subscribe().await;
let (qospid, pid) = match qos {
QoS::AtMostOnce => (QosPid::AtMostOnce, None),
QoS::AtLeastOnce => {
let pid = assign_pid().await;
(QosPid::AtLeastOnce(pid), Some(pid))
}
QoS::ExactlyOnce => {
let pid = assign_pid().await;
(QosPid::ExactlyOnce(pid), Some(pid))
}
};
let packet = Packet::Publish(Publish {
dup: false,
qospid,
retain,
topic_name,
payload,
});
send_packet(packet).await?;
if let Some(expected_pid) = pid {
wait_for_publish(subscriber, expected_pid).await
} else {
Ok(())
}
}
fn packet_size(buffer: &[u8]) -> Option<usize> {
let mut pos = 1;
let mut multiplier = 1;
let mut value = 0;
while pos < buffer.len() {
value += (buffer[pos] & 127) as usize * multiplier;
multiplier *= 128;
if (buffer[pos] & 128) == 0 {
return Some(value + pos + 1);
}
pos += 1;
if pos == 5 {
return Some(0);
}
}
None
}
/// The MQTT task that must be run in order for the stack to operate.
pub struct McutieTask<'t, T, L, const S: usize>
where
T: Deref<Target = str> + 't,
L: Publishable + 't,
{
pub(crate) network: Stack<'t>,
pub(crate) broker: &'t str,
pub(crate) last_will: Option<L>,
pub(crate) username: Option<&'t str>,
pub(crate) password: Option<&'t str>,
pub(crate) subscriptions: [Topic<T>; S],
pub(crate) keep_alive: u16
}
impl<'t, T, L, const S: usize> McutieTask<'t, T, L, S>
where
T: Deref<Target = str> + 't,
L: Publishable + 't,
{
#[cfg(not(feature = "homeassistant"))]
async fn ha_handle_update(&self, _topic: &Topic<TopicString>, _payload: &Payload) -> bool {
false
}
async fn recv_loop(&self, mut reader: TcpReader<'_>) -> Result<(), Error> {
let mut buffer = [0_u8; 4096];
let mut cursor: usize = 0;
let controller = CONTROL_CHANNEL.immediate_publisher();
loop {
match reader.read(&mut buffer[cursor..]).await {
Ok(0) => {
error!("Receive socket closed");
return Ok(());
}
Ok(len) => {
cursor += len;
}
Err(_) => {
error!("I/O failure reading packet");
return Err(Error::IOError);
}
}
let mut start_pos = 0;
loop {
let packet_length = match packet_size(&buffer[start_pos..cursor]) {
Some(0) => {
error!("Invalid MQTT packet");
return Err(Error::PacketError);
}
Some(len) => len,
None => {
// None is returned when there is not yet enough data to decode a packet.
if start_pos != 0 {
// Adjust the buffer to reclaim any unused data
buffer.copy_within(start_pos..cursor, 0);
cursor -= start_pos;
}
break;
}
};
let packet = match decode_slice(&buffer[start_pos..(start_pos + packet_length)]) {
Ok(Some(p)) => p,
Ok(None) => {
error!("Packet length calculation failed.");
return Err(Error::PacketError);
}
Err(_) => {
error!("Invalid MQTT packet");
return Err(Error::PacketError);
}
};
debug!(
"Received packet from broker: {:?}",
Debug2Format(&packet.get_type())
);
match packet {
Packet::Connack(connack) => match connack.code {
ConnectReturnCode::Accepted => {
#[cfg(feature = "homeassistant")]
self.ha_after_connected().await;
for topic in &self.subscriptions {
let _ = topic.subscribe(false).await;
}
DATA_CHANNEL.send(MqttMessage::Connected).await;
}
_ => {
error!("Connection request to broker was not accepted");
return Err(Error::IOError);
}
},
Packet::Pingresp => {}
Packet::Publish(publish) => {
match (
Topic::from_str(publish.topic_name),
Payload::from(publish.payload),
) {
(Ok(topic), Ok(payload)) => {
if !self.ha_handle_update(&topic, &payload).await {
DATA_CHANNEL
.send(MqttMessage::Publish(topic, payload))
.await;
}
}
_ => {
error!("Unable to process publish data as it was too large");
}
}
match publish.qospid {
mqttrs::QosPid::AtMostOnce => {}
mqttrs::QosPid::AtLeastOnce(pid) => {
send_packet(Packet::Puback(pid)).await?;
}
mqttrs::QosPid::ExactlyOnce(pid) => {
send_packet(Packet::Pubrec(pid)).await?;
}
}
}
Packet::Puback(pid) => {
controller.publish_immediate(ControlMessage::Published(pid));
}
Packet::Pubrec(pid) => {
controller.publish_immediate(ControlMessage::Published(pid));
send_packet(Packet::Pubrel(pid)).await?;
}
Packet::Pubrel(pid) => send_packet(Packet::Pubrel(pid)).await?,
Packet::Pubcomp(_) => {}
Packet::Suback(suback) => {
if let Some(return_code) = suback.return_codes.first() {
controller.publish_immediate(ControlMessage::Subscribed(
suback.pid,
*return_code,
));
} else {
warn!("Unexpected suback with no return codes");
}
}
Packet::Unsuback(pid) => {
controller.publish_immediate(ControlMessage::Unsubscribed(pid));
}
Packet::Connect(_)
| Packet::Subscribe(_)
| Packet::Pingreq
| Packet::Unsubscribe(_)
| Packet::Disconnect => {
debug!(
"Unexpected packet from broker: {:?}",
Debug2Format(&packet.get_type())
);
}
}
start_pos += packet_length;
if start_pos == cursor {
cursor = 0;
break;
}
}
}
}
async fn write_loop(&self, mut writer: TcpWriter<'_>) {
let mut buffer = Payload::new();
let mut last_will_topic = TopicString::new();
let mut last_will_payload = Payload::new();
let last_will = self.last_will.as_ref().and_then(|p| {
if p.write_topic(&mut last_will_topic).is_ok()
&& p.write_payload(&mut last_will_payload).is_ok()
{
Some(LastWill {
topic: &last_will_topic,
message: &last_will_payload,
qos: p.qos(),
retain: p.retain(),
})
} else {
None
}
});
// Send our connection request.
if buffer
.encode_packet(&Packet::Connect(Connect {
protocol: Protocol::MQTT311,
keep_alive: self.keep_alive,
client_id: device_id(),
clean_session: true,
last_will,
username: self.username,
password: self.password.map(|s| s.as_bytes()),
}))
.is_err()
{
error!("Failed to encode connection packet");
return;
}
if let Err(e) = writer.write_all(&buffer).await {
error!("Failed to send connection packet: {:?}", e);
return;
}
let reader = SEND_QUEUE.reader();
loop {
let buffer = reader.receive().await;
trace!("Writer sending packet");
if let Err(e) = writer.write_all(&buffer).await {
error!("Failed to send data: {:?}", e);
return;
}
}
}
/// Runs the MQTT stack. The future returned from this must be awaited for everything to work.
pub async fn run(self) {
let mut timeout: Option<u64> = None;
let mut rx_buffer = [0; 4096];
let mut tx_buffer = [0; 4096];
loop {
if let Some(millis) = timeout.replace(DEFAULT_BACKOFF) {
Timer::after_millis(millis).await;
}
if !self.network.is_config_up() {
debug!("Waiting for network to configure.");
self.network.wait_config_up().await;
debug!("Network configured.");
}
let ip_addrs = match self.network.dns_query(self.broker, DnsQueryType::A).await {
Ok(v) => v,
Err(e) => {
error!("Failed to lookup '{}' for broker: {:?}", self.broker, e);
continue;
}
};
let ip = match ip_addrs.first() {
Some(i) => *i,
None => {
error!("No IP address found for broker '{}'", self.broker);
continue;
}
};
debug!("Connecting to {}:1883", ip);
let mut socket = TcpSocket::new(self.network, &mut rx_buffer, &mut tx_buffer);
if let Err(e) = socket.connect((ip, 1883)).await {
error!("Failed to connect to {}:1883: {:?}", ip, e);
continue;
}
info!("Connected to {}", self.broker);
timeout = Some(RESET_BACKOFF);
let (reader, writer) = socket.split();
let recv_loop = self.recv_loop(reader);
let send_loop = self.write_loop(writer);
let ping_loop = async {
loop {
Timer::after_secs(45).await;
let _ = send_packet(Packet::Pingreq).await;
}
};
let link_down = async {
self.network.wait_link_down().await;
warn!("Network link lost");
};
let ip_down = async {
self.network.wait_config_down().await;
warn!("Network config lost");
};
select4(send_loop, ping_loop, recv_loop, select(link_down, ip_down)).await;
socket.close();
warn!("Lost connection with broker");
DATA_CHANNEL.send(MqttMessage::Disconnected).await;
}
}
}

View File

@@ -0,0 +1,227 @@
#![no_std]
#![deny(unreachable_pub)]
#![warn(missing_docs)]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
//! MQTT client support crate vendored into this repository.
use core::{ops::Deref, str};
pub use buffer::Buffer;
use embassy_net::{HardwareAddress, Stack};
use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, channel::Channel};
use heapless::String;
pub use io::McutieTask;
pub use mqttrs::QoS;
use mqttrs::{Pid, SubscribeReturnCodes};
use once_cell::sync::OnceCell;
pub use publish::*;
pub use topic::Topic;
// This must come first so the macros are visible
pub(crate) mod fmt;
mod buffer;
#[cfg(feature = "homeassistant")]
pub mod homeassistant;
mod io;
mod pipe;
mod publish;
mod topic;
// This really needs to match that used by mqttrs.
const TOPIC_LENGTH: usize = 256;
const PAYLOAD_LENGTH: usize = 2048;
/// A fixed length stack allocated string. The length is fixed by the mqttrs crate.
pub type TopicString = String<TOPIC_LENGTH>;
/// A fixed length buffer of 2048 bytes.
pub type Payload = Buffer<PAYLOAD_LENGTH>;
// By default in the event of an error connecting to the broker we will wait for 5s.
const DEFAULT_BACKOFF: u64 = 5000;
// If the connection dropped then re-connect more quickly.
const RESET_BACKOFF: u64 = 200;
// How long to wait for the broker to confirm actions.
const CONFIRMATION_TIMEOUT: u64 = 2000;
static DATA_CHANNEL: Channel<CriticalSectionRawMutex, MqttMessage, 10> = Channel::new();
static DEVICE_TYPE: OnceCell<String<32>> = OnceCell::new();
static DEVICE_ID: OnceCell<String<32>> = OnceCell::new();
fn device_id() -> &'static str {
DEVICE_ID.get().unwrap()
}
fn device_type() -> &'static str {
DEVICE_TYPE.get().unwrap()
}
/// Various errors
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum Error {
/// An IO error occured.
IOError,
/// The operation timed out.
TimedOut,
/// An attempt was made to encode something too large.
TooLarge,
/// A packet or payload could not be decoded or encoded.
PacketError,
/// An invalid or unsupported operation was attempted.
Invalid,
/// A value was rejected.
Rejected,
}
#[allow(clippy::large_enum_variant)]
/// A message from the MQTT broker.
pub enum MqttMessage {
/// The broker has been connected to successfully. Generally in response to this message a
/// device should subscribe to topics of interest and send out any device state.
Connected,
/// New data received from the broker.
Publish(Topic<TopicString>, Payload),
/// The connection to the broker has been dropped.
Disconnected,
/// Home Assistant has come online and you should send any discovery messages.
#[cfg(feature = "homeassistant")]
HomeAssistantOnline,
}
#[derive(Clone)]
enum ControlMessage {
Published(Pid),
Subscribed(Pid, SubscribeReturnCodes),
Unsubscribed(Pid),
}
/// Receives messages from the broker.
pub struct McutieReceiver;
impl McutieReceiver {
/// Waits for the next message from the broker.
pub async fn receive(&self) -> MqttMessage {
DATA_CHANNEL.receive().await
}
}
/// A builder to configure the MQTT stack.
pub struct McutieBuilder<'t, T, L, const S: usize>
where
T: Deref<Target = str> + 't,
L: Publishable + 't,
{
network: Stack<'t>,
device_type: &'t str,
device_id: Option<&'t str>,
broker: &'t str,
last_will: Option<L>,
username: Option<&'t str>,
password: Option<&'t str>,
subscriptions: [Topic<T>; S],
}
impl<'t, T: Deref<Target = str> + 't, L: Publishable + 't> McutieBuilder<'t, T, L, 0> {
/// Creates a new builder with the initial required configuration.
///
/// `device_type` is expected to be the same for all devices of the same type.
/// `broker` may be an IP address or a DNS name for the broker to connect to.
pub fn new(network: Stack<'t>, device_type: &'t str, broker: &'t str) -> Self {
Self {
network,
device_type,
broker,
device_id: None,
last_will: None,
username: None,
password: None,
subscriptions: [],
}
}
}
impl<'t, T: Deref<Target = str> + 't, L: Publishable + 't, const S: usize>
McutieBuilder<'t, T, L, S>
{
/// Add some default topics to subscribe to.
pub fn with_subscriptions<const N: usize>(
self,
subscriptions: [Topic<T>; N],
) -> McutieBuilder<'t, T, L, N> {
McutieBuilder {
network: self.network,
device_type: self.device_type,
broker: self.broker,
device_id: self.device_id,
last_will: self.last_will,
username: self.username,
password: self.password,
subscriptions,
}
}
}
impl<'t, T: Deref<Target = str> + 't, L: Publishable + 't, const S: usize>
McutieBuilder<'t, T, L, S>
{
/// Adds authentication for the broker.
pub fn with_authentication(self, username: &'t str, password: &'t str) -> Self {
Self {
username: Some(username),
password: Some(password),
..self
}
}
/// Sets a last will message to be published in the event of disconnection.
pub fn with_last_will(self, last_will: L) -> Self {
Self {
last_will: Some(last_will),
..self
}
}
/// Sets a custom unique device identifier. If none is set then the network
/// MAC address is used.
pub fn with_device_id(self, device_id: &'t str) -> Self {
Self {
device_id: Some(device_id),
..self
}
}
/// Initialises the MQTT stack returning a receiver for listening to
/// messages from the broker and a future that must be run in order for the
/// stack to operate.
pub fn build(self, keep_alive: u16) -> (McutieReceiver, McutieTask<'t, T, L, S>) {
let mut dtype = String::<32>::new();
dtype.push_str(self.device_type).unwrap();
DEVICE_TYPE.set(dtype).unwrap();
let mut did = String::<32>::new();
if let Some(device_id) = self.device_id {
did.push_str(device_id).unwrap();
} else if let HardwareAddress::Ethernet(address) = self.network.hardware_address() {
let mut buffer = [0_u8; 12];
hex::encode_to_slice(address.as_bytes(), &mut buffer).unwrap();
did.push_str(str::from_utf8(&buffer).unwrap()).unwrap();
}
DEVICE_ID.set(did).unwrap();
(
McutieReceiver {},
McutieTask {
network: self.network,
broker: self.broker,
last_will: self.last_will,
username: self.username,
password: self.password,
subscriptions: self.subscriptions,
keep_alive
},
)
}
}

View File

@@ -0,0 +1,267 @@
use core::{
cell::RefCell,
future::Future,
pin::Pin,
task::{Context, Poll, Waker},
};
use embassy_sync::blocking_mutex::{raw::RawMutex, Mutex};
use pin_project::pin_project;
struct PipeData<T, const N: usize> {
connect_count: usize,
receiver_waker: Option<Waker>,
sender_waker: Option<Waker>,
pending: Option<T>,
}
fn swap_wakers(waker: &mut Option<Waker>, new_waker: &Waker) {
if let Some(old_waker) = waker.take() {
if old_waker.will_wake(new_waker) {
*waker = Some(old_waker)
} else {
if !new_waker.will_wake(&old_waker) {
old_waker.wake();
}
*waker = Some(new_waker.clone());
}
} else {
*waker = Some(new_waker.clone())
}
}
pub(crate) struct ReceiveFuture<'a, M: RawMutex, T, const N: usize> {
pipe: &'a ConnectedPipe<M, T, N>,
}
impl<M: RawMutex, T, const N: usize> Future for ReceiveFuture<'_, M, T, N> {
type Output = T;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.pipe.inner.lock(|cell| {
let mut inner = cell.borrow_mut();
if let Some(waker) = inner.sender_waker.take() {
waker.wake();
}
if let Some(item) = inner.pending.take() {
if let Some(old_waker) = inner.receiver_waker.take() {
old_waker.wake();
}
Poll::Ready(item)
} else {
swap_wakers(&mut inner.receiver_waker, cx.waker());
Poll::Pending
}
})
}
}
pub(crate) struct PipeReader<'a, M: RawMutex, T, const N: usize> {
pipe: &'a ConnectedPipe<M, T, N>,
}
impl<M: RawMutex, T, const N: usize> PipeReader<'_, M, T, N> {
#[must_use]
pub(crate) fn receive(&self) -> ReceiveFuture<'_, M, T, N> {
ReceiveFuture { pipe: self.pipe }
}
}
impl<M: RawMutex, T, const N: usize> Drop for PipeReader<'_, M, T, N> {
fn drop(&mut self) {
self.pipe.inner.lock(|cell| {
let mut inner = cell.borrow_mut();
inner.connect_count -= 1;
if inner.connect_count == 0 {
inner.pending = None;
}
if let Some(waker) = inner.sender_waker.take() {
waker.wake();
}
})
}
}
#[pin_project]
pub(crate) struct PushFuture<'a, M: RawMutex, T, const N: usize> {
data: Option<T>,
pipe: &'a ConnectedPipe<M, T, N>,
}
impl<M: RawMutex, T, const N: usize> Future for PushFuture<'_, M, T, N> {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.pipe.inner.lock(|cell| {
let project = self.project();
let mut inner = cell.borrow_mut();
if let Some(receiver) = inner.receiver_waker.take() {
receiver.wake();
}
if project.data.is_none() || inner.connect_count == 0 {
trace!("Dropping packet");
Poll::Ready(())
} else if inner.pending.is_some() {
swap_wakers(&mut inner.sender_waker, cx.waker());
Poll::Pending
} else {
inner.pending = project.data.take();
Poll::Ready(())
}
})
}
}
/// A pipe that knows whether a receiver is connected. If so pushing to the
/// queue waits until there is space in the queue, otherwise data is simply
/// dropped.
pub(crate) struct ConnectedPipe<M: RawMutex, T, const N: usize> {
inner: Mutex<M, RefCell<PipeData<T, N>>>,
}
impl<M: RawMutex, T, const N: usize> ConnectedPipe<M, T, N> {
pub(crate) const fn new() -> Self {
Self {
inner: Mutex::new(RefCell::new(PipeData {
connect_count: 0,
receiver_waker: None,
sender_waker: None,
pending: None,
})),
}
}
/// A future that waits for a new item to be available.
pub(crate) fn reader(&self) -> PipeReader<'_, M, T, N> {
self.inner.lock(|cell| {
let mut inner = cell.borrow_mut();
inner.connect_count += 1;
PipeReader { pipe: self }
})
}
/// Pushes an item to the reader, waiting for a slot to become available if
/// connected.
#[must_use]
pub(crate) fn push(&self, data: T) -> PushFuture<'_, M, T, N> {
PushFuture {
data: Some(data),
pipe: self,
}
}
}
#[cfg(test)]
mod tests {
use core::time::Duration;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use futures_executor::{LocalPool, ThreadPool};
use futures_timer::Delay;
use futures_util::{future::select, pin_mut, task::SpawnExt, FutureExt};
use super::ConnectedPipe;
async fn wait_milis(milis: u64) {
Delay::new(Duration::from_millis(milis)).await;
}
// #[futures_test::test]
#[test]
fn test_send_receive() {
let mut executor = LocalPool::new();
let spawner = executor.spawner();
static PIPE: ConnectedPipe<CriticalSectionRawMutex, usize, 5> = ConnectedPipe::new();
// Task that sends
spawner
.spawn(async {
wait_milis(10).await;
PIPE.push(23).await;
PIPE.push(56).await;
PIPE.push(67).await;
})
.unwrap();
// Task that receives
spawner
.spawn(async {
let reader = PIPE.reader();
let value = reader.receive().await;
assert_eq!(value, 23);
let value = reader.receive().await;
assert_eq!(value, 56);
let value = reader.receive().await;
assert_eq!(value, 67);
})
.unwrap();
executor.run();
}
#[futures_test::test]
async fn test_send_drop() {
static PIPE: ConnectedPipe<CriticalSectionRawMutex, usize, 5> = ConnectedPipe::new();
PIPE.push(23).await;
PIPE.push(56).await;
PIPE.push(67).await;
// Create a reader after sending
let reader = PIPE.reader();
let receive = reader.receive().fuse();
pin_mut!(receive);
let timeout = wait_milis(50).fuse();
pin_mut!(timeout);
let either = select(receive, timeout).await;
match either {
futures_util::future::Either::Left(_) => {
panic!("There should be nothing to receive!");
}
futures_util::future::Either::Right(_) => {}
}
}
#[futures_test::test]
async fn test_bulk_send_publish() {
static PIPE: ConnectedPipe<CriticalSectionRawMutex, usize, 5> = ConnectedPipe::new();
let executor = ThreadPool::new().unwrap();
executor
.spawn(async {
for i in 0..1000 {
PIPE.push(i).await;
}
})
.unwrap();
executor
.spawn(async {
for i in 1000..2000 {
PIPE.push(i).await;
}
})
.unwrap();
let reader = PIPE.reader();
for _ in 0..800 {
reader.receive().await;
}
}
}

View File

@@ -0,0 +1,173 @@
use core::{fmt::Display, future::Future, ops::Deref};
use embedded_io::Write;
use mqttrs::QoS;
use crate::{io::publish, Error, Payload, Topic, TopicString};
/// A message that can be published to an MQTT broker.
pub trait Publishable {
/// Write this message's topic into the supplied buffer.
fn write_topic(&self, buffer: &mut TopicString) -> Result<(), Error>;
/// Write this message's payload into the supplied buffer.
fn write_payload(&self, buffer: &mut Payload) -> Result<(), Error>;
/// Get this message's QoS level.
fn qos(&self) -> QoS {
QoS::AtMostOnce
}
/// Whether the broker should retain this message.
fn retain(&self) -> bool {
false
}
/// Publishes this message to the broker. If the stack has not yet been
/// initialized this is likely to panic.
fn publish(&self) -> impl Future<Output = Result<(), Error>> {
async {
let mut topic = TopicString::new();
self.write_topic(&mut topic)?;
let mut payload = Payload::new();
self.write_payload(&mut payload)?;
publish(&topic, &payload, self.qos(), self.retain()).await
}
}
}
/// A [`Publishable`] with a raw byte payload.
pub struct PublishBytes<'a, T, B: AsRef<[u8]>> {
pub(crate) topic: &'a Topic<T>,
pub(crate) data: B,
pub(crate) qos: QoS,
pub(crate) retain: bool,
}
impl<T, B: AsRef<[u8]>> PublishBytes<'_, T, B> {
/// Sets the QoS level for this message.
pub fn qos(mut self, qos: QoS) -> Self {
self.qos = qos;
self
}
/// Sets whether the broker should retain this message.
pub fn retain(mut self, retain: bool) -> Self {
self.retain = retain;
self
}
}
impl<'a, T: Deref<Target = str> + 'a, B: AsRef<[u8]>> Publishable for PublishBytes<'a, T, B> {
fn write_topic(&self, buffer: &mut TopicString) -> Result<(), Error> {
self.topic.to_string(buffer)
}
fn write_payload(&self, buffer: &mut Payload) -> Result<(), Error> {
buffer
.write_all(self.data.as_ref())
.map_err(|_| Error::TooLarge)
}
fn qos(&self) -> QoS {
self.qos
}
fn retain(&self) -> bool {
self.retain
}
async fn publish(&self) -> Result<(), Error> {
let mut topic = TopicString::new();
self.write_topic(&mut topic)?;
publish(&topic, self.data.as_ref(), self.qos(), self.retain()).await
}
}
/// A [`Publishable`] with a payload that implements [`Display`].
pub struct PublishDisplay<'a, T, D: Display> {
pub(crate) topic: &'a Topic<T>,
pub(crate) data: D,
pub(crate) qos: QoS,
pub(crate) retain: bool,
}
impl<T, D: Display> PublishDisplay<'_, T, D> {
/// Sets the QoS level for this message.
pub fn qos(mut self, qos: QoS) -> Self {
self.qos = qos;
self
}
/// Sets whether the broker should retain this message.
pub fn retain(mut self, retain: bool) -> Self {
self.retain = retain;
self
}
}
impl<'a, T: Deref<Target = str> + 'a, D: Display> Publishable for PublishDisplay<'a, T, D> {
fn write_topic(&self, buffer: &mut TopicString) -> Result<(), Error> {
self.topic.to_string(buffer)
}
fn write_payload(&self, buffer: &mut Payload) -> Result<(), Error> {
write!(buffer, "{}", self.data).map_err(|_| Error::TooLarge)
}
fn qos(&self) -> QoS {
self.qos
}
fn retain(&self) -> bool {
self.retain
}
}
#[cfg(feature = "serde")]
/// A [`Publishable`] with that serializes a JSON payload.
pub struct PublishJson<'a, T, D: serde::Serialize> {
pub(crate) topic: &'a Topic<T>,
pub(crate) data: D,
pub(crate) qos: QoS,
pub(crate) retain: bool,
}
#[cfg(feature = "serde")]
impl<T, D: serde::Serialize> PublishJson<'_, T, D> {
/// Sets the QoS level for this message.
pub fn qos(mut self, qos: QoS) -> Self {
self.qos = qos;
self
}
/// Sets whether the broker should retain this message.
pub fn retain(mut self, retain: bool) -> Self {
self.retain = retain;
self
}
}
#[cfg(feature = "serde")]
impl<'a, T: Deref<Target = str> + 'a, D: serde::Serialize> Publishable for PublishJson<'a, T, D> {
fn write_topic(&self, buffer: &mut TopicString) -> Result<(), Error> {
self.topic.to_string(buffer)
}
fn write_payload(&self, buffer: &mut Payload) -> Result<(), Error> {
buffer
.serialize_json(&self.data)
.map_err(|_| Error::TooLarge)
}
fn qos(&self) -> QoS {
self.qos
}
fn retain(&self) -> bool {
self.retain
}
}

View File

@@ -0,0 +1,284 @@
use core::{fmt::Display, ops::Deref};
use embassy_futures::select::{select, Either};
use embassy_sync::pubsub::WaitResult;
use embassy_time::Timer;
use heapless::{String, Vec};
use mqttrs::{Packet, QoS, Subscribe, SubscribeReturnCodes, SubscribeTopic, Unsubscribe};
#[cfg(feature = "serde")]
use crate::publish::PublishJson;
use crate::{
device_id, device_type,
io::{assign_pid, send_packet, subscribe},
publish::{PublishBytes, PublishDisplay},
ControlMessage, Error, TopicString, CONFIRMATION_TIMEOUT,
};
/// An MQTT topic that is optionally prefixed with the device type and unique ID.
/// Normally you will define all your application's topics as consts with static
/// lifetimes.
///
/// A [`Topic`] is the main entry to publishing messages to the broker.
///
/// ```
/// # use mcutie::{Publishable, Topic};
/// const DEVICE_AVAILABILITY: Topic<&'static str> = Topic::Device("state");
///
/// async fn send_status(status: &'static str) {
/// let _ = DEVICE_AVAILABILITY.with_bytes(status.as_bytes()).publish().await;
/// }
/// ```
#[derive(Clone, Copy)]
pub enum Topic<T> {
/// A topic that is prefixed with the device type.
DeviceType(T),
/// A topic that is prefixed with the device type and unique ID.
Device(T),
/// Any topic.
General(T),
}
impl<A, B> PartialEq<Topic<A>> for Topic<B>
where
B: PartialEq<A>,
{
fn eq(&self, other: &Topic<A>) -> bool {
match (self, other) {
(Topic::DeviceType(l0), Topic::DeviceType(r0)) => l0 == r0,
(Topic::Device(l0), Topic::Device(r0)) => l0 == r0,
(Topic::General(l0), Topic::General(r0)) => l0 == r0,
_ => false,
}
}
}
impl<T> Topic<T> {
/// Creates a publishable message with something that can return a reference
/// to the payload in bytes.
///
/// Defaults to non-retained with QoS of 0 (AtMostOnce).
pub fn with_bytes<B: AsRef<[u8]>>(&self, data: B) -> PublishBytes<'_, T, B> {
PublishBytes {
topic: self,
data,
qos: QoS::AtMostOnce,
retain: false,
}
}
/// Creates a publishable message with something that implements [`Display`].
///
/// Defaults to non-retained with QoS of 0 (AtMostOnce).
pub fn with_display<D: Display>(&self, data: D) -> PublishDisplay<'_, T, D> {
PublishDisplay {
topic: self,
data,
qos: QoS::AtMostOnce,
retain: false,
}
}
#[cfg(feature = "serde")]
/// Creates a publishable message with something that can be serialized to
/// JSON.
///
/// Defaults to non-retained with QoS of 0 (AtMostOnce).
pub fn with_json<D: serde::Serialize>(&self, data: D) -> PublishJson<'_, T, D> {
PublishJson {
topic: self,
data,
qos: QoS::AtMostOnce,
retain: false,
}
}
}
impl Topic<TopicString> {
pub(crate) fn from_str(mut st: &str) -> Result<Self, Error> {
let mut strip_prefix = |pr: &str| -> bool {
if st.starts_with(pr) && st.len() > pr.len() && &st[pr.len()..pr.len() + 1] == "/" {
st = &st[pr.len() + 1..];
true
} else {
false
}
};
if strip_prefix(device_type()) {
if strip_prefix(device_id()) {
let mut topic = TopicString::new();
topic.push_str(st).map_err(|_| Error::TooLarge)?;
Ok(Topic::Device(topic))
} else {
let mut topic = TopicString::new();
topic.push_str(st).map_err(|_| Error::TooLarge)?;
Ok(Topic::DeviceType(topic))
}
} else {
let mut topic = TopicString::new();
topic.push_str(st).map_err(|_| Error::TooLarge)?;
Ok(Topic::General(topic))
}
}
}
impl<T: Deref<Target = str>> Topic<T> {
pub(crate) fn to_string<const N: usize>(&self, result: &mut String<N>) -> Result<(), Error> {
match self {
Topic::Device(st) => {
result
.push_str(device_type())
.map_err(|_| Error::TooLarge)?;
result.push_str("/").map_err(|_| Error::TooLarge)?;
result.push_str(device_id()).map_err(|_| Error::TooLarge)?;
result.push_str("/").map_err(|_| Error::TooLarge)?;
result.push_str(st.as_ref()).map_err(|_| Error::TooLarge)?;
}
Topic::DeviceType(st) => {
result
.push_str(device_type())
.map_err(|_| Error::TooLarge)?;
result.push_str("/").map_err(|_| Error::TooLarge)?;
result.push_str(st.as_ref()).map_err(|_| Error::TooLarge)?;
}
Topic::General(st) => {
result.push_str(st.as_ref()).map_err(|_| Error::TooLarge)?;
}
}
Ok(())
}
/// Converts to a topic containing an [`str`]. Particularly useful for converting from an owned
/// string for match patterns.
pub fn as_ref(&self) -> Topic<&str> {
match self {
Topic::DeviceType(st) => Topic::DeviceType(st.as_ref()),
Topic::Device(st) => Topic::Device(st.as_ref()),
Topic::General(st) => Topic::General(st.as_ref()),
}
}
/// Subscribes to this topic. If `wait_for_ack` is true then this will wait until confirmation
/// is received from the broker before returning.
pub async fn subscribe(&self, wait_for_ack: bool) -> Result<(), Error> {
let mut subscriber = subscribe().await;
let mut topic_path = TopicString::new();
if self.to_string(&mut topic_path).is_err() {
return Err(Error::TooLarge);
}
let pid = assign_pid().await;
let mut subscribe_topic_path = String::<256>::new();
subscribe_topic_path
.push_str(topic_path.as_str())
.map_err(|_| Error::TooLarge)?;
let subscribe_topic = SubscribeTopic {
topic_path: subscribe_topic_path,
qos: QoS::AtLeastOnce,
};
// The size of this vec must match that used by mqttrs.
let topics = match Vec::<SubscribeTopic, 5>::from_slice(&[subscribe_topic]) {
Ok(t) => t,
Err(_) => return Err(Error::TooLarge),
};
let packet = Packet::Subscribe(Subscribe { pid, topics });
send_packet(packet).await?;
if wait_for_ack {
match select(
async {
loop {
match subscriber.next_message().await {
WaitResult::Lagged(_) => {
// Maybe we missed the message?
}
WaitResult::Message(ControlMessage::Subscribed(
subscribed_pid,
return_code,
)) => {
if subscribed_pid == pid {
if matches!(return_code, SubscribeReturnCodes::Success(_)) {
return Ok(());
} else {
return Err(Error::IOError);
}
}
}
_ => {}
}
}
},
Timer::after_millis(CONFIRMATION_TIMEOUT),
)
.await
{
Either::First(r) => r,
Either::Second(_) => Err(Error::TimedOut),
}
} else {
Ok(())
}
}
/// Unsubscribes from a topic. If `wait_for_ack` is true then this will wait until confirmation is
/// received from the broker before returning.
pub async fn unsubscribe(&self, wait_for_ack: bool) -> Result<(), Error> {
let mut subscriber = subscribe().await;
let mut topic_path = TopicString::new();
if self.to_string(&mut topic_path).is_err() {
return Err(Error::TooLarge);
}
let pid = assign_pid().await;
// The size of this vec must match that used by mqttrs.
let mut unsubscribe_topic_path = String::<256>::new();
unsubscribe_topic_path
.push_str(topic_path.as_str())
.map_err(|_| Error::TooLarge)?;
let topics = match Vec::<String<256>, 5>::from_slice(&[unsubscribe_topic_path]) {
Ok(t) => t,
Err(_) => return Err(Error::TooLarge),
};
let packet = Packet::Unsubscribe(Unsubscribe { pid, topics });
send_packet(packet).await?;
if wait_for_ack {
match select(
async {
loop {
match subscriber.next_message().await {
WaitResult::Lagged(_) => {
// Maybe we missed the message?
}
WaitResult::Message(ControlMessage::Unsubscribed(subscribed_pid)) => {
if subscribed_pid == pid {
return Ok(());
}
}
_ => {}
}
}
},
Timer::after_millis(CONFIRMATION_TIMEOUT),
)
.await
{
Either::First(r) => r,
Either::Second(_) => Err(Error::TimedOut),
}
} else {
Ok(())
}
}
}

View File

@@ -1,6 +1,6 @@
use crate::hal::Moistures;
use crate::{config::PlantConfig, hal::HAL, in_time_range};
use chrono::{DateTime, TimeDelta, Utc};
use chrono::{DateTime, TimeDelta, Timelike, Utc};
use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
@@ -9,14 +9,16 @@ const MOIST_SENSOR_MIN_FREQUENCY: f32 = 150.; // this is really, really dry, thi
#[derive(Debug, PartialEq, Serialize)]
pub enum MoistureSensorError {
NoMessage,
MissingMessage,
NotExpectedMessage { hz: f32 },
ShortCircuit { hz: f32, max: f32 },
OpenLoop { hz: f32, min: f32 },
}
#[derive(Debug, PartialEq, Serialize)]
pub enum MoistureSensorState {
MoistureValue { raw_hz: f32, moisture_percent: f32 },
MoistureValue { hz: f32, moisture_percent: f32 },
NoMessage,
SensorError(MoistureSensorError),
}
@@ -30,7 +32,7 @@ impl MoistureSensorState {
pub fn moisture_percent(&self) -> Option<f32> {
if let MoistureSensorState::MoistureValue {
raw_hz: _,
hz: _,
moisture_percent,
} = self
{
@@ -82,6 +84,11 @@ pub struct PlantState {
pub sensor_a: MoistureSensorState,
pub sensor_b: MoistureSensorState,
pub pump: PumpState,
/// Last known firmware build timestamp for sensor A (minutes since Unix epoch).
/// Set during sensor detection; None if detection has not been run yet.
pub sensor_a_firmware_build_minutes: Option<u32>,
/// Last known firmware build timestamp for sensor B.
pub sensor_b_firmware_build_minutes: Option<u32>,
}
fn map_range_moisture(
@@ -111,67 +118,51 @@ fn map_range_moisture(
}
impl PlantState {
pub async fn read_hardware_state(
pub async fn interpret_raw_values(
moistures: Moistures,
plant_id: usize,
board: &mut HAL<'_>,
) -> Self {
let sensor_a = {
//if board.board_hal.get_config().plants[plant_id].sensor_a {
let raw = moistures.sensor_a_hz[plant_id];
match raw {
None => MoistureSensorState::SensorError(MoistureSensorError::NoMessage),
Some(raw) => {
match map_range_moisture(
raw,
board.board_hal.get_config().plants[plant_id]
.moisture_sensor_min_frequency
.map(|a| a as f32),
board.board_hal.get_config().plants[plant_id]
.moisture_sensor_max_frequency
.map(|b| b as f32),
) {
Ok(moisture_percent) => MoistureSensorState::MoistureValue {
raw_hz: raw,
moisture_percent,
},
Err(err) => MoistureSensorState::SensorError(err),
}
}
}
}; // else {
// MoistureSensorState::Disabled
//};
let min = board.board_hal.get_config().plants[plant_id].moisture_sensor_min_frequency;
let max = board.board_hal.get_config().plants[plant_id].moisture_sensor_max_frequency;
let sensor_b = {
//if board.board_hal.get_config().plants[plant_id].sensor_b {
let raw = moistures.sensor_b_hz[plant_id];
let raw_to_value = |raw: Option<f32>, expected: bool| -> MoistureSensorState {
match raw {
None => MoistureSensorState::SensorError(MoistureSensorError::NoMessage),
None => {
if expected {
MoistureSensorState::SensorError(MoistureSensorError::MissingMessage)
} else {
MoistureSensorState::NoMessage
}
}
Some(raw) => {
match map_range_moisture(
raw,
board.board_hal.get_config().plants[plant_id]
.moisture_sensor_min_frequency
.map(|a| a as f32),
board.board_hal.get_config().plants[plant_id]
.moisture_sensor_max_frequency
.map(|b| b as f32),
) {
Ok(moisture_percent) => MoistureSensorState::MoistureValue {
raw_hz: raw,
moisture_percent,
},
Err(err) => MoistureSensorState::SensorError(err),
if expected {
match map_range_moisture(raw, min.map(|a| a as f32), max.map(|b| b as f32))
{
Ok(moisture_percent) => MoistureSensorState::MoistureValue {
hz: raw,
moisture_percent,
},
Err(err) => MoistureSensorState::SensorError(err),
}
} else {
MoistureSensorState::SensorError(MoistureSensorError::NotExpectedMessage {
hz: raw,
})
}
}
}
}; // else {
// MoistureSensorState::Disabled
//};
};
let expected_a = board.board_hal.get_config().plants[plant_id].sensor_a;
let expected_b = board.board_hal.get_config().plants[plant_id].sensor_b;
let sensor_a = { raw_to_value(moistures.sensor_a_hz[plant_id], expected_a) };
let sensor_b = { raw_to_value(moistures.sensor_b_hz[plant_id], expected_b) };
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 (a_builds, b_builds) = board.board_hal.get_sensor_build_minutes();
let state = Self {
sensor_a,
sensor_b,
@@ -179,6 +170,8 @@ impl PlantState {
consecutive_pump_count,
previous_pump,
},
sensor_a_firmware_build_minutes: a_builds[plant_id],
sensor_b_firmware_build_minutes: b_builds[plant_id],
};
if state.is_err() {
let _ = board.board_hal.fault(plant_id, true).await;
@@ -199,6 +192,67 @@ impl PlantState {
})
}
/// Returns how many minutes until this plant becomes actionable (could be watered),
/// or `None` if no action is pending within any reasonable horizon.
///
/// Used to dynamically shorten deep-sleep so cooldowns are not prolonged.
pub fn minutes_until_actionable(
&self,
plant_conf: &PlantConfig,
current_time: &DateTime<Tz>,
) -> Option<u32> {
if matches!(plant_conf.mode, PlantWateringMode::Off) {
return None;
}
let mut earliest: Option<u32> = None;
// When does the current cooldown expire?
if self.pump_in_timeout(plant_conf, current_time) {
if let Some(last_pump) = self.pump.previous_pump {
if let Some(expiry) = last_pump
.checked_add_signed(TimeDelta::minutes(plant_conf.pump_cooldown_min.into()))
{
let diff = expiry.signed_duration_since(current_time.with_timezone(&Utc));
let mins = diff.num_minutes().max(0) as u32;
earliest = Some(earliest.map_or(mins, |e| e.min(mins)));
}
}
}
// For moisture-based modes: when does the watering window open, if the plant is dry
// but currently outside its time window (and not in cooldown)?
if matches!(
plant_conf.mode,
PlantWateringMode::TargetMoisture | PlantWateringMode::MinMoisture
) {
let (moisture, _) = self.plant_moisture();
if let Some(moisture) = moisture {
if moisture < plant_conf.target_moisture
&& !self.pump_in_timeout(plant_conf, current_time)
&& !in_time_range(
current_time,
plant_conf.pump_hour_start,
plant_conf.pump_hour_end,
)
{
// in_time_range uses `hour > start`, so the window opens at start+1:00
let cur_hour = current_time.hour() as u32;
let cur_minute = current_time.minute() as u32;
let open_at_hour = (plant_conf.pump_hour_start as u32 + 1) % 24;
let mins = if open_at_hour > cur_hour {
(open_at_hour - cur_hour) * 60 - cur_minute
} else {
(24 - cur_hour + open_at_hour) * 60 - cur_minute
};
earliest = Some(earliest.map_or(mins, |e| e.min(mins)));
}
}
}
earliest
}
pub fn is_err(&self) -> bool {
self.sensor_a.is_err().is_some() || self.sensor_b.is_err().is_some()
}
@@ -301,6 +355,8 @@ impl PlantState {
} else {
None
},
sensor_a_firmware_build_minutes: self.sensor_a_firmware_build_minutes,
sensor_b_firmware_build_minutes: self.sensor_b_firmware_build_minutes,
}
}
}
@@ -329,4 +385,8 @@ pub struct PlantInfo<'a> {
last_pump: Option<DateTime<Tz>>,
/// next time when pump should activate
next_pump: 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>,
}

View File

@@ -158,12 +158,11 @@ 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()
.map(|f| f.tank_sensor_voltage())
{
Ok(raw_sensor_value_mv) => TankState::Present(raw_sensor_value_mv.await.unwrap()),
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())),
}
} else {

View File

@@ -1,8 +1,7 @@
use crate::fat_error::{FatError, FatResult};
use crate::hal::rtc::X25;
use crate::webserver::read_up_to_bytes_from_request;
use crate::BOARD_ACCESS;
use alloc::borrow::ToOwned;
use alloc::format;
use alloc::string::{String, ToString};
use chrono::DateTime;
use edge_http::io::server::Connection;
@@ -21,48 +20,9 @@ pub(crate) async fn get_backup_config<T, const N: usize>(
where
T: Read + Write,
{
// First pass: verify checksum without sending data
let mut checksum = X25.digest();
let mut chunk = 0_usize;
loop {
let mut board = BOARD_ACCESS.get().await.lock().await;
board.board_hal.progress(chunk as u32).await;
let (buf, len, expected_crc) = board
.board_hal
.get_rtc_module()
.get_backup_config(chunk)
.await?;
let mut board = BOARD_ACCESS.get().await.lock().await;
let backup = board.board_hal.read_backup().await?;
// Update checksum with the actual data bytes of this chunk
checksum.update(&buf[..len]);
let is_last = len == 0 || len < buf.len();
if is_last {
let actual_crc = checksum.finalize();
if actual_crc != expected_crc {
BOARD_ACCESS
.get()
.await
.lock()
.await
.board_hal
.clear_progress()
.await;
conn.initiate_response(
409,
Some(
format!("Checksum mismatch expected {expected_crc} got {actual_crc}")
.as_str(),
),
&[],
)
.await?;
return Ok(Some(409));
}
break;
}
chunk += 1;
}
// Second pass: stream data
conn.initiate_response(
200,
@@ -75,35 +35,8 @@ where
)
.await?;
let mut chunk = 0_usize;
loop {
let mut board = BOARD_ACCESS.get().await.lock().await;
board.board_hal.progress(chunk as u32).await;
let (buf, len, _expected_crc) = board
.board_hal
.get_rtc_module()
.get_backup_config(chunk)
.await?;
if len == 0 {
break;
}
conn.write_all(&buf[..len]).await?;
if len < buf.len() {
break;
}
chunk += 1;
}
BOARD_ACCESS
.get()
.await
.lock()
.await
.board_hal
.clear_progress()
.await;
conn.write_all(serde_json::to_string(&backup)?.as_bytes())
.await?;
Ok(Some(200))
}
@@ -113,49 +46,12 @@ pub(crate) async fn backup_config<T, const N: usize>(
where
T: Read + Write,
{
let mut offset = 0_usize;
let mut buf = [0_u8; 32];
let mut checksum = X25.digest();
let mut counter = 0;
loop {
let to_write = conn.read(&mut buf).await?;
if to_write == 0 {
info!("backup finished");
break;
} else {
let mut board = BOARD_ACCESS.get().await.lock().await;
board.board_hal.progress(counter).await;
counter += 1;
board
.board_hal
.get_rtc_module()
.backup_config(offset, &buf[0..to_write])
.await?;
checksum.update(&buf[0..to_write]);
}
offset += to_write;
}
let input = read_up_to_bytes_from_request(conn, Some(4096)).await?;
info!("Read input with length {}", input.len());
let mut board = BOARD_ACCESS.get().await.lock().await;
board
.board_hal
.get_rtc_module()
.backup_config_finalize(checksum.finalize(), offset)
.await?;
board.board_hal.clear_progress().await;
conn.initiate_response(
200,
Some("OK"),
&[
("Access-Control-Allow-Origin", "*"),
("Access-Control-Allow-Headers", "*"),
("Access-Control-Allow-Methods", "*"),
],
)
.await?;
let config_to_backup = serde_json::from_slice(&input)?;
info!("Parsed send config to object");
board.board_hal.backup_config(&config_to_backup).await?;
Ok(Some("saved".to_owned()))
}
@@ -166,10 +62,12 @@ where
T: Read + Write,
{
let mut board = BOARD_ACCESS.get().await.lock().await;
let header = board.board_hal.get_rtc_module().get_backup_info().await;
let json = match header {
let info = board.board_hal.backup_info().await;
let json = match info {
Ok(h) => {
let timestamp = DateTime::from_timestamp_millis(h.timestamp).unwrap();
info!("Got backup info: {:?}", h);
let timestamp = DateTime::from_timestamp_millis(h.timestamp).unwrap_or_default();
let wbh = WebBackupHeader {
timestamp: timestamp.to_rfc3339(),
size: h.size,
@@ -177,6 +75,7 @@ where
serde_json::to_string(&wbh)?
}
Err(err) => {
info!("Error getting backup info: {:?}", err);
let wbh = WebBackupHeader {
timestamp: err.to_string(),
size: 0,

View File

@@ -1,160 +0,0 @@
use crate::fat_error::{FatError, FatResult};
use crate::webserver::read_up_to_bytes_from_request;
use crate::BOARD_ACCESS;
use alloc::borrow::ToOwned;
use alloc::format;
use alloc::string::String;
use edge_http::io::server::Connection;
use edge_http::Method;
use edge_nal::io::{Read, Write};
use log::info;
pub(crate) async fn list_files<T, const N: usize>(
_request: &mut Connection<'_, T, N>,
) -> FatResult<Option<String>> {
let mut board = BOARD_ACCESS.get().await.lock().await;
let result = board.board_hal.get_esp().list_files().await?;
let file_list_json = serde_json::to_string(&result)?;
Ok(Some(file_list_json))
}
pub(crate) async fn file_operations<T, const N: usize>(
conn: &mut Connection<'_, T, { N }>,
method: Method,
path: &&str,
prefix: &&str,
) -> Result<Option<u32>, FatError>
where
T: Read + Write,
{
let filename = &path[prefix.len()..];
info!("file request for {filename} with method {method}");
Ok(match method {
Method::Delete => {
let mut board = BOARD_ACCESS.get().await.lock().await;
board
.board_hal
.get_esp()
.delete_file(filename.to_owned())
.await?;
conn.initiate_response(
200,
Some("OK"),
&[
("Access-Control-Allow-Origin", "*"),
("Access-Control-Allow-Headers", "*"),
("Access-Control-Allow-Methods", "*"),
],
)
.await?;
Some(200)
}
Method::Get => {
let disposition = format!("attachment; filename=\"{filename}\"");
let size = {
let mut board = BOARD_ACCESS.get().await.lock().await;
board
.board_hal
.get_esp()
.get_size(filename.to_owned())
.await?
};
conn.initiate_response(
200,
Some("OK"),
&[
("Content-Type", "application/octet-stream"),
("Content-Disposition", disposition.as_str()),
("Content-Length", &format!("{size}")),
("Access-Control-Allow-Origin", "*"),
("Access-Control-Allow-Headers", "*"),
("Access-Control-Allow-Methods", "*"),
],
)
.await?;
let mut chunk = 0;
loop {
let mut board = BOARD_ACCESS.get().await.lock().await;
board.board_hal.progress(chunk).await;
let read_chunk = board
.board_hal
.get_esp()
.get_file(filename.to_owned(), chunk)
.await?;
let length = read_chunk.1;
if length == 0 {
info!("file request for {filename} finished");
break;
}
let data = &read_chunk.0[0..length];
conn.write_all(data).await?;
if length < read_chunk.0.len() {
info!("file request for {filename} finished");
break;
}
chunk += 1;
}
BOARD_ACCESS
.get()
.await
.lock()
.await
.board_hal
.clear_progress()
.await;
Some(200)
}
Method::Post => {
{
let mut board = BOARD_ACCESS.get().await.lock().await;
//ensure the file is deleted first; otherwise we would need to truncate the file which will not work with streaming
let _ = board
.board_hal
.get_esp()
.delete_file(filename.to_owned())
.await;
}
let mut offset = 0_usize;
let mut chunk = 0;
loop {
let buf = read_up_to_bytes_from_request(conn, Some(4096)).await?;
if buf.is_empty() {
info!("file request for {filename} finished");
break;
} else {
let mut board = BOARD_ACCESS.get().await.lock().await;
board.board_hal.progress(chunk as u32).await;
board
.board_hal
.get_esp()
.write_file(filename.to_owned(), offset as u32, &buf)
.await?;
}
offset += buf.len();
chunk += 1;
}
BOARD_ACCESS
.get()
.await
.lock()
.await
.board_hal
.clear_progress()
.await;
conn.initiate_response(
200,
Some("OK"),
&[
("Access-Control-Allow-Origin", "*"),
("Access-Control-Allow-Headers", "*"),
("Access-Control-Allow-Methods", "*"),
],
)
.await?;
Some(200)
}
_ => None,
})
}

View File

@@ -1,11 +1,11 @@
use crate::fat_error::{FatError, FatResult};
use crate::hal::{esp_time, PLANT_COUNT};
use crate::hal::PLANT_COUNT;
use crate::log::LogMessage;
use crate::plant_state::{MoistureSensorState, PlantState};
use crate::tank::determine_tank_state;
use crate::{get_version, BOARD_ACCESS};
use alloc::format;
use alloc::string::String;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use chrono_tz::Tz;
use core::str::FromStr;
@@ -40,25 +40,27 @@ where
let moistures = board.board_hal.measure_moisture_hz().await?;
let mut plant_state = Vec::new();
for i in 0..PLANT_COUNT {
plant_state.push(PlantState::read_hardware_state(moistures, i, &mut board).await);
plant_state.push(PlantState::interpret_raw_values(moistures, i, &mut board).await);
}
let a = Vec::from_iter(plant_state.iter().map(|s| match &s.sensor_a {
MoistureSensorState::MoistureValue {
raw_hz,
hz: raw_hz,
moisture_percent,
} => {
format!("{moisture_percent:.2}% {raw_hz}hz",)
}
MoistureSensorState::SensorError(err) => format!("{err:?}"),
MoistureSensorState::NoMessage => "No Message".to_string(),
}));
let b = Vec::from_iter(plant_state.iter().map(|s| match &s.sensor_b {
MoistureSensorState::MoistureValue {
raw_hz,
hz: raw_hz,
moisture_percent,
} => {
format!("{moisture_percent:.2}% {raw_hz}hz",)
}
MoistureSensorState::SensorError(err) => format!("{err:?}"),
MoistureSensorState::NoMessage => "No Message".to_string(),
}));
let data = Moistures {
@@ -107,19 +109,44 @@ pub(crate) async fn get_solar_state<T, const N: usize>(
Ok(Some(serde_json::to_string(&state)?))
}
pub(crate) async fn get_version_web<T, const N: usize>(
pub(crate) async fn get_firmware_info_web<T, const N: usize>(
_request: &mut Connection<'_, T, N>,
) -> FatResult<Option<String>> {
let mut board = BOARD_ACCESS.get().await.lock().await;
Ok(Some(serde_json::to_string(&get_version(&mut board).await)?))
}
/// Return the current in-memory config, or — when `saveidx` is `Some(idx)` —
/// the JSON stored in save slot `idx`.
pub(crate) async fn get_config<T, const N: usize>(
_request: &mut Connection<'_, T, N>,
saveidx: Option<usize>,
) -> FatResult<Option<String>> {
let mut board = BOARD_ACCESS.get().await.lock().await;
let json = match saveidx {
None => serde_json::to_string(board.board_hal.get_config())?,
Some(idx) => board.board_hal.get_esp().load_config_slot(idx).await?,
};
Ok(Some(json))
}
/// Return a JSON array describing every valid save slot on flash.
pub(crate) async fn list_saves<T, const N: usize>(
_request: &mut Connection<'_, T, N>,
) -> FatResult<Option<String>> {
let mut board = BOARD_ACCESS.get().await.lock().await;
let json = serde_json::to_string(&board.board_hal.get_config())?;
Ok(Some(json))
let saves = board.board_hal.get_esp().list_saves().await?;
Ok(Some(serde_json::to_string(&saves)?))
}
/// Erase (delete) a single save slot by index.
pub(crate) async fn delete_save<T, const N: usize>(
_request: &mut Connection<'_, T, N>,
idx: usize,
) -> FatResult<Option<String>> {
let mut board = BOARD_ACCESS.get().await.lock().await;
board.board_hal.get_esp().delete_save_slot(idx).await?;
Ok(None)
}
pub(crate) async fn get_battery_state<T, const N: usize>(
@@ -147,7 +174,12 @@ pub(crate) async fn get_time<T, const N: usize>(
},
};
let native = esp_time().await.with_timezone(&tz).to_rfc3339();
let native = board
.board_hal
.get_time()
.await
.with_timezone(&tz)
.to_rfc3339();
let rtc = match board.board_hal.get_rtc_module().get_rtc_time().await {
Ok(time) => time.with_timezone(&tz).to_rfc3339(),

View File

@@ -1,7 +1,10 @@
use crate::fat_error::FatResult;
use crate::log::LOG_ACCESS;
use alloc::string::String;
use alloc::vec::Vec;
use edge_http::io::server::Connection;
use edge_nal::io::{Read, Write};
use serde::Serialize;
pub(crate) async fn get_log<T, const N: usize>(
conn: &mut Connection<'_, T, N>,
@@ -34,3 +37,29 @@ where
conn.write_all("]".as_bytes()).await?;
Ok(Some(200))
}
#[derive(Serialize)]
struct LiveLogEntry {
seq: u64,
text: String,
}
#[derive(Serialize)]
struct LiveLogResponse {
entries: Vec<LiveLogEntry>,
dropped: bool,
next_seq: u64,
}
pub(crate) async fn get_live_log(after: Option<u64>) -> FatResult<Option<String>> {
let (raw_entries, dropped, next_seq) = crate::log::INTERCEPTOR.get_live_logs(after);
let response = LiveLogResponse {
entries: raw_entries
.into_iter()
.map(|(seq, text)| LiveLogEntry { seq, text })
.collect(),
dropped,
next_seq,
};
Ok(Some(serde_json::to_string(&response)?))
}

View File

@@ -1,7 +1,6 @@
//offer ota and config mode
mod backup_manager;
mod file_manager;
mod get_json;
mod get_log;
mod get_static;
@@ -10,12 +9,11 @@ mod post_json;
use crate::fat_error::{FatError, FatResult};
use crate::webserver::backup_manager::{backup_config, backup_info, get_backup_config};
use crate::webserver::file_manager::{file_operations, list_files};
use crate::webserver::get_json::{
get_battery_state, get_config, get_live_moisture, get_log_localization_config, get_solar_state,
get_time, get_timezones, get_version_web, tank_info,
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,
};
use crate::webserver::get_log::get_log;
use crate::webserver::get_log::{get_live_log, get_log};
use crate::webserver::get_static::{serve_bundle, serve_favicon, serve_index};
use crate::webserver::ota::ota_operations;
use crate::webserver::post_json::{
@@ -60,10 +58,7 @@ impl Handler for HTTPRequestRouter {
let method = headers.method;
let path = headers.path;
let prefix = "/file?filename=";
let status = if path.starts_with(prefix) {
file_operations(conn, method, &path, &prefix).await?
} else if path == "/ota" {
let status = if path == "/ota" {
ota_operations(conn, method).await.map_err(|e| {
error!("Error handling ota: {e}");
e
@@ -78,17 +73,32 @@ impl Handler for HTTPRequestRouter {
"/get_backup_config" => get_backup_config(conn).await?,
&_ => {
let json = match path {
"/version" => Some(get_version_web(conn).await),
"/firmware_info" => Some(get_firmware_info_web(conn).await),
"/time" => Some(get_time(conn).await),
"/battery" => Some(get_battery_state(conn).await),
"/solar" => Some(get_solar_state(conn).await),
"/get_config" => Some(get_config(conn).await),
"/files" => Some(list_files(conn).await),
"/log_localization" => Some(get_log_localization_config(conn).await),
"/tank" => Some(tank_info(conn).await),
"/backup_info" => Some(backup_info(conn).await),
"/timezones" => Some(get_timezones().await),
"/moisture" => Some(get_live_moisture(conn).await),
"/list_saves" => Some(list_saves(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
.find("after=")
.and_then(|pos| p[pos + 6..].split('&').next())
.and_then(|s| s.parse().ok());
Some(get_live_log(after).await)
}
// /get_config accepts an optional ?saveidx=N query parameter
p if p == "/get_config" || p.starts_with("/get_config?") => {
let saveidx: Option<usize> = p
.find("saveidx=")
.and_then(|pos| p[pos + 8..].split('&').next())
.and_then(|s| s.parse().ok());
Some(get_config(conn, saveidx).await)
}
_ => None,
};
match json {
@@ -127,7 +137,28 @@ impl Handler for HTTPRequestRouter {
Some(json) => Some(handle_json(conn, json).await?),
}
}
Method::Options | Method::Delete | Method::Head | Method::Put => None,
Method::Delete => {
// DELETE /delete_save?idx=N
let json = if path == "/delete_save" || path.starts_with("/delete_save?") {
let idx: Option<usize> = path
.find("idx=")
.and_then(|pos| path[pos + 4..].split('&').next())
.and_then(|s| s.parse().ok());
match idx {
Some(idx) => Some(delete_save(conn, idx).await),
None => Some(Err(FatError::String {
error: "missing idx parameter".into(),
})),
}
} else {
None
};
match json {
None => None,
Some(json) => Some(handle_json(conn, json).await?),
}
}
Method::Options | Method::Head | Method::Put => None,
_ => None,
}
};
@@ -181,6 +212,7 @@ where
}
#[embassy_executor::task]
#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)]
pub async fn http_server(reboot_now: Arc<AtomicBool>, stack: Stack<'static>) {
let buffer: TcpBuffers<2, 1024, 1024> = TcpBuffers::new();
let tcp = Tcp::new(stack, &buffer);
@@ -241,6 +273,7 @@ where
Err(err) => {
let error_text = err.to_string();
info!("error handling process {error_text}");
conn.initiate_response(
500,
Some("OK"),

View File

@@ -1,13 +1,15 @@
use crate::config::PlantControllerConfig;
use crate::fat_error::FatResult;
use crate::hal::{esp_set_time, Detection};
use crate::hal::DetectionRequest;
use crate::webserver::read_up_to_bytes_from_request;
use crate::{do_secure_pump, BOARD_ACCESS};
use alloc::borrow::ToOwned;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use chrono::DateTime;
use edge_http::io::server::Connection;
use edge_nal::io::{Read, Write};
use esp_radio::wifi::ap::AccessPointInfo;
use log::info;
use serde::{Deserialize, Serialize};
@@ -40,10 +42,10 @@ pub(crate) async fn wifi_scan<T, const N: usize>(
let mut board = BOARD_ACCESS.get().await.lock().await;
info!("start wifi scan");
let mut ssids: Vec<String> = Vec::new();
let scan_result = board.board_hal.get_esp().wifi_scan().await?;
let scan_result: Vec<AccessPointInfo> = board.board_hal.get_esp().wifi_scan().await?;
scan_result
.iter()
.for_each(|s| ssids.push(s.ssid.to_string()));
.for_each(|s| ssids.push(s.ssid.as_str().to_owned()));
let ssid_json = serde_json::to_string(&SSIDList { ssids })?;
info!("Sending ssid list {}", &ssid_json);
Ok(Some(ssid_json))
@@ -62,7 +64,7 @@ where
T: Read + Write,
{
let actual_data = read_up_to_bytes_from_request(request, None).await?;
let detect: Detection = serde_json::from_slice(&actual_data)?;
let detect: DetectionRequest = serde_json::from_slice(&actual_data)?;
let mut board = BOARD_ACCESS.get().await.lock().await;
let result = board.board_hal.detect_sensors(detect).await?;
let json = serde_json::to_string(&result)?;
@@ -108,8 +110,9 @@ where
{
let actual_data = read_up_to_bytes_from_request(request, None).await?;
let time: SetTime = serde_json::from_slice(&actual_data)?;
let parsed = DateTime::parse_from_rfc3339(time.time).unwrap();
esp_set_time(parsed).await?;
let parsed = DateTime::parse_from_rfc3339(time.time)?;
let mut board = BOARD_ACCESS.get().await.lock().await;
board.board_hal.set_time(&parsed).await?;
Ok(None)
}

View File

@@ -1,6 +1,17 @@
export interface LogArray extends Array<LogEntry> {
}
export interface LiveLogEntry {
seq: number,
text: string,
}
export interface LiveLogResponse {
entries: LiveLogEntry[],
dropped: boolean,
next_seq: number,
}
export interface LogEntry {
timestamp: string,
message_id: number,
@@ -34,6 +45,12 @@ export interface NetworkConfig {
max_wait: number
}
export interface SaveInfo {
idx: number,
len: number,
created_at: string | null,
}
export interface FileList {
total: number,
used: number,
@@ -92,6 +109,7 @@ export enum BoardVersion {
export interface BoardHardware {
board: BoardVersion,
battery: BatteryBoardVersion,
pump_corrosion_protection: boolean,
}
export interface PlantControllerConfig {
@@ -165,6 +183,9 @@ export interface VersionInfo {
current: string,
slot0_state: string,
slot1_state: string,
heap_total: number,
heap_used: number,
heap_max_used: number,
}
export interface BatteryState {
@@ -177,9 +198,22 @@ export interface BatteryState {
state_of_health: string
}
export interface DetectionPlant {
/// Request: which sensors to send IDENTIFY_CMD to.
export interface SensorRequest {
sensor_a: boolean,
sensor_b: boolean
sensor_b: boolean,
}
export interface DetectionRequest {
plant: SensorRequest[]
}
/// Response: detection result per plant.
/// sensor_a / sensor_b: firmware build timestamp in minutes since Unix epoch,
/// or null if the sensor did not respond.
export interface DetectionPlant {
sensor_a: number | null,
sensor_b: number | null,
}
export interface Detection {

View File

@@ -29,44 +29,10 @@
}
</style>
<div class="subtitle">Files:</div>
<div class="flexcontainer">
<div class="filekey">Total Size</div>
<div id="filetotalsize" class="filevalue"></div>
</div>
<div class="flexcontainer">
<div class="filekey">Used Size</div>
<div id="fileusedsize" class="filevalue"></div>
</div>
<div class="flexcontainer">
<div class="filekey">Free Size</div>
<div id="filefreesize" class="filevalue"></div>
</div>
<br>
<div class="flexcontainer" style="border-left-style: double; border-right-style: double; border-top-style: double;">
<div class="subtitle" >Upload:</div>
</div>
<div class="flexcontainer" style="border-left-style: double; border-right-style: double;">
<div class="flexcontainer">
<div class="filekey">
File:
</div>
<input id="fileuploadfile" class="filevalue" type="file">
</div>
<div class="flexcontainer">
<div class="filekey">
Name:
</div>
<input id="fileuploadname" class="filevalue" type="text">
</div>
</div>
<div class="flexcontainer" style="border-left-style: double; border-right-style: double; border-bottom-style: double;">
<button id="fileuploadbtn" class="subtitle">Upload</button>
</div>
<div class="subtitle">Save Slots:</div>
<br>
<div class="flexcontainer" style="border-left-style: double; border-right-style: double; border-top-style: double;">
<div class="subtitle">List:</div>
</div>
<div id="fileList" class="flexcontainer" style="border-left-style: double; border-right-style: double; border-bottom-style: double;">
</div>
</div>

View File

@@ -1,96 +1,62 @@
import {Controller} from "./main";
import {FileInfo, FileList} from "./api";
const regex = /[^a-zA-Z0-9_.]/g;
function sanitize(str:string){
return str.replaceAll(regex, '_')
}
import {SaveInfo} from "./api";
export class FileView {
readonly fileListView: HTMLElement;
readonly controller: Controller;
readonly filefreesize: HTMLElement;
readonly filetotalsize: HTMLElement;
readonly fileusedsize: HTMLElement;
constructor(controller: Controller) {
(document.getElementById("fileview") as HTMLElement).innerHTML = require('./fileview.html') as string;
this.fileListView = document.getElementById("fileList") as HTMLElement
this.filefreesize = document.getElementById("filefreesize") as HTMLElement
this.filetotalsize = document.getElementById("filetotalsize") as HTMLElement
this.fileusedsize = document.getElementById("fileusedsize") as HTMLElement
let fileuploadfile = document.getElementById("fileuploadfile") as HTMLInputElement
let fileuploadname = document.getElementById("fileuploadname") as HTMLInputElement
let fileuploadbtn = document.getElementById("fileuploadbtn") as HTMLInputElement
fileuploadfile.onchange = () => {
const selectedFile = fileuploadfile.files?.[0];
if (selectedFile == null) {
//TODO error dialog here
return
}
fileuploadname.value = sanitize(selectedFile.name)
};
fileuploadname.onchange = () => {
let input = fileuploadname.value
let clean = sanitize(fileuploadname.value)
if (input != clean){
fileuploadname.value = clean
}
}
fileuploadbtn.onclick = () => {
const selectedFile = fileuploadfile.files?.[0];
if (selectedFile == null) {
//TODO error dialog here
return
}
controller.uploadFile(selectedFile, selectedFile.name)
}
this.fileListView = document.getElementById("fileList") as HTMLElement;
this.controller = controller;
}
setFileList(fileList: FileList, public_url: string) {
this.filetotalsize.innerText = Math.floor(fileList.total / 1024) + "kB"
this.fileusedsize.innerText = Math.ceil(fileList.used / 1024) + "kB"
this.filefreesize.innerText = Math.ceil((fileList.total - fileList.used) / 1024) + "kB"
setSaveList(saves: SaveInfo[], public_url: string) {
// Sort newest first (highest index = most recently written slot)
const sorted = saves.slice().sort((a, b) => b.idx - a.idx);
//fast clear
this.fileListView.textContent = ""
for (let i = 0; i < fileList.files.length; i++) {
let file = fileList.files[i]
new FileEntry(this.controller, i, file, this.fileListView, public_url);
this.fileListView.textContent = "";
for (let i = 0; i < sorted.length; i++) {
new SaveEntry(this.controller, i, sorted[i], this.fileListView, public_url);
}
}
}
class FileEntry {
class SaveEntry {
view: HTMLElement;
constructor(controller: Controller, fileid: number, fileinfo: FileInfo, parent: HTMLElement, public_url: string) {
this.view = document.createElement("div") as HTMLElement
parent.appendChild(this.view)
this.view.classList.add("fileentryouter")
constructor(controller: Controller, fileid: number, saveinfo: SaveInfo, parent: HTMLElement, public_url: string) {
this.view = document.createElement("div") as HTMLElement;
parent.appendChild(this.view);
this.view.classList.add("fileentryouter");
const template = require('./fileviewentry.html') as string;
this.view.innerHTML = template.replaceAll("${fileid}", String(fileid))
this.view.innerHTML = template.replaceAll("${fileid}", String(fileid));
let name = document.getElementById("file_" + fileid + "_name") as HTMLElement;
let created = document.getElementById("file_" + fileid + "_created") as HTMLElement;
let size = document.getElementById("file_" + fileid + "_size") as HTMLElement;
let deleteBtn = document.getElementById("file_" + fileid + "_delete") as HTMLButtonElement;
deleteBtn.onclick = () => {
controller.deleteFile(fileinfo.filename);
}
controller.deleteSlot(saveinfo.idx);
};
let downloadBtn = document.getElementById("file_" + fileid + "_download") as HTMLAnchorElement;
downloadBtn.href = public_url + "/file?filename=" + fileinfo.filename
downloadBtn.download = fileinfo.filename
downloadBtn.href = public_url + "/get_config?saveidx=" + saveinfo.idx;
downloadBtn.download = "config_slot_" + saveinfo.idx + ".json";
name.innerText = fileinfo.filename;
size.innerText = fileinfo.size.toString()
name.innerText = "Slot " + saveinfo.idx;
size.innerText = saveinfo.len + " bytes";
// Format timestamp in browser's local timezone
if (saveinfo.created_at) {
try {
const date = new Date(saveinfo.created_at);
created.innerText = date.toLocaleString();
} catch (e) {
created.innerText = "Invalid date";
}
} else {
created.innerText = "Unknown";
}
}
}
}

View File

@@ -1,11 +1,17 @@
<div class="flexcontainer">
<div id="file_${fileid}_name" class="filetitle">Name</div>
<div id="file_${fileid}_name" class="filetitle">Slot</div>
</div>
<div class="flexcontainer">
<div class="filekey">Created</div>
<div id="file_${fileid}_created" class="filevalue"></div>
</div>
<div class="flexcontainer">
<div class="filekey">Size</div>
<div id = "file_${fileid}_size" class="filevalue"></div>
<a id = "file_${fileid}_download" class="filevalue" target="_blank">Download</a>
<button id = "file_${fileid}_delete" class="filevalue">Delete</button>
<div id="file_${fileid}_size" class="filevalue"></div>
<a id="file_${fileid}_download" class="filevalue" target="_blank">Download</a>
<button id="file_${fileid}_delete" class="filevalue">Delete</button>
</div>

View File

@@ -10,11 +10,15 @@
<div class="subtitle">Hardware:</div>
<div class="flexcontainer">
<div class="boardkey">BoardRevision</div>
<select class="boardvalue" id="hardware_board_value">
<select class="boardvalue hidden-mode-advanced" id="hardware_board_value">
</select>
</div>
<div class="flexcontainer">
<div class="flexcontainer hidden-mode-advanced">
<div class="boardkey">BatteryMonitor</div>
<select class="boardvalue" id="hardware_battery_value">
</select>
</div>
<div class="flexcontainer">
<div class="boardkey">Pump corrosion protection (weekly)</div>
<input type="checkbox" id="hardware_pump_corrosion_protection">
</div>

View File

@@ -4,6 +4,7 @@ import {BatteryBoardVersion, BoardHardware, BoardVersion} from "./api";
export class HardwareConfigView {
private readonly hardware_board_value: HTMLSelectElement;
private readonly hardware_battery_value: HTMLSelectElement;
private readonly hardware_pump_corrosion_protection: HTMLInputElement;
constructor(controller:Controller){
(document.getElementById("hardwareview") as HTMLElement).innerHTML = require('./hardware.html') as string;
@@ -29,17 +30,22 @@ export class HardwareConfigView {
option.innerText = version.toString();
this.hardware_battery_value.appendChild(option);
})
this.hardware_pump_corrosion_protection = document.getElementById("hardware_pump_corrosion_protection") as HTMLInputElement;
this.hardware_pump_corrosion_protection.onchange = controller.configChanged
}
setConfig(hardware: BoardHardware) {
this.hardware_board_value.value = hardware.board.toString()
this.hardware_battery_value.value = hardware.battery.toString()
this.hardware_pump_corrosion_protection.checked = hardware.pump_corrosion_protection
}
getConfig(): BoardHardware {
return {
board : BoardVersion[this.hardware_board_value.value as keyof typeof BoardVersion],
battery : BatteryBoardVersion[this.hardware_battery_value.value as keyof typeof BatteryBoardVersion],
pump_corrosion_protection : this.hardware_pump_corrosion_protection.checked,
}
}
}

View File

@@ -1,7 +1,48 @@
<style>
#livelogpanel {
font-family: monospace;
font-size: 0.85em;
background: #1a1a1a;
color: #d4d4d4;
padding: 8px;
max-height: 300px;
overflow-y: auto;
border: 1px solid #444;
white-space: pre-wrap;
word-break: break-all;
}
.livelog-dropped {
color: #f0a500;
font-style: italic;
}
.log-accordion-header {
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 6px;
}
.log-accordion-header::before {
content: "▶";
font-size: 0.75em;
transition: transform 0.15s;
display: inline-block;
}
.log-accordion-header.open::before {
transform: rotate(90deg);
}
#logpanel {
display: none;
}
</style>
<button id="loadLog">Load Logs</button>
<div id="logpanel">
</div>
<h4 id="logAccordionHeader" class="log-accordion-header">Application Log</h4>
<div id="logpanel"></div>
<h4>Live Log</h4>
<div id="livelogpanel"></div>

View File

@@ -1,19 +1,38 @@
import { Controller } from "./main";
import {LogArray, LogLocalisation} from "./api";
import {LiveLogResponse, LogArray, LogLocalisation} from "./api";
const LIVE_LOG_POLL_INTERVAL_MS = 2000;
export class LogView {
private readonly logpanel: HTMLElement;
private readonly loadLog: HTMLButtonElement;
private readonly livelogpanel: HTMLElement;
private readonly accordionHeader: HTMLElement;
loglocale: LogLocalisation | undefined;
private liveLogNextSeq: number | undefined = undefined;
private liveLogTimer: ReturnType<typeof setTimeout> | undefined = undefined;
private structuredLogLoaded = false;
constructor(controller: Controller) {
(document.getElementById("logview") as HTMLElement).innerHTML = require('./log.html') as string;
this.logpanel = document.getElementById("logpanel") as HTMLElement
this.loadLog = document.getElementById("loadLog") as HTMLButtonElement
this.logpanel = document.getElementById("logpanel") as HTMLElement;
this.livelogpanel = document.getElementById("livelogpanel") as HTMLElement;
this.accordionHeader = document.getElementById("logAccordionHeader") as HTMLElement;
this.loadLog.onclick = () => {
controller.loadLog();
}
this.accordionHeader.onclick = () => {
const isOpen = this.logpanel.style.display !== "none";
if (isOpen) {
this.logpanel.style.display = "none";
this.accordionHeader.classList.remove("open");
} else {
this.logpanel.style.display = "";
this.accordionHeader.classList.add("open");
if (!this.structuredLogLoaded) {
this.structuredLogLoaded = true;
controller.loadLog();
}
}
};
}
setLogLocalisation(loglocale: LogLocalisation) {
@@ -21,10 +40,10 @@ export class LogView {
}
setLog(logs: LogArray) {
this.logpanel.textContent = ""
this.logpanel.textContent = "";
logs.forEach(entry => {
let message = this.loglocale!![entry.message_id];
let template = message.message
let template = message.message;
template = template.replace("${number_a}", entry.a.toString());
template = template.replace("${number_b}", entry.b.toString());
template = template.replace("${txt_short}", entry.txt_short.toString());
@@ -32,15 +51,67 @@ export class LogView {
let ts = new Date(entry.timestamp);
let div = document.createElement("div")
let timestampDiv = document.createElement("div")
let messageDiv = document.createElement("div")
let div = document.createElement("div");
let timestampDiv = document.createElement("div");
let messageDiv = document.createElement("div");
timestampDiv.innerText = ts.toISOString();
messageDiv.innerText = template;
div.appendChild(timestampDiv)
div.appendChild(messageDiv)
this.logpanel.appendChild(div)
}
)
div.appendChild(timestampDiv);
div.appendChild(messageDiv);
this.logpanel.appendChild(div);
});
}
}
startLivePoll(publicUrl: string) {
if (this.liveLogTimer !== undefined) {
return;
}
const poll = async () => {
try {
const url = this.liveLogNextSeq !== undefined
? `${publicUrl}/live_log?after=${this.liveLogNextSeq}`
: `${publicUrl}/live_log`;
const response = await fetch(url);
const data = await response.json() as LiveLogResponse;
this.appendLiveLog(data);
} catch (_e) {
// network error — silently ignore, will retry next interval
}
this.liveLogTimer = setTimeout(poll, LIVE_LOG_POLL_INTERVAL_MS);
};
// Kick off immediately
this.liveLogTimer = setTimeout(poll, 0);
}
stopLivePoll() {
if (this.liveLogTimer !== undefined) {
clearTimeout(this.liveLogTimer);
this.liveLogTimer = undefined;
}
}
private appendLiveLog(data: LiveLogResponse) {
const panel = this.livelogpanel;
const wasAtBottom = panel.scrollHeight - panel.scrollTop <= panel.clientHeight + 4;
if (data.dropped) {
const marker = document.createElement("div");
marker.className = "livelog-dropped";
marker.textContent = "[..]";
panel.appendChild(marker);
}
for (const entry of data.entries) {
const line = document.createElement("div");
line.textContent = entry.text;
panel.appendChild(line);
}
this.liveLogNextSeq = data.next_seq;
// Auto-scroll to bottom only if user was already at the bottom
if (wasAtBottom) {
panel.scrollTop = panel.scrollHeight;
}
}
}

View File

@@ -137,11 +137,157 @@
font-weight: bold;
}
.mode-slider-container {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 8px 0;
flex-wrap: wrap;
}
.mode-label {
font-weight: bold;
font-size: 0.85em;
min-width: 55px;
text-align: center;
}
.mode-slider {
-webkit-appearance: none;
appearance: none;
width: 200px;
height: 8px;
border-radius: 4px;
outline: none;
cursor: pointer;
}
.mode-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
cursor: pointer;
border: 2px solid white;
}
.mode-slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
cursor: pointer;
border: 2px solid white;
}
.mode-simple .mode-slider {
background: linear-gradient(to right, #28a745, #28a745);
}
.mode-simple .mode-slider::-webkit-slider-thumb {
background: #28a745;
}
.mode-simple .mode-slider::-moz-range-thumb {
background: #28a745;
}
.mode-advanced .mode-slider {
background: linear-gradient(to right, #28a745, #ffc107);
}
.mode-advanced .mode-slider::-webkit-slider-thumb {
background: #ffc107;
}
.mode-advanced .mode-slider::-moz-range-thumb {
background: #ffc107;
}
.mode-expert .mode-slider {
background: linear-gradient(to right, #28a745, #ffc107, #dc3545);
}
.mode-expert .mode-slider::-webkit-slider-thumb {
background: #dc3545;
}
.mode-expert .mode-slider::-moz-range-thumb {
background: #dc3545;
}
.mode-simple .mode-label {
color: #28a745;
}
.mode-advanced .mode-label {
color: #ffc107;
}
.mode-expert .mode-label {
color: #dc3545;
}
.mode-label-mode {
font-size: 0.9em;
font-weight: bold;
min-width: 60px;
}
.mode-simple .mode-label-mode {
color: #28a745;
}
.mode-advanced .mode-label-mode {
color: #ffc107;
}
.mode-expert .mode-label-mode {
color: #dc3545;
}
.mode-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: 4px;
vertical-align: middle;
}
.mode-simple .mode-dot {
background: #28a745;
}
.mode-advanced .mode-dot {
background: #ffc107;
}
.mode-expert .mode-dot {
background: #dc3545;
}
.hidden-mode {
display: none !important;
}
.mode-simple .hidden-mode-simple,
.mode-advanced .hidden-mode-simple,
.mode-expert .hidden-mode-simple {
display: none !important;
}
.mode-advanced .hidden-mode-advanced,
.mode-expert .hidden-mode-advanced {
display: none !important;
}
.mode-expert .hidden-mode-expert {
display: none !important;
}
</style>
<div class="container-xl">
<div class="mode-slider-container mode-simple" id="modeSliderContainer">
<span class="mode-dot"></span>
<span class="mode-label">Simple</span>
<input type="range" class="mode-slider" id="configModeSlider" min="0" max="2" value="0" step="1">
<span class="mode-label">Expert</span>
<span class="mode-label-mode" id="modeLabel">Simple</span>
</div>
<div style="display:flex; flex-wrap: wrap;">
<div id="hardwareview" class="subcontainer"></div>
</div>

View File

@@ -29,7 +29,7 @@ import {
SetTime, SSIDList, TankInfo,
TestPump,
VersionInfo,
FileList, SolarState, PumpTestResult, Detection, CanPower
SaveInfo, SolarState, PumpTestResult, Detection, DetectionRequest, CanPower
} from "./api";
import {SolarView} from "./solarview";
import {toast} from "./toast";
@@ -47,28 +47,26 @@ export class Controller {
});
}
loadLogLocaleConfig() {
return fetch(PUBLIC_URL + "/log_localization")
.then(response => response.json())
.then(json => json as LogLocalisation)
.then(loglocale => {
controller.logView.setLogLocalisation(loglocale)
})
.catch(error => {
console.log(error);
});
async loadLogLocaleConfig() {
try {
const response = await fetch(PUBLIC_URL + "/log_localization");
const json = await response.json();
const loglocale = json as LogLocalisation;
controller.logView.setLogLocalisation(loglocale);
} catch (error) {
console.log(error);
}
}
loadLog() {
return fetch(PUBLIC_URL + "/log")
.then(response => response.json())
.then(json => json as LogArray)
.then(logs => {
controller.logView.setLog(logs)
})
.catch(error => {
console.log(error);
});
async loadLog() {
try {
const response = await fetch(PUBLIC_URL + "/log");
const json = await response.json();
const logs = json as LogArray;
controller.logView.setLog(logs);
} catch (error) {
console.log(error);
}
}
async getBackupInfo(): Promise<void> {
@@ -93,65 +91,36 @@ export class Controller {
}
}
async updateFileList(): Promise<void> {
async updateSaveList(): Promise<void> {
try {
const response = await fetch(PUBLIC_URL + "/files");
const response = await fetch(PUBLIC_URL + "/list_saves");
const json = await response.json();
const filelist = json as FileList;
controller.fileview.setFileList(filelist, PUBLIC_URL);
const saves = json as SaveInfo[];
controller.fileview.setSaveList(saves, PUBLIC_URL);
} catch (error) {
console.log(error);
}
}
uploadFile(file: File, name: string) {
let current = 0;
let max = 100;
controller.progressview.addProgress("file_upload", (current / max) * 100, "Uploading File " + name + "(" + current + "/" + max + ")")
deleteSlot(idx: number) {
controller.progressview.addIndeterminate("slot_delete", "Deleting slot " + idx);
const ajax = new XMLHttpRequest();
ajax.upload.addEventListener("progress", event => {
current = event.loaded / 1000;
max = event.total / 1000;
controller.progressview.addProgress("file_upload", (current / max) * 100, "Uploading File " + name + "(" + current + "/" + max + ")")
}, false);
ajax.addEventListener("load", () => {
controller.progressview.removeProgress("file_upload")
controller.updateFileList()
}, false);
ajax.addEventListener("error", () => {
alert("Error upload")
controller.progressview.removeProgress("file_upload")
controller.updateFileList()
}, false);
ajax.addEventListener("abort", () => {
alert("abort upload")
controller.progressview.removeProgress("file_upload")
controller.updateFileList()
}, false);
ajax.open("POST", PUBLIC_URL + "/file?filename=" + name);
ajax.send(file);
}
deleteFile(name: string) {
controller.progressview.addIndeterminate("file_delete", "Deleting " + name);
const ajax = new XMLHttpRequest();
ajax.open("DELETE", PUBLIC_URL + "/file?filename=" + name);
ajax.open("DELETE", PUBLIC_URL + "/delete_save?idx=" + idx);
ajax.send();
ajax.addEventListener("error", () => {
controller.progressview.removeProgress("file_delete")
alert("Error delete")
controller.updateFileList()
controller.progressview.removeProgress("slot_delete");
alert("Error deleting slot");
controller.updateSaveList();
}, false);
ajax.addEventListener("abort", () => {
controller.progressview.removeProgress("file_delete")
alert("Error upload")
controller.updateFileList()
controller.progressview.removeProgress("slot_delete");
alert("Aborted deleting slot");
controller.updateSaveList();
}, false);
ajax.addEventListener("load", () => {
controller.progressview.removeProgress("file_delete")
controller.updateFileList()
controller.progressview.removeProgress("slot_delete");
controller.updateSaveList();
}, false);
controller.updateFileList()
}
async updateRTCData(): Promise<void> {
@@ -225,7 +194,7 @@ export class Controller {
async version(): Promise<void> {
controller.progressview.addIndeterminate("version", "Getting buildVersion")
const response = await fetch(PUBLIC_URL + "/version");
const response = await fetch(PUBLIC_URL + "/firmware_info");
const json = await response.json();
const versionInfo = json as VersionInfo;
controller.progressview.removeProgress("version");
@@ -264,13 +233,21 @@ export class Controller {
method: "POST",
body: json,
})
.then(response => response.text())
.then(text => statusCallback(text))
.then(_ => {
.then(async response => {
let text = response.text();
statusCallback(await text)
return response.status
})
.then(status => {
controller.progressview.removeProgress("set_config");
setTimeout(() => {
controller.downloadConfig()
}, 250)
if (status == 200) {
setTimeout(() => {
controller.downloadConfig().then(() => {
controller.updateSaveList().then(() => {
});
});
}, 250)
}
})
}
@@ -362,7 +339,7 @@ export class Controller {
)
}
async detectSensors(detection: Detection, silent: boolean = false) {
async detectSensors(detection: DetectionRequest, silent: boolean = false) {
let counter = 0
let limit = 5
if (!silent) {
@@ -522,7 +499,7 @@ export class Controller {
waitForReboot() {
console.log("Check if controller online again")
fetch(PUBLIC_URL + "/version", {
fetch(PUBLIC_URL + "/firmware_info", {
method: "GET",
signal: AbortSignal.timeout(5000)
}).then(response => {
@@ -583,6 +560,24 @@ export class Controller {
readonly can_power: HTMLInputElement;
readonly auto_refresh_moisture_sensors: HTMLInputElement;
private auto_refresh_timer: NodeJS.Timeout | undefined;
readonly configModeSlider: HTMLInputElement;
readonly modeSliderContainer: HTMLDivElement;
readonly modeLabel: HTMLElement;
setMode(mode: number) {
const container = this.modeSliderContainer;
container.classList.remove('mode-simple', 'mode-advanced', 'mode-expert');
if (mode === 0) {
container.classList.add('mode-simple');
this.modeLabel.textContent = 'Simple';
} else if (mode === 1) {
container.classList.add('mode-advanced');
this.modeLabel.textContent = 'Advanced';
} else {
container.classList.add('mode-expert');
this.modeLabel.textContent = 'Expert';
}
}
constructor() {
this.timeView = new TimeView(this)
@@ -600,7 +595,7 @@ export class Controller {
this.hardwareView = new HardwareConfigView(this)
this.detectBtn = document.getElementById("detect_sensors") as HTMLButtonElement
this.detectBtn.onclick = () => {
const detection: Detection = {
const detection: DetectionRequest = {
plant: Array.from({length: PLANT_COUNT}, () => ({
sensor_a: true,
sensor_b: true,
@@ -629,6 +624,14 @@ export class Controller {
this.autoRefreshLoop()
}
}
this.configModeSlider = document.getElementById("configModeSlider") as HTMLInputElement;
this.modeSliderContainer = document.getElementById("modeSliderContainer") as HTMLDivElement;
this.modeLabel = document.getElementById("modeLabel") as HTMLElement;
this.configModeSlider.oninput = () => {
this.setMode(parseInt(this.configModeSlider.value));
};
this.setMode(0);
}
private async autoRefreshLoop() {
@@ -638,7 +641,7 @@ export class Controller {
try {
await this.measure_moisture(true);
const detection: Detection = {
const detection: DetectionRequest = {
plant: Array.from({length: PLANT_COUNT}, () => ({
sensor_a: true,
sensor_b: true,
@@ -668,7 +671,7 @@ const tasks = [
{task: controller.updateSolarData, displayString: "Updating Solar Data"},
{task: controller.downloadConfig, displayString: "Downloading Configuration"},
{task: controller.version, displayString: "Fetching Version Information"},
{task: controller.updateFileList, displayString: "Updating File List"},
{task: controller.updateSaveList, displayString: "Updating Save Slots"},
{task: controller.getBackupInfo, displayString: "Fetching Backup Information"},
{task: controller.loadLogLocaleConfig, displayString: "Loading Log Localization Config"},
{task: controller.loadTankInfo, displayString: "Loading Tank Information"},
@@ -690,8 +693,9 @@ async function executeTasksSequentially() {
}
}
executeTasksSequentially().then(r => {
controller.progressview.removeProgress("initial")
executeTasksSequentially().then(() => {
controller.progressview.removeProgress("initial");
controller.logView.startLivePoll(PUBLIC_URL);
});
controller.progressview.removeProgress("rebooting");

View File

@@ -53,7 +53,7 @@
<input class="basicnetworkvalue" type="text" id="password">
</div>
</div>
<div class="subcontainer">
<div class="subcontainer hidden-mode-advanced">
<div class="flexcontainer">
<div class="subtitle">
Mqtt Reporting

View File

@@ -28,21 +28,21 @@
<div class="lightkey">Light only when dark</div>
<input class="lightcheckbox" type="checkbox" id="night_lamp_only_when_dark">
</div>
<div class="flexcontainer">
<div class="flexcontainer hidden-mode-advanced">
<div class="lightkey">Start</div>
<select class="lightnumberbox" type="time" id="night_lamp_time_start">
</select>
</div>
<div class="flexcontainer">
<div class="flexcontainer hidden-mode-advanced">
<div class="lightkey">Stop</div>
<select class="lightnumberbox" type="time" id="night_lamp_time_end">
</select>
</div>
<div class="flexcontainer">
<div class="flexcontainer hidden-mode-expert">
<div class="lightkey">Disable if Battery below %</div>
<input class="lightcheckbox" type="number" id="night_lamp_soc_low" min="0" max="100">
</div>
<div class="flexcontainer">
<div class="flexcontainer hidden-mode-expert">
<div class="lightkey">Reenable if Battery higher %</div>
<input class="lightcheckbox" type="number" id="night_lamp_soc_restore" min="0" max="100">
</div>
</div>

View File

@@ -21,6 +21,7 @@
<div class="subtitle">
Current Firmware
</div>
<button style="margin-left: auto;" type="button" id="refresh_firmware_info">Refresh</button>
</div>
<div class="flexcontainer">
<span class="otakey">Buildtime:</span>
@@ -42,12 +43,35 @@
<span class="otakey">State1:</span>
<span class="otavalue" id="firmware_state1"></span>
</div>
<div class="flexcontainer">
<div class="flexcontainer hidden-mode-advanced">
<form class="otaform" id="upload_form" method="post">
<input class="otachooser" type="file" name="file1" id="firmware_file"><br>
</form>
</div>
<div class="flexcontainer">
<div class="subtitle">
Heap Memory
</div>
<div></div>
</div>
<div class="flexcontainer">
<span class="otakey">Minimum Free:</span>
<span class="otavalue" id="heap_min_free"></span>
</div>
<div class="flexcontainer">
<span class="otakey">Used:</span>
<span class="otavalue" id="heap_used"></span>
</div>
<div class="flexcontainer">
<span class="otakey">Total:</span>
<span class="otavalue" id="heap_total"></span>
</div>
<div class="flexcontainer">
<span class="otakey">Peak used:</span>
<span class="otavalue" id="heap_max_used"></span>
</div>
<div class="display:flex">
<button style="margin-left: 16px; margin-top: 8px;" class="col-6" type="button" id="test">Self-Test</button>
</div>
<button style="margin-left: 16px; margin-top: 8px;" class="col-6 hidden-mode-advanced" type="button" id="test">Self-Test</button>
</div>

View File

@@ -1,6 +1,10 @@
import {Controller} from "./main";
import {VersionInfo} from "./api";
function fmtBytes(n: number): string {
return `${n} B (${(n / 1024).toFixed(1)} KiB)`;
}
export class OTAView {
readonly file1Upload: HTMLInputElement;
readonly firmware_buildtime: HTMLDivElement;
@@ -8,19 +12,26 @@ export class OTAView {
readonly firmware_partition: HTMLDivElement;
readonly firmware_state0: HTMLDivElement;
readonly firmware_state1: HTMLDivElement;
readonly heap_min_free: HTMLDivElement;
readonly heap_used: HTMLDivElement;
readonly heap_total: HTMLDivElement;
readonly heap_max_used: HTMLDivElement;
constructor(controller: Controller) {
(document.getElementById("firmwareview") as HTMLElement).innerHTML = require("./ota.html")
let test = document.getElementById("test") as HTMLButtonElement;
let refresh = document.getElementById("refresh_firmware_info") as HTMLButtonElement;
this.firmware_buildtime = document.getElementById("firmware_buildtime") as HTMLDivElement;
this.firmware_githash = document.getElementById("firmware_githash") as HTMLDivElement;
this.firmware_partition = document.getElementById("firmware_partition") as HTMLDivElement;
this.firmware_state0 = document.getElementById("firmware_state0") as HTMLDivElement;
this.firmware_state1 = document.getElementById("firmware_state1") as HTMLDivElement;
this.heap_min_free = document.getElementById("heap_min_free") as HTMLDivElement;
this.heap_used = document.getElementById("heap_used") as HTMLDivElement;
this.heap_total = document.getElementById("heap_total") as HTMLDivElement;
this.heap_max_used = document.getElementById("heap_max_used") as HTMLDivElement;
const file = document.getElementById("firmware_file") as HTMLInputElement;
this.file1Upload = file
@@ -36,6 +47,10 @@ export class OTAView {
test.onclick = () => {
controller.selfTest();
}
refresh.onclick = () => {
controller.version();
}
}
setVersion(versionInfo: VersionInfo) {
@@ -44,5 +59,9 @@ export class OTAView {
this.firmware_partition.innerText = versionInfo.current;
this.firmware_state0.innerText = versionInfo.slot0_state;
this.firmware_state1.innerText = versionInfo.slot1_state;
this.heap_min_free.innerText = fmtBytes(versionInfo.heap_total - versionInfo.heap_max_used);
this.heap_used.innerText = fmtBytes(versionInfo.heap_used);
this.heap_total.innerText = fmtBytes(versionInfo.heap_total);
this.heap_max_used.innerText = fmtBytes(versionInfo.heap_max_used);
}
}

View File

@@ -37,7 +37,7 @@
<div>
<div class="subtitle"
id="plant_${plantId}_header">
id="plant_${plantId}_header">
Plant ${plantId}
</div>
<div class="flexcontainer">
@@ -91,11 +91,11 @@
<input class="plantvalue" id="plant_${plantId}_max_consecutive_pump_count" type="number" min="1" max="50"
placeholder="10">
</div>
<div class="flexcontainer plantSensorEnabledOnly_${plantId}">
<div class="flexcontainer plantSensorEnabledOnly_${plantId} hidden-mode-advanced">
<div class="plantkey">Min Frequency Override</div>
<input class="plantvalue" id="plant_${plantId}_min_frequency" type="number" min="1000" max="25000">
</div>
<div class="flexcontainer plantSensorEnabledOnly_${plantId}">
<div class="flexcontainer plantSensorEnabledOnly_${plantId} hidden-mode-advanced">
<div class="plantkey">Max Frequency Override</div>
<input class="plantvalue" id="plant_${plantId}_max_frequency" type="number" min="1000" max="25000">
</div>
@@ -104,63 +104,71 @@
<div class="flexcontainer plantPumpEnabledOnly_${plantId}">
<h2 class="plantkey">Current config:</h2>
</div>
<div class="flexcontainer plantPumpEnabledOnly_${plantId}">
<div class="flexcontainer plantPumpEnabledOnly_${plantId} hidden-mode-advanced">
<div class="plantkey">Min current</div>
<input class="plantvalue" id="plant_${plantId}_min_pump_current_ma" type="number" min="0" max="4500">
</div>
<div class="flexcontainer plantPumpEnabledOnly_${plantId}">
<div class="flexcontainer plantPumpEnabledOnly_${plantId} hidden-mode-advanced">
<div class="plantkey">Max current</div>
<input class="plantvalue" id="plant_${plantId}_max_pump_current_ma" type="number" min="0" max="4500">
</div>
<div class="flexcontainer plantPumpEnabledOnly_${plantId}">
<div class="flexcontainer plantPumpEnabledOnly_${plantId} hidden-mode-advanced">
<div class="plantkey">Ignore current sensor error</div>
<input class="plantcheckbox" id="plant_${plantId}_ignore_current_error" type="checkbox">
</div>
<div class="flexcontainer plantPumpEnabledOnly_${plantId}">
<div class="flexcontainer plantPumpEnabledOnly_${plantId} hidden-mode-advanced">
<button class="subtitle" id="plant_${plantId}_test">Test Pump</button>
</div>
<div class="flexcontainer plantSensorEnabledOnly_${plantId}">
<div class="flexcontainer plantSensorEnabledOnly_${plantId} hidden-mode-advanced">
<div class="subtitle">Live:</div>
</div>
<div class="flexcontainer plantSensorEnabledOnly_${plantId}">
<button class="subtitle" id="plant_${plantId}_test_sensor_a">Test Sensor A</button>
<button class="subtitle" id="plant_${plantId}_test_sensor_b">Test Sensor B</button>
<div class="flexcontainer plantSensorEnabledOnly_${plantId} hidden-mode-advanced">
<button class="subtitle" id="plant_${plantId}_test_sensor_a">Identify Sensor A</button>
<button class="subtitle" id="plant_${plantId}_test_sensor_b">Identify Sensor B</button>
</div>
<div class="flexcontainer plantSensorEnabledOnly_${plantId}">
<div class="flexcontainer plantSensorEnabledOnly_${plantId} hidden-mode-advanced">
<span class="plantsensorkey">Sensor A:</span>
<span class="plantsensorvalue" id="plant_${plantId}_moisture_a">not measured</span>
</div>
<div class="flexcontainer plantSensorEnabledOnly_${plantId}">
<div class="flexcontainer plantSensorEnabledOnly_${plantId} hidden-mode-advanced">
<span class="plantsensorkey">Sensor A FW:</span>
<span class="plantsensorvalue" id="plant_${plantId}_sensor_a_fw_build">unknown</span>
</div>
<div class="flexcontainer plantSensorEnabledOnly_${plantId} hidden-mode-advanced">
<div class="plantsensorkey">Sensor B:</div>
<span class="plantsensorvalue" id="plant_${plantId}_moisture_b">not measured</span>
</div>
<div class="flexcontainer plantPumpEnabledOnly_${plantId}">
<div class="flexcontainer plantSensorEnabledOnly_${plantId} hidden-mode-advanced">
<span class="plantsensorkey">Sensor B FW:</span>
<span class="plantsensorvalue" id="plant_${plantId}_sensor_b_fw_build">unknown</span>
</div>
<div class="flexcontainer plantPumpEnabledOnly_${plantId} hidden-mode-advanced">
<div class="plantsensorkey">Max Current</div>
<span class="plantsensorvalue" id="plant_${plantId}_pump_test_current_max">not_tested</span>
</div>
<div class="flexcontainer plantPumpEnabledOnly_${plantId}">
<div class="flexcontainer plantPumpEnabledOnly_${plantId} hidden-mode-advanced">
<div class="plantsensorkey">Min Current</div>
<span class="plantsensorvalue" id="plant_${plantId}_pump_test_current_min">not_tested</span>
</div>
<div class="flexcontainer plantPumpEnabledOnly_${plantId}">
<div class="flexcontainer plantPumpEnabledOnly_${plantId} hidden-mode-advanced">
<div class="plantsensorkey">Average</div>
<span class="plantsensorvalue" id="plant_${plantId}_pump_test_current_average">not_tested</span>
</div>
<div class="flexcontainer plantPumpEnabledOnly_${plantId}">
<div class="flexcontainer plantPumpEnabledOnly_${plantId} hidden-mode-advanced">
<div class="plantsensorkey">Pump Time</div>
<span class="plantsensorvalue" id="plant_${plantId}_pump_test_pump_time">not_tested</span>
</div>
<div class="flexcontainer plantPumpEnabledOnly_${plantId}">
<div class="flexcontainer plantPumpEnabledOnly_${plantId} hidden-mode-advanced">
<div class="plantsensorkey">Flow ml</div>
<span class="plantsensorvalue" id="plant_${plantId}_pump_test_flow_ml">not_tested</span>
</div>
<div class="flexcontainer plantPumpEnabledOnly_${plantId}">
<div class="flexcontainer plantPumpEnabledOnly_${plantId} hidden-mode-advanced">
<div class="plantsensorkey">Flow raw</div>
<span class="plantsensorvalue" id="plant_${plantId}_pump_test_flow_raw">not_tested</span>
</div>
</div>
</div>

View File

@@ -1,7 +1,14 @@
import {DetectionPlant, Detection, PlantConfig, PumpTestResult} from "./api";
import {Detection, DetectionPlant, DetectionRequest, PlantConfig, PumpTestResult} from "./api";
export const PLANT_COUNT = 8;
/** Format a firmware build timestamp (minutes since Unix epoch) as a human-readable date/time. */
function formatBuildMinutes(buildMinutes: number | null): string {
if (buildMinutes === null) return "not detected";
if (buildMinutes === 0) return "detected (no timestamp)";
const ms = buildMinutes * 60 * 1000;
return new Date(ms).toISOString().replace("T", " ").slice(0, 16) + " UTC";
}
import {Controller} from "./main";
@@ -79,6 +86,8 @@ export class PlantView {
private readonly mode: HTMLSelectElement;
private readonly moistureA: HTMLElement;
private readonly moistureB: HTMLElement;
private readonly sensorAFwBuild: HTMLElement;
private readonly sensorBFwBuild: HTMLElement;
private readonly maxConsecutivePumpCount: HTMLInputElement;
private readonly minPumpCurrentMa: HTMLInputElement;
private readonly maxPumpCurrentMa: HTMLInputElement;
@@ -109,6 +118,8 @@ export class PlantView {
this.moistureA = document.getElementById("plant_" + plantId + "_moisture_a")! as HTMLElement;
this.moistureB = document.getElementById("plant_" + plantId + "_moisture_b")! as HTMLElement;
this.sensorAFwBuild = document.getElementById("plant_" + plantId + "_sensor_a_fw_build")! as HTMLElement;
this.sensorBFwBuild = document.getElementById("plant_" + plantId + "_sensor_b_fw_build")! as HTMLElement;
this.pump_test_current_max = document.getElementById("plant_" + plantId + "_pump_test_current_max")! as HTMLElement;
this.pump_test_current_min = document.getElementById("plant_" + plantId + "_pump_test_current_min")! as HTMLElement;
@@ -124,7 +135,7 @@ export class PlantView {
this.testSensorAButton = document.getElementById("plant_" + plantId + "_test_sensor_a")! as HTMLButtonElement;
this.testSensorAButton.onclick = () => {
const detection: Detection = {
const detection: DetectionRequest = {
plant: Array.from({length: PLANT_COUNT}, (_v, idx) => ({
sensor_a: idx === plantId,
sensor_b: false,
@@ -135,7 +146,7 @@ export class PlantView {
this.testSensorBButton = document.getElementById("plant_" + plantId + "_test_sensor_b")! as HTMLButtonElement;
this.testSensorBButton.onclick = () => {
const detection: Detection = {
const detection: DetectionRequest = {
plant: Array.from({length: PLANT_COUNT}, (_v, idx) => ({
sensor_a: false,
sensor_b: idx === plantId,
@@ -360,19 +371,23 @@ export class PlantView {
}
setDetectionResult(plantResult: DetectionPlant) {
console.log("setDetectionResult plantResult: " + plantResult.sensor_a + " " + plantResult.sensor_b)
const sensorADetected = plantResult.sensor_a !== null;
const sensorBDetected = plantResult.sensor_b !== null;
console.log("setDetectionResult plantResult: a=" + plantResult.sensor_a + " b=" + plantResult.sensor_b);
var changed = false;
if (this.sensorAInstalled.checked != plantResult.sensor_a) {
if (this.sensorAInstalled.checked != sensorADetected) {
changed = true;
this.sensorAInstalled.checked = plantResult.sensor_a;
this.sensorAInstalled.checked = sensorADetected;
}
if (this.sensorBInstalled.checked != plantResult.sensor_b) {
if (this.sensorBInstalled.checked != sensorBDetected) {
changed = true;
this.sensorBInstalled.checked = plantResult.sensor_b;
this.sensorBInstalled.checked = sensorBDetected;
}
if (changed) {
this.controller.configChanged();
}
this.sensorAFwBuild.innerText = formatBuildMinutes(plantResult.sensor_a);
this.sensorBFwBuild.innerText = formatBuildMinutes(plantResult.sensor_b);
}
}

View File

@@ -28,11 +28,11 @@ export class SubmitView {
controller.uploadConfig(this.json.textContent as string, (status: string) => {
if (status != "OK") {
// Show error toast (click to dismiss only)
const { toast } = require('./toast');
const {toast} = require('./toast');
toast.error(status);
} else {
// Show info toast (auto hides after 5s, or click to dismiss sooner)
const { toast } = require('./toast');
const {toast} = require('./toast');
toast.info('Config uploaded successfully');
}
this.submit_status.innerHTML = status;

View File

@@ -27,14 +27,14 @@
</style>
<button class="submitbutton" id="submit">Submit</button>
<br>
<button id="showJson">Show Json</button>
<div id="rawdata" class="flexcontainer" style="display: none;">
<button class="hidden-mode-advanced" id="showJson">Show Json</button>
<div id="rawdata" class="flexcontainer hidden-mode-advanced" style="display: none;">
<div class="submitarea" id="json" contenteditable="true"></div>
<div class="submitarea" id="backupjson">backup will be here</div>
</div>
<div>BackupStatus:</div>
<div id="backuptimestamp"></div>
<div id="backupsize"></div>
<button id="backup">Backup</button>
<button id="restorebackup">Restore</button>
<div id="submit_status"></div>
<button class="hidden-mode-advanced" id="backup">Backup</button>
<button class="hidden-mode-advanced" id="restorebackup">Restore</button>
<div id="submit_status"></div>

View File

@@ -20,7 +20,7 @@
<span style="flex-grow: 1; text-align: center; font-weight: bold;">
Tank:
</span>
<input id="tankview_auto_refresh" type="checkbox">
<input id="tankview_auto_refresh" type="checkbox">
</div>
<div class="flexcontainer">
<span class="tankkey">Enable Tank Sensor</span>
@@ -32,23 +32,23 @@
</div>
<div class="flexcontainer">
<div class="flexcontainer hidden-mode-advanced">
<div class="tankkey">Useable ml full% to empty%</div>
<input class="tankvalue" type="number" min="2" max="500000" id="tank_useable_ml">
</div>
<div class="flexcontainer">
<div class="flexcontainer hidden-mode-advanced">
<div class="tankkey">Warn below %</div>
<input class="tankvalue" type="number" min="1" max="500000" id="tank_warn_percent">
</div>
<div class="flexcontainer">
<div class="flexcontainer hidden-mode-advanced">
<div class="tankkey">Empty at %</div>
<input class="tankvalue" type="number" min="0" max="100" id="tank_empty_percent">
</div>
<div class="flexcontainer">
<div class="flexcontainer hidden-mode-advanced">
<div class="tankkey">Full at %</div>
<input class="tankvalue" type="number" min="0" max="100" id="tank_full_percent">
</div>
<div class="flexcontainer">
<div class="flexcontainer hidden-mode-expert">
<div class="tankkey">Flow Sensor ml per pulse</div>
<input class="tankvalue" type="number" min="0" max="1000" step="0.01" id="ml_per_pulse">
</div>
@@ -86,4 +86,4 @@
<div class="flexcontainer">
<div class="tankkey">Warn Level</div>
<label class="tankvalue" id="tank_measure_warnlevel"></label>
</div>
</div>

View File

@@ -43,6 +43,7 @@ pub mod id {
// Message group base offsets relative to SENSOR_BASE_ADDRESS
pub const MOISTURE_DATA_OFFSET: u16 = 0; // periodic data from sensor (sensor -> controller)
pub const IDENTIFY_CMD_OFFSET: u16 = 32; // identify LED command (controller -> sensor)
pub const FIRMWARE_BUILD_OFFSET: u16 = 64; // firmware build timestamp (sensor -> controller, sent after identify)
#[inline]
pub const fn plant_id(message_type_offset: u16, sensor: SensorSlot, plant: u16) -> u16 {
@@ -55,8 +56,9 @@ pub mod id {
/// Kinds of message spaces recognized by the addressing scheme.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageKind {
MoistureData, // sensor -> controller
IdentifyCmd, // controller -> sensor
MoistureData, // sensor -> controller
IdentifyCmd, // controller -> sensor
FirmwareBuild, // sensor -> controller, sent after receiving identify cmd
}
/// Try to classify a received 11-bit standard ID into a known message kind and extract plant and sensor slot.
@@ -93,6 +95,9 @@ pub mod id {
if let Some((plant, slot)) = decode_in_group(rel, IDENTIFY_CMD_OFFSET) {
return Some((MessageKind::IdentifyCmd, plant, slot));
}
if let Some((plant, slot)) = decode_in_group(rel, FIRMWARE_BUILD_OFFSET) {
return Some((MessageKind::FirmwareBuild, plant, slot));
}
None
}