diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bd7e527 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,62 @@ +# AGENTS.md + +## Scope +These instructions apply to the entire repository unless a deeper `AGENTS.md` overrides them. + +## Repository Overview +`PlantCtrl` is a mixed-discipline repository with embedded firmware, shared Rust crates, hardware design files, and a Hugo-based website. + +Top-level layout: +- `Software/MainBoard/rust`: main embedded Rust firmware for the controller board (`plant-ctrl2`). +- `Software/CAN_Sensor`: embedded Rust firmware for the CAN sensor / BMS board. +- `Software/Shared/canapi`: shared Rust crate used by firmware projects. +- `Hardware`: PCB, case, and related hardware design assets. +- `DataSheets`: reference material; treat as source data, not generated output. +- `website`: Hugo site based on the Blowfish theme. +- `bin`: helper scripts and local tooling, if present. + +## Working Rules +- Keep changes tightly scoped to the user request; this repo spans hardware, firmware, and website code. +- Prefer fixing the underlying cause instead of applying cosmetic workarounds. +- 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 such as `website/themes` or `Software/MainBoard/rust/src_webpack/node_modules` unless the user explicitly asks for vendor changes. +- When touching firmware code, keep resource usage and target constraints in mind; avoid unnecessary allocations or feature creep. + +## Firmware Guidance +- Shared protocol or serialization changes must be checked for impact across both `Software/MainBoard/rust` and `Software/CAN_Sensor`. +- Prefer small, explicit changes in embedded code paths; do not introduce heavyweight abstractions without a clear payoff. +- Keep `no_std`/embedded assumptions intact unless the code clearly opts into something else. +- Be careful with feature flags, target-specific dependencies, and boot/runtime configuration in Cargo manifests. + +## Hardware Guidance +- Treat hardware directories as design artifacts, not generic text files. +- Do not reorder, normalize, or bulk-edit PCB / CAD files unless the user specifically requests those changes. +- If a software change depends on hardware assumptions, call that out clearly in the final handoff. + +## Website Guidance +- The site in `website` uses Hugo with the Blowfish theme. +- Prefer editing site content, config, or custom assets over modifying vendored theme internals. +- Keep frontend changes consistent with the existing site structure unless the user asks for a redesign. + +## Validation +Use the narrowest relevant check first. + +Useful commands: +- `cargo check --manifest-path Software/Shared/canapi/Cargo.toml` +- `cargo check --manifest-path Software/CAN_Sensor/Cargo.toml` +- `cargo check --manifest-path Software/MainBoard/rust/Cargo.toml` +- `npm run dev` from `website` for local Hugo development if the environment has the required tools installed. + +Validation notes: +- Embedded firmware may require target-specific toolchains or hardware-adjacent tooling that is not always available. +- If you cannot run a meaningful validation step, say so explicitly and describe the likely prerequisite. + +## File Hygiene +- Read large files in chunks. +- Prefer targeted searches (`rg`, or `find` if unavailable) over broad scans. +- Do not commit build outputs, generated binaries, or local IDE metadata unless the user explicitly requests it. + +## 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. diff --git a/Hardware/Controller_Case/flap_v2.3mf b/Hardware/Controller_Case/flap_v2.3mf new file mode 100644 index 0000000..7b1a3da Binary files /dev/null and b/Hardware/Controller_Case/flap_v2.3mf differ diff --git a/Software/CAN_Sensor/build.rs b/Software/CAN_Sensor/build.rs index 77208e1..c08c92d 100644 --- a/Software/CAN_Sensor/build.rs +++ b/Software/CAN_Sensor/build.rs @@ -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"); } diff --git a/Software/CAN_Sensor/src/main.rs b/Software/CAN_Sensor/src/main.rs index 537f141..25868da 100644 --- a/Software/CAN_Sensor/src/main.rs +++ b/Software/CAN_Sensor/src/main.rs @@ -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 = 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,8 @@ async fn main(spawner: Spawner) { ain, standard_moisture_id, standard_identify_id, + standard_firmware_build_id, + slot, )) .unwrap(); } @@ -362,6 +370,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. @@ -406,7 +415,7 @@ async fn can_task( Timer::after_millis(100).await; } let mut msg: heapless::String<128> = heapless::String::new(); - let _ = write!(&mut msg, "rx err {:?}", err); + let _ = write!(&mut msg, "rx err {:?} \r\n", err); log(msg); } } @@ -420,9 +429,25 @@ async fn can_task( } } + + while let Ok(mut frame) = CAN_TX_CH.try_receive() { match can.transmit(&mut frame) { - Ok(..) => { + Ok(_ok) => { + let status = hal::pac::CAN1.errsr().read(); + + // Check CAN error status register for bus-off condition + if status.boff() || status.ewgf() || status.epvf() { + let mut msg: heapless::String<128> = heapless::String::new(); + let _ = write!(&mut msg, "canbus status {} {} {} \r\n", status.boff(), status.ewgf(), status.epvf()); + log(msg); + for _ in 0..2 { + warn.set_high(); + Timer::after_millis(100).await; + warn.set_low(); + Timer::after_millis(100).await; + } + } } Err(nb::Error::WouldBlock) => { for _ in 0..2 { @@ -432,7 +457,7 @@ async fn can_task( Timer::after_millis(100).await; } let mut msg: heapless::String<128> = heapless::String::new(); - let _ = write!(&mut msg, "canbus out buffer full"); + let _ = write!(&mut msg, "canbus out buffer full \r\n"); log(msg); } Err(nb::Error::Other(err)) => { @@ -443,7 +468,7 @@ async fn can_task( Timer::after_millis(100).await; } let mut msg: heapless::String<128> = heapless::String::new(); - let _ = write!(&mut msg, "tx err {:?}", err); + let _ = write!(&mut msg, "tx err {:?} \r\n", err); log(msg); } } @@ -460,65 +485,108 @@ async fn worker( mut ain: hal::peripherals::PA1, moisture_id: StandardId, identify_id: StandardId, + firmware_build_id: StandardId, + slot: SensorSlot, ) { // 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 - 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; + const AVG_WINDOWS: u32 = 4; + const YIELD_EVERY: u32 = 64; + let probe_duration = Duration::from_millis(100); + let measurement_time = probe_duration.as_millis() * AVG_WINDOWS as u64; // 400ms + let interleaving_gap = Duration::from_millis(50); - 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); + // Interleaving timing to ensure A and B never overlap: + // - Sensor A: measures for 400ms + // - Gap: 50ms + // - Sensor B: measures for 400ms + // - Gap: 50ms + // Total cycle: 900ms, so each sensor measures every 900ms (~1.1 measurements/second) + // + // Timeline: + // 0-400ms: A measures, B idle + // 400-450ms: both idle (gap) + // 450-850ms: B measures, A idle + // 850-900ms: both idle (gap) + // Then repeat from 0ms - // 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 to allow USB and other tasks to run - yield_now().await; + // Initial offset: B waits for A's measurement time + one gap + match slot { + SensorSlot::A => { + // A sensors start measuring immediately } - probe_gnd.set_as_input(Pull::None); + SensorSlot::B => { + // B sensors wait for A to finish measuring + gap + Timer::after(Duration::from_millis(measurement_time + interleaving_gap.as_millis())).await; + } + } - let freq_hz: u32 = pulses * (1000 / probe_duration.as_millis()) as u32; // pulses per 0.1s => Hz + loop { + let mut total_pulses: u32 = 0; + + for _window 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; + + 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; + } + } + probe_gnd.set_as_input(Pull::None); + total_pulses = total_pulses.saturating_add(pulses); + } + + 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 +594,17 @@ 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. + let firmware = CanFrame::new(firmware_build_id, &FIRMWARE_BUILD_MINUTES.to_be_bytes()).unwrap(); + CAN_TX_CH.send(firmware).await; + + // Wait for the other slot to measure, plus gaps to ensure no overlap + // After A finishes measuring: wait 50ms (gap) + 400ms (B measures) + 50ms (gap) = 500ms + // After B finishes measuring: wait 50ms (gap) + 400ms (A measures) + 50ms (gap) = 500ms + // This ensures the full 900ms cycle is maintained and A/B never overlap + Timer::after(Duration::from_millis(interleaving_gap.as_millis() + measurement_time + interleaving_gap.as_millis())).await; } } diff --git a/Software/MainBoard/rust/.cargo/config.toml b/Software/MainBoard/rust/.cargo/config.toml index 55152e5..efcb842 100644 --- a/Software/MainBoard/rust/.cargo/config.toml +++ b/Software/MainBoard/rust/.cargo/config.toml @@ -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] diff --git a/Software/MainBoard/rust/Cargo.lock b/Software/MainBoard/rust/Cargo.lock new file mode 100644 index 0000000..4da74d5 --- /dev/null +++ b/Software/MainBoard/rust/Cargo.lock @@ -0,0 +1,2835 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c583acf993cf4245c4acb0a2cc2ab1f9cc097de73411bb6d3647ff6af2b1013d" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bitfield" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf79f42d21f18b5926a959280215903e659760da994835d27c3a0c5ff4f898f" +dependencies = [ + "bitfield-macros", +] + +[[package]] +name = "bitfield-macros" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6115af052c7914c0cbb97195e5c72cb61c511527250074f5c041d1048b0d8b16" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "canapi" +version = "0.1.0" +dependencies = [ + "bincode", +] + +[[package]] +name = "cc" +version = "1.2.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d8d1efd5109b9c1cd3b7966bd071cdfb53bb6eb0b22a473a68c2f70a11a1eb" +dependencies = [ + "parse-zoneinfo", + "phf_codegen", + "regex", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.110", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.110", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "delegate" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6178a82cf56c836a3ba61a7935cdb1c49bfaa6fa4327cd5bf554a503087de26b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "djb2" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eb8886ecca3498d7a9dab291fc7c9dc293c7b35a74d60065a9ba0d510f5d39f" + +[[package]] +name = "docsplay" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8547ea80db62c5bb9d7796fcce5e6e07d1136bdc1a02269095061e806758fab4" +dependencies = [ + "docsplay-macros", +] + +[[package]] +name = "docsplay-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11772ed3eb3db124d826f3abeadf5a791a557f62c19b123e3f07288158a71fdd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "ds323x" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44a8956eeecab3afcbab217b63fad8122cf0a4a8fe34ebd862e9d6802c2633" +dependencies = [ + "embedded-hal 1.0.0", + "rtcc", +] + +[[package]] +name = "edge-dhcp" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e0b32c831ced877a78378312fe0b6f7cdd5759f3ba272578f582ff9bba5291d" +dependencies = [ + "edge-nal", + "edge-raw", + "embassy-futures", + "embassy-time", + "heapless 0.9.2", + "num_enum", + "rand_core 0.9.3", +] + +[[package]] +name = "edge-http" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b983fa8c1e7fa8a104583f0798fecdda484b9976b4638b0b6d309cd0a87720e" +dependencies = [ + "base64 0.22.1", + "edge-nal", + "embassy-futures", + "embassy-sync 0.7.2", + "embassy-time", + "embedded-io-async 0.7.0", + "heapless 0.9.2", + "httparse", + "log", + "sha1_smol", +] + +[[package]] +name = "edge-nal" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c7d7163586cb9d457a34561a644aa957ce870226729bf6c9c8beeaead7e0d8" +dependencies = [ + "embassy-time", + "embedded-io-async 0.7.0", +] + +[[package]] +name = "edge-nal-embassy" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66d0fa7b3b11c25646fae5dc15f3f3830c6127c39743cf2d54ba96110a5330" +dependencies = [ + "edge-nal", + "embassy-futures", + "embassy-net 0.8.0", + "embedded-io-async 0.7.0", + "heapless 0.9.2", +] + +[[package]] +name = "edge-raw" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "466dfce9c2172a4e947b81b556f1f07a86029fbac679e323cfb66c738cc2faea" + +[[package]] +name = "eeprom24x" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7a31bca283ff29097bb5e7c0dbdb39fbe165dda33b825e19f8ac04f15a8eef" +dependencies = [ + "embedded-hal 1.0.0", + "embedded-storage", +] + +[[package]] +name = "embassy-embedded-hal" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0641612053b2f34fc250bb63f6630ae75de46e02ade7f457268447081d709ce" +dependencies = [ + "embassy-futures", + "embassy-hal-internal", + "embassy-sync 0.8.0", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-storage", + "embedded-storage-async", + "nb 1.1.0", +] + +[[package]] +name = "embassy-executor" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0d3b15c9d7dc4fec1d8cb77112472fb008b3b28c51ad23838d83587a6d2f1e" +dependencies = [ + "cordyceps", + "critical-section", + "document-features", + "embassy-executor-macros", + "embassy-executor-timer-queue", + "log", +] + +[[package]] +name = "embassy-executor-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11a246f53de5f97a387f40ac24726817cd0b6f833e7603baac784f29d6ff276" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "embassy-executor-timer-queue" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc328bf943af66b80b98755db9106bf7e7471b0cf47dc8559cd9a6be504cc9c" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-hal-internal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f10ce10a4dfdf6402d8e9bd63128986b96a736b1a0a6680547ed2ac55d55dba" +dependencies = [ + "num-traits", +] + +[[package]] +name = "embassy-net" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71f0aa32082b7df00164f485322d6edab59122c9718b363b07ec23424c2c06a0" +dependencies = [ + "document-features", + "embassy-net-driver", + "embassy-sync 0.7.2", + "embassy-time", + "embedded-io-async 0.7.0", + "embedded-nal-async", + "heapless 0.8.0", + "log", + "managed", + "smoltcp 0.12.0", +] + +[[package]] +name = "embassy-net" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "347bc855bdbdf50ed9c5a1d80e8204badb0ba149b8732dde38e1e9708ed9d313" +dependencies = [ + "document-features", + "embassy-net-driver", + "embassy-sync 0.8.0", + "embassy-time", + "embedded-io-async 0.7.0", + "embedded-nal-async", + "heapless 0.9.2", + "managed", + "smoltcp 0.13.0", +] + +[[package]] +name = "embassy-net-driver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524eb3c489760508f71360112bca70f6e53173e6fe48fc5f0efd0f5ab217751d" + +[[package]] +name = "embassy-sync" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d2c8cdff05a7a51ba0087489ea44b0b1d97a296ca6b1d6d1a33ea7423d34049" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.6.1", + "futures-sink", + "futures-util", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-sync" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.6.1", + "futures-core", + "futures-sink", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-sync" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbd85cf5a5ae56bdf26f618364af642d1d0a4e245cdd75cd9aabda382f65a81" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async 0.7.0", + "futures-core", + "futures-sink", + "heapless 0.9.2", + "log", +] + +[[package]] +name = "embassy-time" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "592b0c143ec626e821d4d90da51a2bd91d559d6c442b7c74a47d368c9e23d97a" +dependencies = [ + "cfg-if", + "critical-section", + "document-features", + "embassy-time-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-core", + "log", +] + +[[package]] +name = "embassy-time-driver" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ee71af1b3a0deaa53eaf2d39252f83504c853646e472400b763060389b9fcc9" +dependencies = [ + "document-features", +] + +[[package]] +name = "embassy-time-queue-utils" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e2ee86063bd028a420a5fb5898c18c87a8898026da1d4c852af2c443d0a454" +dependencies = [ + "embassy-executor-timer-queue", + "heapless 0.8.0", +] + +[[package]] +name = "embedded-can" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d2e857f87ac832df68fa498d18ddc679175cf3d2e4aa893988e5601baf9438" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" + +[[package]] +name = "embedded-io-async" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +dependencies = [ + "embedded-io 0.6.1", +] + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "embedded-io 0.7.1", +] + +[[package]] +name = "embedded-nal" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56a28be191a992f28f178ec338a0bf02f63d7803244add736d026a471e6ed77" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-nal-async" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb5a1bd585135d302f8f6d7de329310938093da6271b37a6c94b8798795c0c6d" +dependencies = [ + "embedded-io-async 0.7.0", + "embedded-nal", +] + +[[package]] +name = "embedded-savegame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23884450e54db7f4063f0bcac563c45e36454f7ad9f379e9a269c5c4d74c980f" +dependencies = [ + "arrayref", + "djb2", +] + +[[package]] +name = "embedded-storage" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21dea9854beb860f3062d10228ce9b976da520a73474aed3171ec276bc0c032" + +[[package]] +name = "embedded-storage-async" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1763775e2323b7d5f0aa6090657f5e21cfa02ede71f5dc40eead06d64dcd15cc" +dependencies = [ + "embedded-storage", +] + +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "esp-alloc" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46ced060d4085858283df950b80a4da2348e1707d7d07b1e966308582dae79f5" +dependencies = [ + "allocator-api2", + "cfg-if", + "document-features", + "enumset", + "esp-config", + "esp-sync", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "esp-backtrace" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37950e24b2dfd98f1581102d1798281d4d9547af881e6bffc2c2b534c026ec8f" +dependencies = [ + "cfg-if", + "document-features", + "esp-config", + "esp-metadata-generated", + "esp-println", + "heapless 0.9.2", + "riscv", + "xtensa-lx", +] + +[[package]] +name = "esp-bootloader-esp-idf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35ffc117c3a9859835d89d0e90f5ee9886ce2264a71a849a7a22ab5308f6653c" +dependencies = [ + "cfg-if", + "document-features", + "embedded-storage", + "esp-config", + "esp-hal-procmacros", + "esp-metadata-generated", + "esp-rom-sys", + "jiff", + "log", + "strum", +] + +[[package]] +name = "esp-config" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9b92fd9cfb0b4f8f1b6219b9763269a335571e307b014903b8201619374b80" +dependencies = [ + "document-features", + "esp-metadata-generated", + "serde", + "serde_yaml", + "somni-expr", +] + +[[package]] +name = "esp-hal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af8fa8216bc126941bd43b5a200a50eab16e43881ccd0dd0b6792f4a82805f0" +dependencies = [ + "bitfield", + "bitflags 2.10.0", + "bytemuck", + "cfg-if", + "critical-section", + "delegate", + "digest", + "document-features", + "embassy-embedded-hal", + "embassy-futures", + "embassy-sync 0.8.0", + "embedded-can", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-io 0.6.1", + "embedded-io 0.7.1", + "embedded-io-async 0.6.1", + "embedded-io-async 0.7.0", + "enumset", + "esp-config", + "esp-hal-procmacros", + "esp-metadata-generated", + "esp-riscv-rt", + "esp-rom-sys", + "esp-sync", + "esp32", + "esp32c2", + "esp32c3", + "esp32c6", + "esp32h2", + "esp32s2", + "esp32s3", + "fugit", + "instability", + "log", + "nb 1.1.0", + "paste", + "portable-atomic", + "rand_core 0.10.1", + "rand_core 0.6.4", + "rand_core 0.9.3", + "riscv", + "strum", + "ufmt-write", + "xtensa-lx", + "xtensa-lx-rt", +] + +[[package]] +name = "esp-hal-procmacros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aebfabb2c21bec45e575e4f6cb6bb7aa8e1b33e7ac45b5dffa0f9d33ff59105" +dependencies = [ + "document-features", + "object", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.110", + "termcolor", +] + +[[package]] +name = "esp-metadata-generated" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c2ee95b945a4780796e4359e72c033aed3b45073880e8029458f538532db8a" + +[[package]] +name = "esp-phy" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c0a29815cd105ae1a02f3d0c6e7aafda9504a41effae17fac4c3f827719228" +dependencies = [ + "cfg-if", + "document-features", + "embassy-sync 0.8.0", + "esp-config", + "esp-hal", + "esp-metadata-generated", + "esp-sync", + "esp-wifi-sys-esp32c6", + "esp32c6", + "log", +] + +[[package]] +name = "esp-println" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42dee1e9ac7c3539bf6464db1707b0edd7557168f98278cf3c84fe70e63c6ce6" +dependencies = [ + "document-features", + "esp-metadata-generated", + "esp-sync", + "log", + "portable-atomic", +] + +[[package]] +name = "esp-radio" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fbff98b06a96b6ce3791ecec5c668524052a068e23aacd23afe17ddba844ce" +dependencies = [ + "allocator-api2", + "cfg-if", + "docsplay", + "document-features", + "embassy-net-driver", + "embassy-sync 0.8.0", + "embedded-io 0.6.1", + "embedded-io 0.7.1", + "embedded-io-async 0.6.1", + "embedded-io-async 0.7.0", + "enumset", + "esp-alloc", + "esp-config", + "esp-hal", + "esp-hal-procmacros", + "esp-metadata-generated", + "esp-phy", + "esp-radio-rtos-driver", + "esp-sync", + "esp-wifi-sys-esp32", + "esp-wifi-sys-esp32c2", + "esp-wifi-sys-esp32c3", + "esp-wifi-sys-esp32c6", + "esp-wifi-sys-esp32h2", + "esp-wifi-sys-esp32s2", + "esp-wifi-sys-esp32s3", + "esp32c6", + "heapless 0.9.2", + "instability", + "log", + "num-derive", + "num-traits", + "portable-atomic", + "portable_atomic_enum", +] + +[[package]] +name = "esp-radio-rtos-driver" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd75cd9073a90ffaa53db0bf17df7dc14164f2407a6ff36c725d2d1f78ff494" +dependencies = [ + "cfg-if", + "esp-sync", + "portable-atomic", +] + +[[package]] +name = "esp-riscv-rt" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a814ae91452de56a5e74f69aebfee40579511756837d3774a56fd24cf0ab79" +dependencies = [ + "document-features", + "riscv", + "riscv-rt", +] + +[[package]] +name = "esp-rom-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae852ccb08971155023d1371c96d5490cbc26860f06aee2d629ef73f1a890c3" +dependencies = [ + "cfg-if", + "document-features", + "esp-metadata-generated", + "esp32c6", +] + +[[package]] +name = "esp-rtos" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f90766e1527edaa0c91e8d559e9e2a60397b545e93357ac61fb31845e5712" +dependencies = [ + "allocator-api2", + "cfg-if", + "document-features", + "embassy-executor", + "embassy-sync 0.8.0", + "embassy-time-driver", + "embassy-time-queue-utils", + "esp-config", + "esp-hal", + "esp-hal-procmacros", + "esp-metadata-generated", + "esp-radio-rtos-driver", + "esp-rom-sys", + "esp-sync", + "portable-atomic", + "riscv", + "xtensa-lx", +] + +[[package]] +name = "esp-storage" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf2b3f00c9f94b27c62a4f5296b19470c3a083c687c8146e554a459f9bee596" +dependencies = [ + "document-features", + "embedded-storage", + "esp-hal", + "esp-hal-procmacros", + "esp-metadata-generated", + "esp-rom-sys", + "esp-sync", +] + +[[package]] +name = "esp-sync" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4736bfbbb9e3f6353344e14fc61b6d18d3b877c3286914cf8c0a037be0ed224" +dependencies = [ + "cfg-if", + "document-features", + "embassy-sync 0.6.2", + "embassy-sync 0.7.2", + "embassy-sync 0.8.0", + "esp-metadata-generated", + "riscv", + "xtensa-lx", +] + +[[package]] +name = "esp-wifi-sys-esp32" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2556f38f5292d9735d4e156e276815fc001c9a0a2be0544a575c5fb867129d24" +dependencies = [ + "log", +] + +[[package]] +name = "esp-wifi-sys-esp32c2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4b3eb3435dae84de611d4384639e61eed862818223e93fa70c525cb6a70127f" +dependencies = [ + "log", +] + +[[package]] +name = "esp-wifi-sys-esp32c3" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b290846b53db9a3965866964260220b67f2c41cc2dbc0b377e7136239fe168a7" +dependencies = [ + "log", +] + +[[package]] +name = "esp-wifi-sys-esp32c6" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57649401fc2f906a16e2268de88693724a125adcd0eba89b594a157affcee2d5" +dependencies = [ + "log", +] + +[[package]] +name = "esp-wifi-sys-esp32h2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf1be2e311f4b4e75b5507c6b007ffc3fcb8c6cb57f83cc6a9569ecdcf58484" +dependencies = [ + "log", +] + +[[package]] +name = "esp-wifi-sys-esp32s2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a9a7f1bc51e7026c3012cda4f11d8799e5e0c0bb3be85797462219def6c26e" +dependencies = [ + "log", +] + +[[package]] +name = "esp-wifi-sys-esp32s3" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d8321f44b57d8112cbd8607cc83b85783b9195289acb45532728e3e229e7786" +dependencies = [ + "log", +] + +[[package]] +name = "esp32" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5726e07689249d1a2cb7c492077bc424837fb68a64f7eb5d46569325352e9428" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c2" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef0b623533bbaa37e348c18b6b41cfd5b47c3cb64a4b9e44f0295941d62aa2e" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c3" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e89ed62cf6c043a6d29c520b02a13b359ec8a75d67b65d4330ed717d15fe97" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c6" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c58f34ff2633968c12125efc7f4f8f101078d5d34c7cb60eab82268db20986f9" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32h2" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5bab026020ed4606ce113b6fde598dbc48f7eefcc46e9469ece77cc2b1aa4be" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32s2" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0ad6f21cdf6ec7b06b7f7e0fbe51f0d975fd6a5fa67c3f8a5a910d3981af531" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32s3" +version = "0.35.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4b8c4e4d9f187553ecdb7173edec7b2deb2beea106eedefecdb1654b8ee25a" +dependencies = [ + "vcell", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "fugit" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e639847d312d9a82d2e75b0edcc1e934efcc64e6cb7aa94f0b1fbec0bc231d6" +dependencies = [ + "gcd", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "gcd" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "ina219" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457a68120d2d0f0ca1b5c7f8e0ff3f77db9e34b1076a4e82f7097cbde7d2aaa4" +dependencies = [ + "embedded-hal 1.0.0", + "embedded-hal-async", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jiff" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lib-bms-protocol" +version = "0.1.0" +source = "git+https://gitea.wlandt.de/judge/ch32-bms.git#0bb359cdc6bc1d86f9638e480c8789cb0a913d34" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "linked_list_allocator" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afa463f5405ee81cdb9cc2baf37e08ec7e4c8209442b5d72c04cfb2cd6e6286" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "mcutie" +version = "3.0.0" +dependencies = [ + "embassy-futures", + "embassy-net 0.8.0", + "embassy-sync 0.8.0", + "embassy-time", + "embedded-io 0.7.1", + "embedded-io-async 0.7.0", + "heapless 0.7.17", + "hex", + "log", + "mqttrs", + "once_cell", + "pin-project", +] + +[[package]] +name = "measurements" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f72b38901c571007811f0483e64a7e215ac9e6e3688e9f83705a717621d81513" +dependencies = [ + "libm", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mqttrs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62fc2b40eee1386c55479d534ec95a668e0562d54c6d1dc83bb1962469fec8a7" +dependencies = [ + "heapless 0.7.17", +] + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "onewire" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46326d0426636b36a753c6e795e2eeb431581f9663f90df7117a7842aad5b391" +dependencies = [ + "byteorder", + "embedded-hal 1.0.0", +] + +[[package]] +name = "option-lock" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73c3798199227f36e116c06a454b3f380dc85499e9f5b7d6d064083e1a53de8" + +[[package]] +name = "parse-zoneinfo" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c406c9e2aa74554e662d2c2ee11cd3e73756988800be7e6f5eddb16fed4699" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pca9535" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d18c64d2a5d513a8cc0638efd94a9c5ae76d1ca63e6b346d7571a98fd52fb8d1" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbdcb6f01d193b17f0b9c3360fa7e0e620991b193ff08702f78b3ce365d7e61" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "plant-ctrl2" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytemuck", + "canapi", + "chrono", + "chrono-tz", + "crc", + "deranged", + "ds323x", + "edge-dhcp", + "edge-http", + "edge-nal", + "edge-nal-embassy", + "eeprom24x", + "embassy-embedded-hal", + "embassy-executor", + "embassy-net 0.8.0", + "embassy-sync 0.8.0", + "embassy-time", + "embedded-can", + "embedded-hal 1.0.0", + "embedded-savegame", + "embedded-storage", + "esp-alloc", + "esp-backtrace", + "esp-bootloader-esp-idf", + "esp-hal", + "esp-println", + "esp-radio", + "esp-rtos", + "esp-storage", + "esp32c6", + "heapless 0.7.17", + "ina219", + "lib-bms-protocol", + "log", + "mcutie", + "measurements", + "nb 1.1.0", + "onewire", + "option-lock", + "pca9535", + "portable-atomic", + "postcard", + "serde", + "serde_json", + "sntpc", + "static_cell", + "strum_macros", + "unit-enum", + "vergen", +] + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "portable_atomic_enum" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d48f60c43e0120bb2bb48589a16d4bed2f4b911be41e299f2d0fc0e0e20885" +dependencies = [ + "portable-atomic", + "portable_atomic_enum_macros", +] + +[[package]] +name = "portable_atomic_enum_macros" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33fa6ec7f2047f572d49317cca19c87195de99c6e5b6ee492da701cfe02b053" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal 1.0.0", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "riscv-rt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d07b9f3a0eff773fc4df11f44ada4fa302e529bff4b7fe7e6a4b98a65ce9174" +dependencies = [ + "riscv", + "riscv-pac", + "riscv-rt-macros", + "riscv-target-parser", +] + +[[package]] +name = "riscv-rt-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c3138fdd8d128b2d81829842a3e0ce771b3712f7b6318ed1476b0695e7d330" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "riscv-target-parser" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1376b15f3ff160e9b1e8ea564ce427f2f6fcf77528cc0a8bf405cb476f9cea7" + +[[package]] +name = "rlsf" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "222fb240c3286247ecdee6fa5341e7cdad0ffdf8e7e401d9937f2d58482a20bf" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "svgbobdoc", +] + +[[package]] +name = "rtcc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34cdafab440283cfef25f6b08e6dde7339c80efa920865c96a4e1923caae0ee7" +dependencies = [ + "chrono", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smoltcp" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "heapless 0.8.0", + "managed", +] + +[[package]] +name = "smoltcp" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac729b0a77bd092a3f06ddaddc59fe0d67f48ba0de45a9abe707c2842c7f8767" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "heapless 0.9.2", + "managed", +] + +[[package]] +name = "sntpc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d84c42dedf8b01d52b62272684741866938260e74b9e58851f680b2a92854b" +dependencies = [ + "cfg-if", + "embassy-net 0.9.1", + "log", +] + +[[package]] +name = "somni-expr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed9b7648d5e8b2df6c5e49940c54bcdd2b4dd71eafc6e8f1c714eb4581b0f53" +dependencies = [ + "somni-parser", +] + +[[package]] +name = "somni-parser" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0f368519fc6c85fc1afdb769fb5a51123f6158013e143656e25a3485a0d401c" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_cell" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0530892bb4fa575ee0da4b86f86c667132a94b74bb72160f58ee5a4afec74c23" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "svgbobdoc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" +dependencies = [ + "base64 0.13.1", + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-width", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ufmt-write" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e87a2ed6b42ec5e28cc3b94c09982969e9227600b2e3dcbc1db927a84c06bd69" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unit-enum" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797f5e985cf584861a6b090d120743551e6ed2d65c39ee21907e372183e1d290" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcell" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002" + +[[package]] +name = "vergen" +version = "8.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990d9ea5967266ea0ccf413a4aa5c42a93dbcfda9cb49a97de6931726b12566" +dependencies = [ + "anyhow", + "cfg-if", + "rustversion", + "time", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.110", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "xtensa-lx" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e012d667b0aa6d2592ace8ef145a98bff3e76cca7a644f4181ecd7a916ed289b" +dependencies = [ + "critical-section", +] + +[[package]] +name = "xtensa-lx-rt" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409a9b4629d429e995cde4dfbd9fe562ccae66f7624514e200733fc5d0ea8905" +dependencies = [ + "document-features", + "xtensa-lx", + "xtensa-lx-rt-proc-macros", +] + +[[package]] +name = "xtensa-lx-rt-proc-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96fb42cd29c42f8744c74276e9f5bee7b06685bbe5b88df891516d72cb320450" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] diff --git a/Software/MainBoard/rust/Cargo.toml b/Software/MainBoard/rust/Cargo.toml index 4fce08b..bd3cbeb 100644 --- a/Software/MainBoard/rust/Cargo.toml +++ b/Software/MainBoard/rust/Cargo.toml @@ -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] diff --git a/Software/MainBoard/rust/build.rs b/Software/MainBoard/rust/build.rs index 2cc5449..ec20e04 100644 --- a/Software/MainBoard/rust/build.rs +++ b/Software/MainBoard/rust/build.rs @@ -49,5 +49,8 @@ fn linker_be_nice() { fn main() { linker_be_nice(); + // Non-existent path causes Cargo to always re-run this script, + // keeping VERGEN_BUILD_TIMESTAMP fresh on every build. + println!("cargo:rerun-if-changed=ALWAYS_REBUILD_SENTINEL"); let _ = EmitBuilder::builder().all_git().all_build().emit(); } diff --git a/Software/MainBoard/rust/clippy.toml b/Software/MainBoard/rust/clippy.toml new file mode 100644 index 0000000..de1e4a1 --- /dev/null +++ b/Software/MainBoard/rust/clippy.toml @@ -0,0 +1,2 @@ +# This file is used for clippy configuration. +# It shouldn't contain the deny attributes, which belong to the crate root. diff --git a/Software/MainBoard/rust/espflash.toml b/Software/MainBoard/rust/espflash.toml index 207afc6..f2a3eb8 100644 --- a/Software/MainBoard/rust/espflash.toml +++ b/Software/MainBoard/rust/espflash.toml @@ -1,8 +1,6 @@ -[connection] +format = "EspIdf" -[[usb_device]] -vid = "303a" -pid = "1001" +[idf_format_args] [flash] size = "16MB" diff --git a/Software/MainBoard/rust/src/config.rs b/Software/MainBoard/rust/src/config.rs index 5dd6a7b..5ae2d5d 100644 --- a/Software/MainBoard/rust/src/config.rs +++ b/Software/MainBoard/rust/src/config.rs @@ -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>, - pub password: Option>, + pub ap_ssid: String, + pub ssid: Option, + pub password: Option, pub mqtt_url: Option, - pub base_topic: Option>, + pub base_topic: Option, pub mqtt_user: Option, pub mqtt_password: Option, 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)] @@ -128,6 +129,8 @@ pub struct PlantConfig { pub min_pump_current_ma: u16, pub max_pump_current_ma: u16, pub ignore_current_error: bool, + pub fertilizer_s: u16, + pub fertilizer_cooldown_min: u16, } impl Default for PlantConfig { @@ -149,6 +152,8 @@ impl Default for PlantConfig { min_pump_current_ma: 10, max_pump_current_ma: 3000, ignore_current_error: true, + fertilizer_s: 0, + fertilizer_cooldown_min: 1440, // 1 day default } } } diff --git a/Software/MainBoard/rust/src/fat_error.rs b/Software/MainBoard/rust/src/fat_error.rs index 604b3f0..0032b1e 100644 --- a/Software/MainBoard/rust/src/fat_error.rs +++ b/Software/MainBoard/rust/src/fat_error.rs @@ -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, }, 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 ContextExt for Option { } } +impl ContextExt for Result +where + E: fmt::Debug, +{ + fn context(self, context: C) -> Result + where + C: AsRef, + { + match self { + Ok(value) => Ok(value), + Err(err) => Err(FatError::String { + error: format!("{}: {:?}", context.as_ref(), err), + }), + } + } +} + impl From> for FatError { fn from(error: Error) -> Self { FatError::OneWireError { error } } } -impl From for FatError { - fn from(value: littlefs2_core::Error) -> Self { - FatError::LittleFSError { error: value } - } -} - -impl From for FatError { - fn from(value: PathError) -> Self { - FatError::PathError { error: value } - } -} - impl From for FatError { fn from(value: TryLockError) -> Self { FatError::TryLockError { error: value } @@ -236,16 +230,8 @@ impl From>> for FatError { } } -impl From for FatError { - fn from(value: bincode::error::DecodeError) -> Self { - FatError::Eeprom24x { - error: format!("{value:?}"), - } - } -} - -impl From for FatError { - fn from(value: bincode::error::EncodeError) -> Self { +impl From for FatError { + fn from(value: postcard::Error) -> Self { FatError::Eeprom24x { error: format!("{value:?}"), } @@ -283,7 +269,7 @@ impl From>> for FatError impl From for FatError { fn from(value: Infallible) -> Self { - panic!("Infallible error: {:?}", value) + match value {} } } @@ -336,3 +322,27 @@ impl From for FatError { } } } + +impl From for FatError { + fn from(value: ParseError) -> Self { + FatError::String { + error: format!("Parsing error: {value:?}"), + } + } +} + +impl From for FatError { + fn from(value: ParseErrorKind) -> Self { + FatError::String { + error: format!("Parsing error: {value:?}"), + } + } +} + +impl From for FatError { + fn from(value: chrono::format::ParseError) -> Self { + FatError::String { + error: format!("Parsing error: {value:?}"), + } + } +} diff --git a/Software/MainBoard/rust/src/hal/esp.rs b/Software/MainBoard/rust/src/hal/esp.rs index 5923a53..56e0e2d 100644 --- a/Software/MainBoard/rust/src/hal/esp.rs +++ b/Software/MainBoard/rust/src/hal/esp.rs @@ -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,32 +34,31 @@ 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]; #[esp_hal::ram(unstable(rtc_fast), unstable(persistent))] static mut CONSECUTIVE_WATERING_PLANT: [u32; PLANT_COUNT] = [0; PLANT_COUNT]; #[esp_hal::ram(unstable(rtc_fast), unstable(persistent))] +static mut LAST_FERTILIZER_TIMESTAMP: [i64; PLANT_COUNT] = [0; PLANT_COUNT]; +#[esp_hal::ram(unstable(rtc_fast), unstable(persistent))] 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 +66,44 @@ static MQTT_ROUND_TRIP_RECEIVED: AtomicBool = AtomicBool::new(false); pub static MQTT_STAY_ALIVE: AtomicBool = AtomicBool::new(false); static MQTT_BASE_TOPIC: OnceLock = OnceLock::new(); -#[derive(Serialize, Debug)] -pub struct FileInfo { - filename: String, - size: usize, -} - -#[derive(Serialize, Debug)] -pub struct FileList { - total: usize, - used: usize, - files: Vec, -} - #[derive(Copy, Clone, Default)] struct Timestamp { stamp: DateTime, } +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 { + 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 +135,11 @@ impl NtpTimestampGenerator for Timestamp { } pub struct Esp<'a> { - pub fs: Arc>>, + pub savegame: SavegameManager, pub rng: Rng, //first starter (ap or sta will take these) - pub interface_sta: Option>, - pub interface_ap: Option>, + pub interface_sta: Option>, + pub interface_ap: Option>, pub controller: Arc>>, pub boot_button: Input<'a>, @@ -130,6 +148,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 +175,15 @@ macro_rules! mk_static { } impl Esp<'_> { + pub fn get_time(&self) -> DateTime { + DateTime::from_timestamp_micros(self.rtc.current_time_us() as i64) + .unwrap_or(DateTime::UNIX_EPOCH) + } + + pub fn set_time(&mut self, time: DateTime) { + self.rtc.set_current_time_us(time.timestamp_micros() as u64); + } + pub(crate) async fn read_serial_line(&mut self) -> FatResult> { let mut buf = [0u8; 1]; let mut line = String::new(); @@ -185,69 +214,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 { - 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 +280,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 +327,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) } @@ -375,6 +344,14 @@ impl Esp<'_> { LAST_WATERING_TIMESTAMP[plant] = time.timestamp_millis(); } } + pub(crate) fn last_fertilizer_time(&self, plant: usize) -> i64 { + unsafe { LAST_FERTILIZER_TIMESTAMP[plant] } + } + pub(crate) fn store_last_fertilizer_time(&mut self, plant: usize, time: DateTime) { + unsafe { + LAST_FERTILIZER_TIMESTAMP[plant] = time.timestamp_millis(); + } + } pub(crate) fn set_low_voltage_in_cycle(&mut self) { unsafe { LOW_VOLTAGE_DETECTED = 1; @@ -409,6 +386,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> { let ssid = match self.load_config().await { @@ -416,9 +401,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 +426,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 +443,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 +457,17 @@ impl Esp<'_> { spawner: Spawner, ) -> FatResult> { 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 +475,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 +492,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, - ) -> ! { + 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 +565,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 { - 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) -> 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 { - 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 { + 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) -> FatResult<()> { + let timestamp = self.get_time().to_rfc3339(); + self.savegame.save(config.as_slice(), ×tamp)?; + + 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> { + self.savegame.list_saves() } pub(crate) async fn init_rtc_deepsleep_memory( @@ -674,34 +630,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 +698,11 @@ impl Esp<'_> { let mut builder: McutieBuilder<'_, String, PublishDisplay, 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 +726,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 +799,7 @@ impl Esp<'_> { Error::TooLarge => false, Error::PacketError => false, Error::Invalid => false, + Error::Rejected => false, }; if !retry { bail!( @@ -929,18 +874,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 +885,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 +905,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( diff --git a/Software/MainBoard/rust/src/hal/little_fs2storage_adapter.rs b/Software/MainBoard/rust/src/hal/little_fs2storage_adapter.rs deleted file mode 100644 index c3b7a80..0000000 --- a/Software/MainBoard/rust/src/hal/little_fs2storage_adapter.rs +++ /dev/null @@ -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 { - 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 { - 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 { - 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) - } - } - } -} diff --git a/Software/MainBoard/rust/src/hal/mod.rs b/Software/MainBoard/rust/src/hal/mod.rs index b350fc6..e64d2b9 100644 --- a/Software/MainBoard/rust/src/hal/mod.rs +++ b/Software/MainBoard/rust/src/hal/mod.rs @@ -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> = 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>, + >, +> = OnceLock::new(); + const TANK_MULTI_SAMPLE: usize = 11; pub static I2C_DRIVER: OnceLock< embassy_sync::blocking_mutex::Mutex>>, > = OnceLock::new(); -#[derive(Debug, PartialEq, Clone, Copy, Encode, Decode)] +#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)] pub enum Sensor { A, B, @@ -147,8 +146,10 @@ pub trait BoardInteraction<'a> { fn get_config(&mut self) -> &PlantControllerConfig; fn get_battery_monitor(&mut self) -> &mut Box; fn get_rtc_module(&mut self) -> &mut Box; + async fn get_time(&mut self) -> DateTime; + async fn set_time(&mut self, time: &DateTime) -> FatResult<()>; async fn set_charge_indicator(&mut self, charging: bool) -> Result<(), FatError>; - async fn deep_sleep(&mut self, duration_in_ms: u64) -> !; + async fn deep_sleep_ms(&mut self, duration_in_ms: u64) -> !; fn is_day(&self) -> bool; //should be multsampled @@ -163,16 +164,29 @@ pub trait BoardInteraction<'a> { async fn get_mptt_voltage(&mut self) -> FatResult; async fn get_mptt_current(&mut self) -> FatResult; async fn can_power(&mut self, state: bool) -> FatResult<()>; + async fn fertilizer_pump(&mut self, enable: bool) -> FatResult<()>; + + async fn backup_config(&mut self, config: &PlantControllerConfig) -> FatResult<()>; + async fn read_backup(&mut self) -> FatResult; + async fn backup_info(&mut self) -> FatResult; // Return JSON string with autodetected sensors per plant. Default: not supported. - async fn detect_sensors(&mut self, _request: Detection) -> FatResult { + async fn detect_sensors(&mut self, _request: DetectionRequest) -> FatResult { 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; PLANT_COUNT], [Option; 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 +258,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 +284,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 +342,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>, - 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 +384,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, + FlashRegion<'static, MutexFlashStorage>, data_partition.as_embedded_storage(flash_storage_3) ); - let lfs2filesystem = mk_static!(LittleFs2Filesystem, LittleFs2Filesystem { storage: data }); - let alloc = mk_static!(Allocation, 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 +420,7 @@ impl PlantHal { slot0_state: state_0, slot1_state: state_1, uart0, + rtc: rtc_peripheral, }; //init,reset rtc memory depending on cause @@ -453,17 +456,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 +474,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 +492,9 @@ impl PlantHal { RefCell>, > = 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 +561,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 +583,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 +659,38 @@ pub fn next_partition(current: AppPartitionSubType) -> FatResult DateTime { - 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) -> 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; PLANT_COUNT], pub sensor_b_hz: [Option; PLANT_COUNT], + pub sensor_a_build_minutes: [Option; PLANT_COUNT], + pub sensor_b_build_minutes: [Option; 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, + pub sensor_b: Option, } diff --git a/Software/MainBoard/rust/src/hal/rtc.rs b/Software/MainBoard/rust/src/hal/rtc.rs index 223f2af..5efd271 100644 --- a/Software/MainBoard/rust/src/hal/rtc.rs +++ b/Software/MainBoard/rust/src/hal/rtc.rs @@ -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 = crc::Crc::::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; - 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>; async fn set_rtc_time(&mut self, time: &DateTime) -> 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 { - 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> { 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(()) + } } diff --git a/Software/MainBoard/rust/src/hal/savegame_manager.rs b/Software/MainBoard/rust/src/hal/savegame_manager.rs new file mode 100644 index 0000000..4d1c5b8 --- /dev/null +++ b/Software/MainBoard/rust/src/hal/savegame_manager.rs @@ -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, +} + +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> { + 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 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, 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(×tamp_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>> { + 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>> { + 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> { + 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) + } +} diff --git a/Software/MainBoard/rust/src/hal/v4_hal.rs b/Software/MainBoard/rust/src/hal/v4_hal.rs index 9181e83..3ba5654 100644 --- a/Software/MainBoard/rust/src/hal/v4_hal.rs +++ b/Software/MainBoard/rust/src/hal/v4_hal.rs @@ -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>, + + /// 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; PLANT_COUNT], + sensor_b_build_minutes: [Option; 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,15 +306,24 @@ impl<'a> BoardInteraction<'a> for V4<'a> { &mut self.rtc_module } + async fn get_time(&mut self) -> DateTime { + self.esp.get_time() + } + + async fn set_time(&mut self, time: &DateTime) -> 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) } - async fn deep_sleep(&mut self, duration_in_ms: u64) -> ! { + async fn deep_sleep_ms(&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 { 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,175 @@ 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 { + 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 { + self.charger.get_mptt_voltage() + } + + async fn get_mptt_current(&mut self) -> FatResult { + 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 fertilizer_pump(&mut self, enable: bool) -> FatResult<()> { + if enable { + self.extra2.set_high(); + } else { + self.extra2.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 { + 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 { + 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::(&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 { 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 +589,7 @@ impl<'a> BoardInteraction<'a> for V4<'a> { } else { request.plant[plant].sensor_b }; + if !detect { continue; } @@ -458,7 +634,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 +642,18 @@ 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 { - self.charger.get_mptt_voltage() - } - - async fn get_mptt_current(&mut self) -> FatResult { - 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; PLANT_COUNT], [Option; PLANT_COUNT]) { + (self.sensor_a_build_minutes, self.sensor_b_build_minutes) } } @@ -557,10 +674,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 +694,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 +737,17 @@ impl From 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 } diff --git a/Software/MainBoard/rust/src/hal/water.rs b/Software/MainBoard/rust/src/hal/water.rs index bdbb3b7..5f3b457 100644 --- a/Software/MainBoard/rust/src/hal/water.rs +++ b/Software/MainBoard/rust/src/hal/water.rs @@ -4,18 +4,20 @@ 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 onewire::{ds18b20, Device, DeviceSearch, OneWire, DS18B20}; +unsafe impl Send for TankSensor<'_> {} + pub struct TankSensor<'a> { one_wire_bus: OneWire>, - tank_channel: Adc<'a, ADC1<'a>, Blocking>, + tank_channel: Adc<'a, ADC1<'a>, Async>, tank_power: Output<'a>, tank_pin: AdcPin, ADC1<'a>, AdcCalLine>>, flow_counter: Unit<'a, 1>, @@ -30,11 +32,20 @@ impl<'a> TankSensor<'a> { flow_sensor: Input, pcnt1: Unit<'a, 1>, ) -> Result, 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,15 +88,33 @@ 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)?; + println!("OneWire: reset presence pulse = {}", presence); + if !presence { + println!("OneWire: no device responded to reset — check pull-up resistor and wiring"); + } + let mut search = DeviceSearch::new(); let mut water_temp_sensor: Option = None; + let mut devices_found = 0u8; while let Some(device) = self.one_wire_bus.search_next(&mut search, &mut delay)? { + devices_found += 1; + println!( + "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 { + println!("OneWire: skipping device — not a DS18B20 (family 0x{:02X} != 0x{:02X})", device.address[0], ds18b20::FAMILY_CODE); } } + if devices_found == 0 { + println!("OneWire: search found zero devices on the bus"); + } + match water_temp_sensor { Some(device) => { println!("Found one wire device: {:?}", device); @@ -138,15 +167,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) } } diff --git a/Software/MainBoard/rust/src/log/interceptor.rs b/Software/MainBoard/rust/src/log/interceptor.rs new file mode 100644 index 0000000..adeb034 --- /dev/null +++ b/Software/MainBoard/rust/src/log/interceptor.rs @@ -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) -> (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>, +} + +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) -> (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) {} +} diff --git a/Software/MainBoard/rust/src/log/mod.rs b/Software/MainBoard/rust/src/log/mod.rs index 5bf6828..a1df0dd 100644 --- a/Software/MainBoard/rust/src/log/mod.rs +++ b/Software/MainBoard/rust/src/log/mod.rs @@ -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 = 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, + pub txt_long: heapless::String, +} + +static LOG_CHANNEL: Channel = 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 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 = heapless::String::new(); + let mut txt_long_stack: heapless::String = 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 { 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 = 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 = heapless::String::new(); - let mut txt_long_stack: heapless::String = 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(); } @@ -279,8 +311,20 @@ pub enum LogMessage { PumpOpenLoopCurrent, #[strum(serialize = "Pump Open current sensor required but did not work: ${number_a}")] PumpMissingSensorCurrent, + #[strum( + serialize = "Fertilizer applied for ${number_a}s on plant ${number_b} (last application ${txt_short} minutes ago)" + )] + FertilizerApplied, #[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 +345,7 @@ impl From<&LogMessage> for MessageTranslation { impl LogMessage { pub fn log_localisation_config() -> Vec { 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() })) } diff --git a/Software/MainBoard/rust/src/main.rs b/Software/MainBoard/rust/src/main.rs index 22482a8..663a5ae 100644 --- a/Software/MainBoard/rust/src/main.rs +++ b/Software/MainBoard/rust/src/main.rs @@ -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,10 +130,11 @@ 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, + overcurrent_ma: Option, } #[derive(Serialize, Debug, PartialEq)] @@ -167,26 +169,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 +195,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 +212,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 +240,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 = OptionLock::empty(); @@ -285,7 +278,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 +289,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 +335,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 +359,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 +377,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, + let mut plantstate: [PlantState; PLANT_COUNT] = [ + 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 +408,8 @@ 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, "", ""); + let mut overcurrent_results: [Option; PLANT_COUNT] = [None; PLANT_COUNT]; for (plant_id, (state, plant_config)) in plantstate .iter() .zip(&board.board_hal.get_config().plants.clone()) @@ -454,8 +430,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 +439,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 +447,50 @@ 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) => { + overcurrent_results[plant_id] = state.overcurrent_ma; + 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 +500,64 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> { .store_consecutive_pump_count(plant_id, 0); } } + for (plant_id, overcurrent) in overcurrent_results.iter().enumerate() { + if let Some(current_ma) = *overcurrent { + plantstate[plant_id].pump.overcurrent_error = Some(current_ma); + } + } + publish_plant_states(&mut board, &timezone_time.clone(), &plantstate) + .await + .unwrap_or_else(|e| { + error!("Error publishing plant states after pumping {e}"); + }); + } 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(¤t_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"); @@ -606,35 +667,45 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> { .mqtt_publish("/deepsleep", "night 1h").await; 60 }; + let _ = board .board_hal .get_esp() .mqtt_publish("/state", "sleep") .await; - info!("Go to sleep for {deep_sleep_duration_minutes} minutes"); + //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)); - spawner.spawn(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); board .board_hal - .deep_sleep(1000 * 1000 * 60 * deep_sleep_duration_minutes as u64) + .deep_sleep_ms(1000 * 60 * deep_sleep_duration_minutes as u64) .await; } } @@ -652,11 +723,50 @@ 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; + let mut overcurrent_ma: Option = None; if !dry_run { + // Run fertilizer pump first if configured and not in cooldown + if plant_config.fertilizer_s > 0 { + let current_time = board.board_hal.get_time().await; + let last_fertilizer = board.board_hal.get_esp().last_fertilizer_time(plant_id); + let elapsed_minutes = (current_time.timestamp() - last_fertilizer) / 60; + + if elapsed_minutes >= plant_config.fertilizer_cooldown_min as i64 { + info!( + "Starting fertilizer pump for {} seconds (last fertilizer was {} minutes ago)", + plant_config.fertilizer_s, elapsed_minutes + ); + log( + LogMessage::FertilizerApplied, + plant_config.fertilizer_s as u32, + (plant_id + 1) as u32, + &elapsed_minutes.to_string(), + "", + ); + board.board_hal.fertilizer_pump(true).await?; + Timer::after_millis(plant_config.fertilizer_s as u64 * 1000).await; + board.board_hal.fertilizer_pump(false).await?; + info!("Fertilizer pump stopped"); + + // Store the current time as last fertilizer time + board + .board_hal + .get_esp() + .store_last_fertilizer_time(plant_id, current_time); + } else { + let remaining_minutes = + plant_config.fertilizer_cooldown_min as i64 - elapsed_minutes; + info!( + "Skipping fertilizer (cooldown: {} minutes remaining)", + remaining_minutes + ); + } + } + board.board_hal.get_tank_sensor()?.reset_flow_meter(); board.board_hal.get_tank_sensor()?.start_flow_meter(); board.board_hal.pump(plant_id, true).await?; @@ -686,46 +796,48 @@ 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; + ); + overcurrent_ma = Some(current_ma); + 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; + ); + overcurrent_ma = Some(current_ma); 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 +846,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 +876,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(); @@ -781,6 +897,7 @@ pub async fn do_secure_pump( flow_value_count: final_flow_value, pump_time_s: (pump_time_ms / 1000) as u16, error, + overcurrent_ma, }) } @@ -801,30 +918,29 @@ async fn publish_tank_state( board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>, tank_state: &TankState, water_temp: FatResult, -) { +) -> 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, 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 +948,7 @@ async fn publish_plant_states( .mqtt_publish(&plant_topic, &state) .await; } + Ok(()) } async fn publish_firmware_info( @@ -927,13 +1044,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, @@ -941,16 +1059,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) @@ -983,11 +1098,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(), @@ -999,12 +1114,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, + 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); @@ -1014,6 +1131,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 = None; // Long-press exit (for webserver config modes): hold boot button for 5 seconds. let mut exit_hold_started: Option = None; @@ -1045,8 +1163,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; @@ -1060,7 +1177,7 @@ async fn wait_infinity( board.board_hal.get_esp().set_restart_to_conf(false); // ensure clean http answer / visible confirmation Timer::after_millis(500).await; - board.board_hal.deep_sleep(0).await; + board.board_hal.deep_sleep_ms(0).await; } // Short tick while holding so the pattern updates smoothly. @@ -1091,6 +1208,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 { @@ -1138,6 +1271,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); } @@ -1151,7 +1286,7 @@ async fn wait_infinity( .lock() .await .board_hal - .deep_sleep(0) + .deep_sleep_ms(0) .await; } } @@ -1196,10 +1331,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() @@ -1244,12 +1382,17 @@ 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_free: heap.size.saturating_sub(heap.current_usage), + heap_max_used: heap.max_usage, } } @@ -1260,4 +1403,8 @@ struct VersionInfo { current: String, slot0_state: String, slot1_state: String, + heap_total: usize, + heap_used: usize, + heap_free: usize, + heap_max_used: usize, } diff --git a/Software/MainBoard/rust/src/mcutie_3_0_0/Cargo.toml b/Software/MainBoard/rust/src/mcutie_3_0_0/Cargo.toml new file mode 100644 index 0000000..eb48eb1 --- /dev/null +++ b/Software/MainBoard/rust/src/mcutie_3_0_0/Cargo.toml @@ -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" diff --git a/Software/MainBoard/rust/src/mcutie_3_0_0/buffer.rs b/Software/MainBoard/rust/src/mcutie_3_0_0/buffer.rs new file mode 100644 index 0000000..2397f80 --- /dev/null +++ b/Software/MainBoard/rust/src/mcutie_3_0_0/buffer.rs @@ -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 { + bytes: [u8; N], + cursor: usize, +} + +impl Default for Buffer { + fn default() -> Self { + Self::new() + } +} + +impl Buffer { + /// 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 { + 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( + &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 { + 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 Deref for Buffer { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + &self.bytes[0..self.cursor] + } +} + +impl fmt::Write for Buffer { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.write_all(s.as_bytes()).map_err(|_| fmt::Error) + } +} + +impl embedded_io::ErrorType for Buffer { + type Error = SliceWriteError; +} + +impl embedded_io::Write for Buffer { + fn write(&mut self, buf: &[u8]) -> Result { + 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 embedded_io_async::Write for Buffer { + async fn write(&mut self, buf: &[u8]) -> Result { + ::write(self, buf) + } + + async fn flush(&mut self) -> Result<(), Self::Error> { + Ok(()) + } +} diff --git a/Software/MainBoard/rust/src/mcutie_3_0_0/fmt.rs b/Software/MainBoard/rust/src/mcutie_3_0_0/fmt.rs new file mode 100644 index 0000000..b678fbf --- /dev/null +++ b/Software/MainBoard/rust/src/mcutie_3_0_0/fmt.rs @@ -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(pub(crate) D); + +#[cfg(feature = "log")] +impl fmt::Debug for Debug2Format { + 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 ),*); + }; +} diff --git a/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/binary_sensor.rs b/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/binary_sensor.rs new file mode 100644 index 0000000..b62d2fd --- /dev/null +++ b/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/binary_sensor.rs @@ -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 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 for BinarySensorState { + fn from(val: bool) -> Self { + if val { + BinarySensorState::On + } else { + BinarySensorState::Off + } + } +} + +impl From 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, +} + +impl Component for BinarySensor { + type State = BinarySensorState; + + fn platform() -> &'static str { + "binary_sensor" + } + + async fn publish_state>( + &self, + topic: &Topic, + state: Self::State, + ) -> Result<(), Error> { + topic.with_bytes(state).publish().await + } +} diff --git a/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/button.rs b/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/button.rs new file mode 100644 index 0000000..b19a66f --- /dev/null +++ b/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/button.rs @@ -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, +} + +impl Component for Button { + type State = (); + + fn platform() -> &'static str { + "button" + } + + async fn publish_state>( + &self, + _topic: &Topic, + _state: Self::State, + ) -> Result<(), Error> { + // Buttons don't have a state + Err(Error::Invalid) + } +} diff --git a/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/light.rs b/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/light.rs new file mode 100644 index 0000000..1b85d11 --- /dev/null +++ b/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/light.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + s: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + x: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + y: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + r: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + g: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + b: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + w: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + c: Option, +} + +#[derive(Deserialize)] +struct LedPayload<'a> { + state: BinarySensorState, + #[serde(default)] + brightness: Option, + #[serde(default)] + color_temp: Option, + #[serde(default)] + color: Option, + #[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 { + 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(&self, serializer: S) -> Result + 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 Serialize for Light<'_, C, E> { + fn serialize(&self, serializer: S) -> Result + 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 Component for Light<'_, C, E> { + type State = LightState<'static>; + + fn platform() -> &'static str { + "light" + } + + async fn publish_state>( + &self, + topic: &Topic, + state: Self::State, + ) -> Result<(), Error> { + topic.with_json(state).publish().await + } +} diff --git a/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/mod.rs b/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/mod.rs new file mode 100644 index 0000000..8d98205 --- /dev/null +++ b/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/mod.rs @@ -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>( + &self, + topic: &Topic, + state: Self::State, + ) -> impl Future>; +} + +impl<'t, T, L, const S: usize> McutieTask<'t, T, L, S> +where + T: Deref + '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, + 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> Serialize for Topic { + fn serialize(&self, serializer: S) -> Result + 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(name: &Option<&str>, serializer: S) -> Result +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(&self, serializer: S) -> Result + 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>, + /// The command topic that this entity receives commands from. + pub command_topic: Option>, + /// The specific entity. + pub component: C, +} + +impl 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 Serialize for Entity<'_, A, C> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let outer = DiscoverySerializer { + discovery: self, + inner: serializer, + }; + + self.component.serialize(outer) + } +} diff --git a/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/sensor.rs b/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/sensor.rs new file mode 100644 index 0000000..bf48ecb --- /dev/null +++ b/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/sensor.rs @@ -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, + /// The type of measurement that this sensor reports. + pub state_class: Option, + /// 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>( + &self, + topic: &Topic, + state: Self::State, + ) -> Result<(), Error> { + topic.with_display(state).publish().await + } +} diff --git a/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/ser.rs b/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/ser.rs new file mode 100644 index 0000000..80e99d9 --- /dev/null +++ b/Software/MainBoard/rust/src/mcutie_3_0_0/homeassistant/ser.rs @@ -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, const N: usize> { + list: &'a [Topic; N], +} + +impl<'a, const N: usize, T: Deref> AvailabilityTopicList<'a, T, N> { + pub(super) fn new(list: &'a [Topic; N]) -> Self { + Self { list } + } +} + +impl, const N: usize> Serialize for AvailabilityTopicList<'_, T, N> { + fn serialize(&self, serializer: S) -> Result + 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 Serialize for List<'_, T, N> { + fn serialize(&self, serializer: S) -> Result + 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 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 { + 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 { + unimplemented!() + } + + fn serialize_i8(self, _: i8) -> Result { + unimplemented!() + } + + fn serialize_i16(self, _: i16) -> Result { + unimplemented!() + } + + fn serialize_i32(self, _: i32) -> Result { + unimplemented!() + } + + fn serialize_i64(self, _: i64) -> Result { + unimplemented!() + } + + fn serialize_u8(self, _: u8) -> Result { + unimplemented!() + } + + fn serialize_u16(self, _: u16) -> Result { + unimplemented!() + } + + fn serialize_u32(self, _: u32) -> Result { + unimplemented!() + } + + fn serialize_u64(self, _: u64) -> Result { + unimplemented!() + } + + fn serialize_f32(self, _: f32) -> Result { + unimplemented!() + } + + fn serialize_f64(self, _: f64) -> Result { + unimplemented!() + } + + fn serialize_char(self, _: char) -> Result { + unimplemented!() + } + + fn serialize_str(self, _: &str) -> Result { + unimplemented!() + } + + fn serialize_bytes(self, _: &[u8]) -> Result { + unimplemented!() + } + + fn serialize_none(self) -> Result { + unimplemented!() + } + + fn serialize_some(self, _: &T) -> Result + where + T: ?Sized + Serialize, + { + unimplemented!() + } + + fn serialize_unit(self) -> Result { + unimplemented!() + } + + fn serialize_unit_struct(self, _: &'static str) -> Result { + unimplemented!() + } + + fn serialize_unit_variant( + self, + _: &'static str, + _: u32, + _: &'static str, + ) -> Result { + unimplemented!() + } + + fn serialize_newtype_struct(self, _: &'static str, _: &T) -> Result + where + T: ?Sized + Serialize, + { + unimplemented!() + } + + fn serialize_newtype_variant( + self, + _: &'static str, + _: u32, + _: &'static str, + _: &T, + ) -> Result + where + T: ?Sized + Serialize, + { + unimplemented!() + } + + fn serialize_seq(self, _: Option) -> Result { + unimplemented!() + } + + fn serialize_tuple(self, _: usize) -> Result { + unimplemented!() + } + + fn serialize_tuple_struct( + self, + _: &'static str, + _: usize, + ) -> Result { + unimplemented!() + } + + fn serialize_tuple_variant( + self, + _: &'static str, + _: u32, + _: &'static str, + _: usize, + ) -> Result { + unimplemented!() + } + + fn serialize_map(self, _: Option) -> Result { + unimplemented!() + } + + fn serialize_struct_variant( + self, + _: &'static str, + _: u32, + _: &'static str, + _: usize, + ) -> Result { + unimplemented!() + } + + fn serialize_i128(self, _: i128) -> Result { + unimplemented!() + } + + fn serialize_u128(self, _: u128) -> Result { + unimplemented!() + } + + fn collect_seq(self, _: I) -> Result + where + I: IntoIterator, + ::Item: Serialize, + { + unimplemented!() + } + + fn collect_map(self, _: I) -> Result + where + K: Serialize, + V: Serialize, + I: IntoIterator, + { + unimplemented!() + } + + fn collect_str(self, _: &T) -> Result + where + T: ?Sized + core::fmt::Display, + { + unimplemented!() + } + + fn is_human_readable(&self) -> bool { + unimplemented!() + } +} diff --git a/Software/MainBoard/rust/src/mcutie_3_0_0/io.rs b/Software/MainBoard/rust/src/mcutie_3_0_0/io.rs new file mode 100644 index 0000000..0ca402b --- /dev/null +++ b/Software/MainBoard/rust/src/mcutie_3_0_0/io.rs @@ -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 = ConnectedPipe::new(); + +pub(crate) static CONTROL_CHANNEL: PubSubChannel = + 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 = 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 { + 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 + 't, + L: Publishable + 't, +{ + pub(crate) network: Stack<'t>, + pub(crate) broker: &'t str, + pub(crate) last_will: Option, + pub(crate) username: Option<&'t str>, + pub(crate) password: Option<&'t str>, + pub(crate) subscriptions: [Topic; S], + pub(crate) keep_alive: u16 +} + +impl<'t, T, L, const S: usize> McutieTask<'t, T, L, S> +where + T: Deref + 't, + L: Publishable + 't, +{ + #[cfg(not(feature = "homeassistant"))] + async fn ha_handle_update(&self, _topic: &Topic, _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 = 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; + } + } +} diff --git a/Software/MainBoard/rust/src/mcutie_3_0_0/lib.rs b/Software/MainBoard/rust/src/mcutie_3_0_0/lib.rs new file mode 100644 index 0000000..126c9af --- /dev/null +++ b/Software/MainBoard/rust/src/mcutie_3_0_0/lib.rs @@ -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; +/// A fixed length buffer of 2048 bytes. +pub type Payload = Buffer; + +// 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 = Channel::new(); + +static DEVICE_TYPE: OnceCell> = OnceCell::new(); +static DEVICE_ID: OnceCell> = 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, 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 + 't, + L: Publishable + 't, +{ + network: Stack<'t>, + device_type: &'t str, + device_id: Option<&'t str>, + broker: &'t str, + last_will: Option, + username: Option<&'t str>, + password: Option<&'t str>, + subscriptions: [Topic; S], +} + +impl<'t, T: Deref + '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 + 't, L: Publishable + 't, const S: usize> + McutieBuilder<'t, T, L, S> +{ + /// Add some default topics to subscribe to. + pub fn with_subscriptions( + self, + subscriptions: [Topic; 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 + '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 + }, + ) + } +} diff --git a/Software/MainBoard/rust/src/mcutie_3_0_0/pipe.rs b/Software/MainBoard/rust/src/mcutie_3_0_0/pipe.rs new file mode 100644 index 0000000..9df156f --- /dev/null +++ b/Software/MainBoard/rust/src/mcutie_3_0_0/pipe.rs @@ -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 { + connect_count: usize, + receiver_waker: Option, + sender_waker: Option, + pending: Option, +} + +fn swap_wakers(waker: &mut Option, 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, +} + +impl Future for ReceiveFuture<'_, M, T, N> { + type Output = T; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + 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, +} + +impl PipeReader<'_, M, T, N> { + #[must_use] + pub(crate) fn receive(&self) -> ReceiveFuture<'_, M, T, N> { + ReceiveFuture { pipe: self.pipe } + } +} + +impl 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, + pipe: &'a ConnectedPipe, +} + +impl Future for PushFuture<'_, M, T, N> { + type Output = (); + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + 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 { + inner: Mutex>>, +} + +impl ConnectedPipe { + 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 = 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 = 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 = 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; + } + } +} diff --git a/Software/MainBoard/rust/src/mcutie_3_0_0/publish.rs b/Software/MainBoard/rust/src/mcutie_3_0_0/publish.rs new file mode 100644 index 0000000..ef0ea14 --- /dev/null +++ b/Software/MainBoard/rust/src/mcutie_3_0_0/publish.rs @@ -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> { + 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, + pub(crate) data: B, + pub(crate) qos: QoS, + pub(crate) retain: bool, +} + +impl> 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 + '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, + pub(crate) data: D, + pub(crate) qos: QoS, + pub(crate) retain: bool, +} + +impl 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 + '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, + pub(crate) data: D, + pub(crate) qos: QoS, + pub(crate) retain: bool, +} + +#[cfg(feature = "serde")] +impl 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 + '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 + } +} diff --git a/Software/MainBoard/rust/src/mcutie_3_0_0/topic.rs b/Software/MainBoard/rust/src/mcutie_3_0_0/topic.rs new file mode 100644 index 0000000..259fd5b --- /dev/null +++ b/Software/MainBoard/rust/src/mcutie_3_0_0/topic.rs @@ -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 { + /// 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 PartialEq> for Topic +where + B: PartialEq, +{ + fn eq(&self, other: &Topic) -> 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 Topic { + /// 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>(&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(&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(&self, data: D) -> PublishJson<'_, T, D> { + PublishJson { + topic: self, + data, + qos: QoS::AtMostOnce, + retain: false, + } + } +} + +impl Topic { + pub(crate) fn from_str(mut st: &str) -> Result { + 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> Topic { + pub(crate) fn to_string(&self, result: &mut String) -> 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::::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::, 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(()) + } + } +} diff --git a/Software/MainBoard/rust/src/plant_state.rs b/Software/MainBoard/rust/src/plant_state.rs index 26f914b..5f00a8a 100644 --- a/Software/MainBoard/rust/src/plant_state.rs +++ b/Software/MainBoard/rust/src/plant_state.rs @@ -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 { if let MoistureSensorState::MoistureValue { - raw_hz: _, + hz: _, moisture_percent, } = self { @@ -49,16 +51,27 @@ pub enum PumpError { failed_attempts: usize, max_allowed_failures: usize, }, + OverCurrent { + current_ma: u16, + max_allowed_ma: u16, + }, } #[derive(Debug, Serialize)] pub struct PumpState { consecutive_pump_count: u32, previous_pump: Option>, + pub overcurrent_error: Option, } impl PumpState { fn is_err(&self, plant_config: &PlantConfig) -> Option { + if let Some(current_ma) = self.overcurrent_error { + return Some(PumpError::OverCurrent { + current_ma, + max_allowed_ma: plant_config.max_pump_current_ma, + }); + } if self.consecutive_pump_count > plant_config.max_consecutive_pump_count as u32 { Some(PumpError::PumpNotWorking { failed_attempts: self.consecutive_pump_count as usize, @@ -82,6 +95,13 @@ 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, + /// Last known firmware build timestamp for sensor B. + pub sensor_b_firmware_build_minutes: Option, + /// Last time fertilizer was applied (Unix timestamp in seconds). + pub last_fertilizer_time: i64, } fn map_range_moisture( @@ -111,74 +131,63 @@ 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, 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 last_fertilizer_time = board.board_hal.get_esp().last_fertilizer_time(plant_id); + let (a_builds, b_builds) = board.board_hal.get_sensor_build_minutes(); let state = Self { sensor_a, sensor_b, pump: PumpState { consecutive_pump_count, previous_pump, + overcurrent_error: None, }, + sensor_a_firmware_build_minutes: a_builds[plant_id], + sensor_b_firmware_build_minutes: b_builds[plant_id], + last_fertilizer_time, }; if state.is_err() { let _ = board.board_hal.fault(plant_id, true).await; @@ -301,6 +310,9 @@ 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, + last_fertilizer_time: self.last_fertilizer_time } } } @@ -329,4 +341,10 @@ pub struct PlantInfo<'a> { last_pump: Option>, /// next time when pump should activate next_pump: Option>, + /// firmware build timestamp of sensor A (minutes since Unix epoch); None if unknown + sensor_a_firmware_build_minutes: Option, + /// firmware build timestamp of sensor B (minutes since Unix epoch); None if unknown + sensor_b_firmware_build_minutes: Option, + /// last time when fertilizer was applied + last_fertilizer_time: i64, } diff --git a/Software/MainBoard/rust/src/tank.rs b/Software/MainBoard/rust/src/tank.rs index 5449826..49a30de 100644 --- a/Software/MainBoard/rust/src/tank.rs +++ b/Software/MainBoard/rust/src/tank.rs @@ -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 { diff --git a/Software/MainBoard/rust/src/webserver/backup_manager.rs b/Software/MainBoard/rust/src/webserver/backup_manager.rs index ef6c4b0..0dde713 100644 --- a/Software/MainBoard/rust/src/webserver/backup_manager.rs +++ b/Software/MainBoard/rust/src/webserver/backup_manager.rs @@ -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( 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( 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, diff --git a/Software/MainBoard/rust/src/webserver/file_manager.rs b/Software/MainBoard/rust/src/webserver/file_manager.rs deleted file mode 100644 index 03304ae..0000000 --- a/Software/MainBoard/rust/src/webserver/file_manager.rs +++ /dev/null @@ -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( - _request: &mut Connection<'_, T, N>, -) -> FatResult> { - 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( - conn: &mut Connection<'_, T, { N }>, - method: Method, - path: &&str, - prefix: &&str, -) -> Result, 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, - }) -} diff --git a/Software/MainBoard/rust/src/webserver/get_json.rs b/Software/MainBoard/rust/src/webserver/get_json.rs index 107ee90..e565a36 100644 --- a/Software/MainBoard/rust/src/webserver/get_json.rs +++ b/Software/MainBoard/rust/src/webserver/get_json.rs @@ -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( Ok(Some(serde_json::to_string(&state)?)) } -pub(crate) async fn get_version_web( +pub(crate) async fn get_firmware_info_web( _request: &mut Connection<'_, T, N>, ) -> FatResult> { 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( _request: &mut Connection<'_, T, N>, + saveidx: Option, +) -> FatResult> { + 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( + _request: &mut Connection<'_, T, N>, ) -> FatResult> { 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( + _request: &mut Connection<'_, T, N>, + idx: usize, +) -> FatResult> { + 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( @@ -147,7 +174,12 @@ pub(crate) async fn get_time( }, }; - 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(), diff --git a/Software/MainBoard/rust/src/webserver/get_log.rs b/Software/MainBoard/rust/src/webserver/get_log.rs index 0f403a0..1a4e504 100644 --- a/Software/MainBoard/rust/src/webserver/get_log.rs +++ b/Software/MainBoard/rust/src/webserver/get_log.rs @@ -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( 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, + dropped: bool, + next_seq: u64, +} + +pub(crate) async fn get_live_log(after: Option) -> FatResult> { + 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)?)) +} diff --git a/Software/MainBoard/rust/src/webserver/mod.rs b/Software/MainBoard/rust/src/webserver/mod.rs index 052b39b..5a6f382 100644 --- a/Software/MainBoard/rust/src/webserver/mod.rs +++ b/Software/MainBoard/rust/src/webserver/mod.rs @@ -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,17 +9,16 @@ 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::{ - board_test, can_power, detect_sensors, night_lamp_test, pump_test, set_config, wifi_scan, - write_time, + board_test, can_power, detect_sensors, fertilizer_pump_test, night_lamp_test, pump_test, + set_config, wifi_scan, write_time, }; use crate::{bail, BOARD_ACCESS}; use alloc::borrow::ToOwned; @@ -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 = 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 = 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 { @@ -106,6 +116,7 @@ impl Handler for HTTPRequestRouter { "/pumptest" => Some(pump_test(conn).await), "/can_power" => Some(can_power(conn).await), "/lamptest" => Some(night_lamp_test(conn).await), + "/fertilizerpumptest" => Some(fertilizer_pump_test(conn).await), "/boardtest" => Some(board_test().await), "/detect_sensors" => Some(detect_sensors(conn).await), "/reboot" => { @@ -127,7 +138,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 = 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 +213,7 @@ where } #[embassy_executor::task] +#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] pub async fn http_server(reboot_now: Arc, stack: Stack<'static>) { let buffer: TcpBuffers<2, 1024, 1024> = TcpBuffers::new(); let tcp = Tcp::new(stack, &buffer); @@ -241,6 +274,7 @@ where Err(err) => { let error_text = err.to_string(); info!("error handling process {error_text}"); + conn.initiate_response( 500, Some("OK"), diff --git a/Software/MainBoard/rust/src/webserver/post_json.rs b/Software/MainBoard/rust/src/webserver/post_json.rs index c92fa75..8b6201c 100644 --- a/Software/MainBoard/rust/src/webserver/post_json.rs +++ b/Software/MainBoard/rust/src/webserver/post_json.rs @@ -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( let mut board = BOARD_ACCESS.get().await.lock().await; info!("start wifi scan"); let mut ssids: Vec = Vec::new(); - let scan_result = board.board_hal.get_esp().wifi_scan().await?; + let scan_result: Vec = 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)?; @@ -100,6 +102,19 @@ where Ok(None) } +pub(crate) async fn fertilizer_pump_test( + _request: &mut Connection<'_, T, N>, +) -> FatResult> +where + T: Read + Write, +{ + let mut board = BOARD_ACCESS.get().await.lock().await; + board.board_hal.fertilizer_pump(true).await?; + embassy_time::Timer::after_millis(1000).await; + board.board_hal.fertilizer_pump(false).await?; + Ok(None) +} + pub(crate) async fn write_time( request: &mut Connection<'_, T, N>, ) -> FatResult> @@ -108,8 +123,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) } diff --git a/Software/MainBoard/rust/src_webpack/src/api.ts b/Software/MainBoard/rust/src_webpack/src/api.ts index 876390b..ec0e4fa 100644 --- a/Software/MainBoard/rust/src_webpack/src/api.ts +++ b/Software/MainBoard/rust/src_webpack/src/api.ts @@ -1,6 +1,17 @@ export interface LogArray extends Array { } +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 { @@ -110,6 +128,8 @@ export interface PlantConfig { min_moisture: number, pump_time_s: number, pump_cooldown_min: number, + fertilizer_s: number, + fertilizer_cooldown_min: number, pump_hour_start: number, pump_hour_end: number, pump_limit_ml: number, @@ -165,6 +185,10 @@ export interface VersionInfo { current: string, slot0_state: string, slot1_state: string, + heap_total: number, + heap_used: number, + heap_free: number, + heap_max_used: number, } export interface BatteryState { @@ -177,9 +201,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 { diff --git a/Software/MainBoard/rust/src_webpack/src/fileview.html b/Software/MainBoard/rust/src_webpack/src/fileview.html index 3c7f49a..eae1b2f 100644 --- a/Software/MainBoard/rust/src_webpack/src/fileview.html +++ b/Software/MainBoard/rust/src_webpack/src/fileview.html @@ -29,44 +29,10 @@ } -
Files:
-
-
Total Size
-
-
-
-
Used Size
-
-
-
-
Free Size
-
-
-
- -
-
Upload:
-
-
-
-
- File: -
- -
-
-
- Name: -
- -
-
-
- -
+
Save Slots:

List:
-
\ No newline at end of file + diff --git a/Software/MainBoard/rust/src_webpack/src/fileview.ts b/Software/MainBoard/rust/src_webpack/src/fileview.ts index 4a30f72..5f44cb0 100644 --- a/Software/MainBoard/rust/src_webpack/src/fileview.ts +++ b/Software/MainBoard/rust/src_webpack/src/fileview.ts @@ -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"; + } } -} \ No newline at end of file +} diff --git a/Software/MainBoard/rust/src_webpack/src/fileviewentry.html b/Software/MainBoard/rust/src_webpack/src/fileviewentry.html index b602480..898141b 100644 --- a/Software/MainBoard/rust/src_webpack/src/fileviewentry.html +++ b/Software/MainBoard/rust/src_webpack/src/fileviewentry.html @@ -1,11 +1,17 @@
-
Name
+
Slot
+
+ +
+
Created
+
Size
-
-
Download - +
+ Download +
+ diff --git a/Software/MainBoard/rust/src_webpack/src/hardware.html b/Software/MainBoard/rust/src_webpack/src/hardware.html index 0b2a24b..1722cdd 100644 --- a/Software/MainBoard/rust/src_webpack/src/hardware.html +++ b/Software/MainBoard/rust/src_webpack/src/hardware.html @@ -18,3 +18,12 @@ +
+
Pump corrosion protection (weekly)
+ +
+ +
Fertilizer Pump:
+
+ +
diff --git a/Software/MainBoard/rust/src_webpack/src/hardware.ts b/Software/MainBoard/rust/src_webpack/src/hardware.ts index 86758f2..64462a9 100644 --- a/Software/MainBoard/rust/src_webpack/src/hardware.ts +++ b/Software/MainBoard/rust/src_webpack/src/hardware.ts @@ -4,6 +4,8 @@ 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; + private readonly fertilizer_pump_test: HTMLButtonElement; constructor(controller:Controller){ (document.getElementById("hardwareview") as HTMLElement).innerHTML = require('./hardware.html') as string; @@ -29,17 +31,27 @@ 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 + + this.fertilizer_pump_test = document.getElementById("fertilizer_pump_test") as HTMLButtonElement; + this.fertilizer_pump_test.onclick = () => { + controller.testFertilizerPump(); + } } 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, } } } diff --git a/Software/MainBoard/rust/src_webpack/src/log.html b/Software/MainBoard/rust/src_webpack/src/log.html index 05c6be7..3863bf4 100644 --- a/Software/MainBoard/rust/src_webpack/src/log.html +++ b/Software/MainBoard/rust/src_webpack/src/log.html @@ -1,7 +1,48 @@ - -
- -
\ No newline at end of file + +

Application Log

+
+ +

Live Log

+
diff --git a/Software/MainBoard/rust/src_webpack/src/log.ts b/Software/MainBoard/rust/src_webpack/src/log.ts index 1e38724..0b8b914 100644 --- a/Software/MainBoard/rust/src_webpack/src/log.ts +++ b/Software/MainBoard/rust/src_webpack/src/log.ts @@ -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 | 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); + }); } -} \ No newline at end of file + + 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; + } + } +} diff --git a/Software/MainBoard/rust/src_webpack/src/main.ts b/Software/MainBoard/rust/src_webpack/src/main.ts index 5469923..996ca2d 100644 --- a/Software/MainBoard/rust/src_webpack/src/main.ts +++ b/Software/MainBoard/rust/src_webpack/src/main.ts @@ -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 { @@ -93,65 +91,36 @@ export class Controller { } } - async updateFileList(): Promise { + async updateSaveList(): Promise { 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 { @@ -225,7 +194,7 @@ export class Controller { async version(): Promise { 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) + } }) } @@ -327,6 +304,12 @@ export class Controller { }) } + testFertilizerPump() { + fetch(PUBLIC_URL + "/fertilizerpumptest", { + method: "POST" + }) + } + testPlant(plantId: number) { let counter = 0 let limit = 30 @@ -362,7 +345,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 +505,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 => { @@ -600,7 +583,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, @@ -638,7 +621,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 +651,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 +673,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"); diff --git a/Software/MainBoard/rust/src_webpack/src/ota.html b/Software/MainBoard/rust/src_webpack/src/ota.html index 0d18370..9c95f8e 100644 --- a/Software/MainBoard/rust/src_webpack/src/ota.html +++ b/Software/MainBoard/rust/src_webpack/src/ota.html @@ -21,6 +21,7 @@
Current Firmware
+
Buildtime: @@ -42,12 +43,35 @@ State1:
-

+
+
+ Heap Memory +
+
+
+
+ Free: + +
+
+ Used: + +
+
+ Total: + +
+
+ Peak used: + +
+ +
\ No newline at end of file diff --git a/Software/MainBoard/rust/src_webpack/src/ota.ts b/Software/MainBoard/rust/src_webpack/src/ota.ts index c407932..b7c6f69 100644 --- a/Software/MainBoard/rust/src_webpack/src/ota.ts +++ b/Software/MainBoard/rust/src_webpack/src/ota.ts @@ -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_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_free = document.getElementById("heap_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_free.innerText = fmtBytes(versionInfo.heap_free); + 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); } } \ No newline at end of file diff --git a/Software/MainBoard/rust/src_webpack/src/plant.html b/Software/MainBoard/rust/src_webpack/src/plant.html index f4737fc..1ef21b6 100644 --- a/Software/MainBoard/rust/src_webpack/src/plant.html +++ b/Software/MainBoard/rust/src_webpack/src/plant.html @@ -78,6 +78,16 @@ +
+
Fertilizer (s):
+ +
+
+
Fertilizer Cooldown (m):
+ +
"Pump Hour Start":
@@ -126,17 +136,25 @@
Live:
- - + +
Sensor A: not measured
+
+ Sensor A FW: + unknown +
Sensor B:
not measured
+
+ Sensor B FW: + unknown +
Max Current
not_tested diff --git a/Software/MainBoard/rust/src_webpack/src/plant.ts b/Software/MainBoard/rust/src_webpack/src/plant.ts index 702e282..4c763fc 100644 --- a/Software/MainBoard/rust/src_webpack/src/plant.ts +++ b/Software/MainBoard/rust/src_webpack/src/plant.ts @@ -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"; @@ -72,6 +79,8 @@ export class PlantView { private readonly minMoisture: HTMLInputElement; private readonly pumpTimeS: HTMLInputElement; private readonly pumpCooldown: HTMLInputElement; + private readonly fertilizerS: HTMLInputElement; + private readonly fertilizerCooldownMin: HTMLInputElement; private readonly pumpHourStart: HTMLSelectElement; private readonly pumpHourEnd: HTMLSelectElement; private readonly sensorAInstalled: HTMLInputElement; @@ -79,6 +88,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 +120,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 +137,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 +148,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, @@ -169,6 +182,16 @@ export class PlantView { controller.configChanged() } + this.fertilizerS = document.getElementById("plant_" + plantId + "_fertilizer_s") as HTMLInputElement; + this.fertilizerS.onchange = function () { + controller.configChanged() + } + + this.fertilizerCooldownMin = document.getElementById("plant_" + plantId + "_fertilizer_cooldown_min") as HTMLInputElement; + this.fertilizerCooldownMin.onchange = function () { + controller.configChanged() + } + this.pumpHourStart = document.getElementById("plant_" + plantId + "_pump_hour_start") as HTMLSelectElement; this.pumpHourStart.onchange = function () { controller.configChanged() @@ -317,6 +340,8 @@ export class PlantView { this.minMoisture.value = plantConfig.min_moisture?.toString() || ""; this.pumpTimeS.value = plantConfig.pump_time_s.toString(); this.pumpCooldown.value = plantConfig.pump_cooldown_min.toString(); + this.fertilizerS.value = plantConfig.fertilizer_s?.toString() || "0"; + this.fertilizerCooldownMin.value = plantConfig.fertilizer_cooldown_min?.toString() || "1440"; this.pumpHourStart.value = plantConfig.pump_hour_start.toString(); this.pumpHourEnd.value = plantConfig.pump_hour_end.toString(); this.sensorBInstalled.checked = plantConfig.sensor_b; @@ -344,6 +369,8 @@ export class PlantView { pump_time_s: this.pumpTimeS.valueAsNumber, pump_limit_ml: 5000, pump_cooldown_min: this.pumpCooldown.valueAsNumber, + fertilizer_s: this.fertilizerS.valueAsNumber || 0, + fertilizer_cooldown_min: this.fertilizerCooldownMin.valueAsNumber || 1440, pump_hour_start: +this.pumpHourStart.value, pump_hour_end: +this.pumpHourEnd.value, sensor_b: this.sensorBInstalled.checked, @@ -360,19 +387,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); } } \ No newline at end of file diff --git a/Software/MainBoard/rust/src_webpack/src/submitView.ts b/Software/MainBoard/rust/src_webpack/src/submitView.ts index 6cf6b60..55ef5e4 100644 --- a/Software/MainBoard/rust/src_webpack/src/submitView.ts +++ b/Software/MainBoard/rust/src_webpack/src/submitView.ts @@ -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; diff --git a/Software/Shared/canapi/src/lib.rs b/Software/Shared/canapi/src/lib.rs index 9a119c3..c06c248 100644 --- a/Software/Shared/canapi/src/lib.rs +++ b/Software/Shared/canapi/src/lib.rs @@ -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 }