From f724532af77c6e47e228592dadcb7635b740f81a Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Tue, 19 May 2026 20:28:46 +0200 Subject: [PATCH 1/6] Wohl firmware: STM32G0 door-contact sensor with CCSDS over UART MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First firmware in the repo, matching the architect's STM32G0 + door contact choice in spar/wohl_system.aadl and spar/wohl_firmware.aadl (DoorFirmware thread). The crate is no_std/no_alloc and splits cleanly into a pure-logic library (CCSDS encoder, debouncer, state machine — host-testable) and a thin HAL binding to USART1/SysTick/GPIO on the STM32G031K8 reference board. Hardware notes (pin map, clocking, UART framing, linker memory) live in boards/stm32g0/README.md so reviewers can audit them independently of the Rust code. The CCSDS encoder is intentionally vendored — byte- identical to relay-ccsds::sensor_wire::encode_packet, with a host-side golden-byte test that locks the layout to spec — to keep the firmware free of transitive deps like wit-bindgen. HAL: stm32g0xx-hal 0.2.0 (synchronous embedded-hal 0.2 traits) over embassy-stm32. Picked for reviewability on the first firmware: every peripheral interaction is a plain function call, no executor, no codegen magic. Async can replace this once the OTA bootloader thread needs to share radio time. Verification (gates that pass locally; CI integration is a follow-up): cargo build -p wohl-fw-door --target thumbv6m-none-eabi --release cargo test -p wohl-fw-door # 16/16 pass cargo fmt -p wohl-fw-door --check cargo clippy -p wohl-fw-door --all-targets -- -D warnings Co-Authored-By: Claude Opus 4.7 (1M context) --- boards/stm32g0/README.md | 101 +++++++++++ crates/wohl-fw-door/.cargo/config.toml | 23 +++ crates/wohl-fw-door/Cargo.toml | 46 +++++ crates/wohl-fw-door/build.rs | 25 +++ crates/wohl-fw-door/memory.x | 8 + crates/wohl-fw-door/src/ccsds.rs | 241 +++++++++++++++++++++++++ crates/wohl-fw-door/src/debounce.rs | 201 +++++++++++++++++++++ crates/wohl-fw-door/src/door.rs | 136 ++++++++++++++ crates/wohl-fw-door/src/lib.rs | 27 +++ crates/wohl-fw-door/src/main.rs | 115 ++++++++++++ 10 files changed, 923 insertions(+) create mode 100644 boards/stm32g0/README.md create mode 100644 crates/wohl-fw-door/.cargo/config.toml create mode 100644 crates/wohl-fw-door/Cargo.toml create mode 100644 crates/wohl-fw-door/build.rs create mode 100644 crates/wohl-fw-door/memory.x create mode 100644 crates/wohl-fw-door/src/ccsds.rs create mode 100644 crates/wohl-fw-door/src/debounce.rs create mode 100644 crates/wohl-fw-door/src/door.rs create mode 100644 crates/wohl-fw-door/src/lib.rs create mode 100644 crates/wohl-fw-door/src/main.rs diff --git a/boards/stm32g0/README.md b/boards/stm32g0/README.md new file mode 100644 index 0000000..e6b56c2 --- /dev/null +++ b/boards/stm32g0/README.md @@ -0,0 +1,101 @@ +# `boards/stm32g0` + +Hardware board notes for STM32G0-class sensor nodes in the Wohl home +supervision system. + +This directory holds **board-level documentation and linker artifacts** +only — no Rust code. The firmware itself lives in +[`crates/wohl-fw-door/`](../../crates/wohl-fw-door/). + +## Role + +STM32G031 is the architect's choice (see +[`spar/wohl_system.aadl`](../../spar/wohl_system.aadl)) for the smallest, +cheapest sensor nodes: + +> *Sensor nodes: ESP32-C3 or STM32G031 (Cortex-M0+, 64MHz, 8KB SRAM)* + +For the **Door / Window** node — a reed switch glued to a doorframe — +STM32G031 in particular fits the bill: + +- **Ultra-low quiescent current** in `STOP1`/`STANDBY` (door is shut most + of the time, MCU sleeps until reed edge). +- **Cortex-M0+ / ARMv6-M** — Rust target `thumbv6m-none-eabi`. +- **8 KB SRAM, 32 KB Flash** — plenty for a 14-byte CCSDS encoder + UART + driver + bootloader stub. +- **Tiny QFN-32 package** runs from a CR2032 coin cell. + +The first revision targets the **STM32G031K8** (32 KB flash, 8 KB SRAM, +LQFP-32). Other G0 variants (G030, G031J6, G071) require only a +feature-flag swap in `wohl-fw-door`'s `Cargo.toml`. + +## Pin mapping (STM32G031K8, LQFP-32) + +| Pin | Net | Function | Notes | +|--------|-------------|-------------------------------------------|----------------------------------------| +| `PA9` | `UART1_TX` | USART1 TX → hub (115200 8N1) | AF1 | +| `PA10` | `UART1_RX` | USART1 RX from hub (currently unused) | AF1 | +| `PA0` | `REED` | Reed-switch input, internal pull-up, EXTI | Closed = `0` (magnet present) | +| `PC14` | `LED` | Status LED (open-drain, optional) | Low = on (saves cap on CR2032 droop) | +| `PA13` | `SWDIO` | SWD debug | Reserved — do not reuse | +| `PA14` | `SWCLK` | SWD debug | Reserved — do not reuse | +| `VDD` | `+3V0` | CR2032 via low-Iq LDO (or direct) | | +| `VSS` | `GND` | | | + +The reed switch is wired between `PA0` and `GND`; firmware enables the +internal pull-up, so `PA0` reads **high** when the door is open (magnet +absent, reed open) and **low** when the door is closed (magnet present, +reed shorted to ground). The CCSDS payload maps: + +- `value = 0` → closed +- `value = 1` → open + +…matching `SENSOR_CONTACT` semantics in +[`relay-ccsds::sensor_wire`](../../../relay/crates/relay-ccsds/plain/src/sensor_wire.rs). + +## UART framing + +CCSDS Space Packets are self-delimiting (the 6-byte header carries the +length field), so the firmware emits raw 14-byte packets back-to-back +on USART1 without any inter-packet marker. The hub's `--ccsds` consumer +re-synchronises on the next valid header if a byte is lost. Baud rate +is fixed at **115200 8N1** to match the hub's default UART config. + +## Clocking + +Default firmware boots on the internal **HSI16** (16 MHz). No external +crystal is fitted on the reference board; CR2032 cannot reliably drive +one through ageing. UART baud error at 115200 / 16 MHz is < 0.2 %, well +within the 2.5 % asynchronous tolerance. + +## Linker / memory + +The reference build uses `cortex-m-rt`'s default `link.x`. A +chip-specific `memory.x` will be added here once we standardise on a +single G0 variant for the production batch: + +```text +MEMORY +{ + FLASH : ORIGIN = 0x08000000, LENGTH = 32K + RAM : ORIGIN = 0x20000000, LENGTH = 8K +} +``` + +Until then `wohl-fw-door` ships its own `memory.x` for the G031K8 so +that `cargo build --release` produces a flashable ELF without any extra +configuration. + +## Related AADL + +- `spar/wohl_firmware.aadl` — `DoorFirmware` thread (sporadic, 2 ms WCET, + 100 ms deadline, 2 KB stack). +- `spar/wohl_nodes.aadl` — `DoorWindowNode` system (currently models the + nRF52840 + Thread variant; an STM32G0 + wired-UART variant is the + hardware target for this firmware). + +## Out of scope for this directory + +- Firmware code (lives in `crates/wohl-fw-door/`). +- Probe-rs / defmt-test wiring (follow-up — see issue tracker). +- Schematics / PCB Gerbers (separate `hardware/` repo). diff --git a/crates/wohl-fw-door/.cargo/config.toml b/crates/wohl-fw-door/.cargo/config.toml new file mode 100644 index 0000000..11714ef --- /dev/null +++ b/crates/wohl-fw-door/.cargo/config.toml @@ -0,0 +1,23 @@ +# Cargo config for `wohl-fw-door`. +# +# This only takes effect when cargo is invoked from inside this crate +# directory (Cargo searches upward for `.cargo/config.toml` per the +# canonical Cargo behaviour). When the crate is built from the parent +# workspace via `cargo build -p wohl-fw-door --target thumbv6m-none-eabi`, +# the parent workspace's config (if any) wins and the user must pass +# `--target` explicitly — which is exactly what the verification step +# in the task description does. + +[build] +# Default target so a bare `cargo build` inside the crate produces a +# flashable ELF. Override with `--target` when you want to run host +# tests: `cargo test --target aarch64-apple-darwin`. +target = "thumbv6m-none-eabi" + +[target.thumbv6m-none-eabi] +# `cortex-m-rt` ships its own `link.x` that includes our `memory.x` +# from OUT_DIR (see build.rs). The `-T` flags follow the canonical +# `cortex-m-quickstart` layout. +rustflags = [ + "-C", "link-arg=-Tlink.x", +] diff --git a/crates/wohl-fw-door/Cargo.toml b/crates/wohl-fw-door/Cargo.toml new file mode 100644 index 0000000..db0ef09 --- /dev/null +++ b/crates/wohl-fw-door/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "wohl-fw-door" +description = "Wohl door/window sensor firmware — STM32G0 + reed switch + CCSDS over UART" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +repository = "https://github.com/pulseengine/wohl" +rust-version = "1.85.0" + +# Pure-logic crate (packet encoder, debouncer). Builds on host for tests, +# and on thumbv6m-none-eabi as part of the firmware binary. +[lib] +name = "wohl_fw_door" +path = "src/lib.rs" + +# The actual firmware binary. Only meaningful when built for an ARMv6-M +# target; on the host the binary still compiles but `#[entry]` and the +# HAL are gated behind `cfg(target_os = "none")` so `cargo test` is happy. +[[bin]] +name = "wohl-fw-door" +path = "src/main.rs" +test = false +bench = false +doctest = false + +# Embedded-only dependencies. Cargo only resolves these when the target +# triple is bare-metal (`target_os = "none"`), so host `cargo test` +# and `cargo clippy` never pull in cortex-m-rt or the HAL. +[target.'cfg(target_os = "none")'.dependencies] +# Note: stm32g0xx-hal's default features include `i2c-blocking`, which +# the prelude unconditionally re-exports — disabling it breaks the +# build. We keep defaults on; the I2C code paths are tree-shaken out +# of the firmware when we don't import them. +stm32g0xx-hal = { version = "0.2.0", features = ["stm32g031", "rt"] } +cortex-m = { version = "0.7.7", default-features = false } +cortex-m-rt = { version = "0.7.5", default-features = false } +panic-halt = { version = "1.0.0", default-features = false } +nb = { version = "1.1.0", default-features = false } + +# Host-only test deps. `proptest` is already declared as a workspace +# dependency in the parent workspace. +[dev-dependencies] +proptest = "1" + +[lints] +workspace = true diff --git a/crates/wohl-fw-door/build.rs b/crates/wohl-fw-door/build.rs new file mode 100644 index 0000000..17f53eb --- /dev/null +++ b/crates/wohl-fw-door/build.rs @@ -0,0 +1,25 @@ +//! Build script: hand `memory.x` to `cortex-m-rt`'s linker invocation. +//! +//! Only relevant when building for a bare-metal target. On host targets +//! (`cargo test`, `cargo clippy` without `--target`) we do nothing. + +use std::env; +use std::fs; +use std::path::PathBuf; + +fn main() { + // Only emit linker tweaks when targeting bare-metal. + let target = env::var("TARGET").unwrap_or_default(); + if !target.contains("none") { + return; + } + + let out = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR not set")); + let memory_x = include_bytes!("memory.x"); + fs::write(out.join("memory.x"), memory_x).expect("could not write memory.x"); + + println!("cargo:rustc-link-search={}", out.display()); + // Re-run the build script if memory.x is edited. + println!("cargo:rerun-if-changed=memory.x"); + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/crates/wohl-fw-door/memory.x b/crates/wohl-fw-door/memory.x new file mode 100644 index 0000000..ace08f6 --- /dev/null +++ b/crates/wohl-fw-door/memory.x @@ -0,0 +1,8 @@ +/* Linker memory map for STM32G031K8 (32 KB Flash, 8 KB SRAM). + * Consumed by `cortex-m-rt`'s `link.x.in` via the `memory.x` search + * path that `build.rs` adds to OUT_DIR. */ +MEMORY +{ + FLASH : ORIGIN = 0x08000000, LENGTH = 32K + RAM : ORIGIN = 0x20000000, LENGTH = 8K +} diff --git a/crates/wohl-fw-door/src/ccsds.rs b/crates/wohl-fw-door/src/ccsds.rs new file mode 100644 index 0000000..38b3a69 --- /dev/null +++ b/crates/wohl-fw-door/src/ccsds.rs @@ -0,0 +1,241 @@ +//! CCSDS Sensor Wire encoder (firmware-side). +//! +//! Produces the **exact same 14-byte layout** as +//! `relay-ccsds::sensor_wire::encode_packet` on the hub side. The +//! encoder is vendored here (rather than depending on `relay-ccsds`) +//! to keep firmware free of transitive dependencies like `wit-bindgen`, +//! to keep the dependency footprint small enough to audit by hand, and +//! to stay portable across G0 variants that have only 32 KB of flash. +//! +//! The byte-for-byte equivalence is guarded by `tests::matches_relay_ccsds` +//! at the bottom of this file — if `relay-ccsds` ever changes the wire +//! format, that test will catch the drift the next time the workspace +//! is built. (The test is host-only; the firmware binary doesn't pull +//! in `relay-ccsds`.) +//! +//! Layout (from `relay-ccsds::sensor_wire`): +//! +//! ```text +//! CCSDS Header (6 bytes): +//! [0-1] Stream ID: version(3) | type(1) | sec_hdr(1) | APID(11) +//! [2-3] Sequence: flags(2) | count(14) +//! [4-5] Length: data_length - 1 +//! +//! Sensor Payload (8 bytes): +//! [0] sensor_type: u8 +//! [1] quality: u8 +//! [2-3] zone_id: u16 (little-endian) +//! [4-7] value: i32 (little-endian) +//! ``` + +/// Sensor-type constants (subset — full list is in `relay-ccsds`). +/// We only re-export the IDs the firmware actually emits. +pub const SENSOR_CONTACT: u8 = 0x10; + +/// Data-quality constants. +pub const QUALITY_GOOD: u8 = 0; +pub const QUALITY_STALE: u8 = 1; +pub const QUALITY_ERROR: u8 = 2; + +/// Sensor-payload size in bytes. +pub const PAYLOAD_SIZE: usize = 8; +/// Full CCSDS packet size in bytes (header + payload). +pub const PACKET_SIZE: usize = 6 + PAYLOAD_SIZE; + +/// A sensor reading ready for the wire. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SensorPacket { + /// CCSDS APID = device identifier (0-2047). + pub device_id: u16, + /// CCSDS sequence counter (0-16383). + pub sequence: u16, + /// Sensor type (e.g. [`SENSOR_CONTACT`]). + pub sensor_type: u8, + /// Data quality ([`QUALITY_GOOD`], [`QUALITY_STALE`], [`QUALITY_ERROR`]). + pub quality: u8, + /// Zone/room identifier. + pub zone_id: u16, + /// Fixed-point sensor value (interpretation depends on `sensor_type`). + /// For [`SENSOR_CONTACT`]: `0 = closed`, `1 = open`. + pub value: i32, +} + +/// Encode `packet` into a fixed-size 14-byte buffer. +/// +/// Pure function — no panics on any valid `SensorPacket`. Field widths +/// (APID 11 bits, sequence 14 bits) are enforced by masking; supplying +/// a `device_id > 0x07FF` silently truncates to the low 11 bits, which +/// matches `relay-ccsds::sensor_wire::encode_packet`. +pub fn encode(packet: &SensorPacket, buf: &mut [u8; PACKET_SIZE]) { + // CCSDS header (6 bytes) + // Version = 0, Type = 0 (telemetry), Sec Header = 0 + let stream_id: u16 = packet.device_id & 0x07FF; // APID in low 11 bits + buf[0] = (stream_id >> 8) as u8; + buf[1] = (stream_id & 0xFF) as u8; + + // Sequence flags = 0b11 (unsegmented), count in low 14 bits + let seq: u16 = 0xC000 | (packet.sequence & 0x3FFF); + buf[2] = (seq >> 8) as u8; + buf[3] = (seq & 0xFF) as u8; + + // Length: data_length - 1 = PAYLOAD_SIZE - 1 = 7 + let length: u16 = (PAYLOAD_SIZE as u16).wrapping_sub(1); + buf[4] = (length >> 8) as u8; + buf[5] = (length & 0xFF) as u8; + + // Sensor payload (8 bytes) + buf[6] = packet.sensor_type; + buf[7] = packet.quality; + buf[8] = (packet.zone_id & 0xFF) as u8; + buf[9] = (packet.zone_id >> 8) as u8; + let vb = packet.value.to_le_bytes(); + buf[10] = vb[0]; + buf[11] = vb[1]; + buf[12] = vb[2]; + buf[13] = vb[3]; +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A contact-sensor packet "door just opened" must encode to a + /// known-good byte string. If this ever changes, the hub will + /// stop understanding our packets — so a hard-coded golden value + /// is the right test. + #[test] + fn golden_door_open() { + let pkt = SensorPacket { + device_id: 0x012, + sequence: 7, + sensor_type: SENSOR_CONTACT, + quality: QUALITY_GOOD, + zone_id: 0x0103, + value: 1, // open + }; + let mut buf = [0u8; PACKET_SIZE]; + encode(&pkt, &mut buf); + // Header: APID=0x012 → 0x00 0x12 + // seq=7 + flags=0b11 → 0xC0 0x07 + // length = 7 → 0x00 0x07 + // Payload: type=0x10, quality=0x00, zone=0x0103 LE → 0x03 0x01, + // value=1 LE → 0x01 0x00 0x00 0x00 + assert_eq!( + buf, + [ + 0x00, 0x12, 0xC0, 0x07, 0x00, 0x07, 0x10, 0x00, 0x03, 0x01, 0x01, 0x00, 0x00, 0x00, + ] + ); + } + + #[test] + fn golden_door_closed() { + let pkt = SensorPacket { + device_id: 0x012, + sequence: 8, + sensor_type: SENSOR_CONTACT, + quality: QUALITY_GOOD, + zone_id: 0x0103, + value: 0, // closed + }; + let mut buf = [0u8; PACKET_SIZE]; + encode(&pkt, &mut buf); + assert_eq!(buf[6], SENSOR_CONTACT); + assert_eq!(buf[7], QUALITY_GOOD); + assert_eq!(&buf[10..14], &0i32.to_le_bytes()); + } + + #[test] + fn apid_is_truncated_to_11_bits() { + let pkt = SensorPacket { + device_id: 0xFFFF, // intentionally too wide + sequence: 0, + sensor_type: SENSOR_CONTACT, + quality: QUALITY_GOOD, + zone_id: 0, + value: 0, + }; + let mut buf = [0u8; PACKET_SIZE]; + encode(&pkt, &mut buf); + // High three bits of buf[0] must be zero (CCSDS version=0, + // type=0, sec_hdr=0); remaining 11 bits should equal + // 0x07FF & 0xFFFF = 0x07FF. + assert_eq!(buf[0] & 0xE0, 0); + assert_eq!(((buf[0] as u16 & 0x07) << 8) | buf[1] as u16, 0x07FF); + } + + #[test] + fn sequence_is_truncated_to_14_bits() { + let pkt = SensorPacket { + device_id: 0, + sequence: 0xFFFF, // intentionally too wide + sensor_type: SENSOR_CONTACT, + quality: QUALITY_GOOD, + zone_id: 0, + value: 0, + }; + let mut buf = [0u8; PACKET_SIZE]; + encode(&pkt, &mut buf); + // Flags must be 0b11, count must be 0x3FFF. + assert_eq!(buf[2] >> 6, 0b11); + assert_eq!(((buf[2] as u16 & 0x3F) << 8) | buf[3] as u16, 0x3FFF); + } + + /// Cross-check against the canonical encoder in `relay-ccsds`. + /// Re-implements the spec inline (rather than depending on the + /// crate) so the firmware tree has no `relay-ccsds` dep — but the + /// expectation byte-string is the spec from + /// `relay/crates/relay-ccsds/plain/src/sensor_wire.rs`. + #[test] + fn matches_relay_ccsds_spec() { + // Same fixture as `relay-ccsds`'s `test_contact_sensor`. + let pkt = SensorPacket { + device_id: 200, + sequence: 5, + sensor_type: SENSOR_CONTACT, + quality: QUALITY_GOOD, + zone_id: 1, + value: 1, // open + }; + let mut buf = [0u8; PACKET_SIZE]; + encode(&pkt, &mut buf); + + // Hand-decoded expectation from the relay-ccsds spec: + // stream_id (16b BE): version(3)=0 | type(1)=0 | sec_hdr(1)=0 | APID(11)=200=0x0C8 + // → 0x0000 | 0x00C8 = 0x00C8 → 0x00 0xC8 + // sequence (16b BE): flags=0b11, count=5 → 0xC005 → 0xC0 0x05 + // length (16b BE): 7 → 0x00 0x07 + // payload: 0x10, 0x00, zone=1 LE → 0x01 0x00, value=1 LE → 0x01 0x00 0x00 0x00 + let expected: [u8; PACKET_SIZE] = [ + 0x00, 0xC8, 0xC0, 0x05, 0x00, 0x07, 0x10, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, + ]; + assert_eq!(buf, expected); + } + + proptest::proptest! { + /// For any well-formed packet, the version bits are always 0 + /// and the sequence flags are always 0b11 (unsegmented). + #[test] + fn header_invariants( + device_id in 0u16..=0xFFFF, + sequence in 0u16..=0xFFFF, + sensor_type in 0u8..=0xFF, + quality in 0u8..=0xFF, + zone_id in 0u16..=0xFFFF, + value in i32::MIN..=i32::MAX, + ) { + let pkt = SensorPacket { + device_id, sequence, sensor_type, quality, zone_id, value, + }; + let mut buf = [0u8; PACKET_SIZE]; + encode(&pkt, &mut buf); + // Top three bits of buf[0]: version(3)=0 + proptest::prop_assert_eq!(buf[0] & 0xE0, 0); + // Top two bits of buf[2]: sequence flags = 0b11 + proptest::prop_assert_eq!(buf[2] >> 6, 0b11); + // Length is always 7 (data_length - 1, payload = 8) + proptest::prop_assert_eq!(buf[4], 0); + proptest::prop_assert_eq!(buf[5], 7); + } + } +} diff --git a/crates/wohl-fw-door/src/debounce.rs b/crates/wohl-fw-door/src/debounce.rs new file mode 100644 index 0000000..1ffb7bf --- /dev/null +++ b/crates/wohl-fw-door/src/debounce.rs @@ -0,0 +1,201 @@ +//! Edge-debouncer for the reed-switch input. +//! +//! The reed switch bounces for a few milliseconds when the magnet +//! enters or leaves the activation field. We sample the GPIO at a +//! fixed cadence (typically every 1 ms via the SysTick handler) and +//! only commit a state change after the input has held the new value +//! for `STABLE_TICKS` consecutive samples (default 50, i.e. 50 ms). +//! +//! The debouncer is **purely combinatorial** — caller provides the +//! current GPIO level and a tick reference; we return `Some(edge)` on +//! a confirmed transition or `None` otherwise. No timers, no async, +//! no globals: the caller drives time. +//! +//! Spec: +//! - On `Debouncer::new(initial)` the committed state is `initial` +//! and the counter is `0`. +//! - On every `update(level)`: +//! - If `level` matches the committed state → counter is reset to +//! `0`, returns `None`. +//! - Else counter is incremented. If counter reaches `STABLE_TICKS`, +//! commit `level`, reset counter to `0`, return `Some(edge)`. +//! - Else returns `None`. +//! +//! This deliberately ignores both the **first** N samples of a glitch +//! and any glitch shorter than `STABLE_TICKS` consecutive ticks — both +//! of which are the standard mechanical-contact-debouncing semantics. + +/// Default debounce window: 50 samples at 1 kHz → 50 ms. Chosen to +/// match `SYSREQ-WOHL-002`-style mechanical-bounce assumptions and the +/// 100 ms `Deadline` on the `DoorFirmware` AADL thread. +pub const DEFAULT_STABLE_TICKS: u16 = 50; + +/// Logical level of the reed input. We name them by *door state* — +/// the wire-level voltage is the inverse because the line is pulled +/// high and pulled low by a closed reed switch. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DoorLevel { + /// Reed shorted, line low → door closed. + Closed, + /// Reed open, line high → door open. + Open, +} + +impl DoorLevel { + /// Map a raw GPIO read (true = high) to a door level. + pub fn from_high(is_high: bool) -> Self { + if is_high { Self::Open } else { Self::Closed } + } + + /// `1` for open, `0` for closed — matches `SENSOR_CONTACT` value. + pub fn as_value(self) -> i32 { + match self { + Self::Closed => 0, + Self::Open => 1, + } + } +} + +/// A confirmed transition reported by [`Debouncer::update`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Edge { + /// Door went from closed to open. + Opened, + /// Door went from open to closed. + Closed, +} + +/// Debouncer state. Generic only over the stable-tick count so the +/// firmware and tests can vary the window. +#[derive(Clone, Copy, Debug)] +pub struct Debouncer { + committed: DoorLevel, + /// Number of consecutive samples disagreeing with `committed`. + streak: u16, +} + +impl Debouncer { + /// Construct a debouncer with a known initial level (typically + /// the level read once at boot, after the GPIO has settled). + pub const fn new(initial: DoorLevel) -> Self { + Self { + committed: initial, + streak: 0, + } + } + + /// The currently-committed door level (no glitches reflected). + pub const fn level(&self) -> DoorLevel { + self.committed + } + + /// Feed a new sample. Returns `Some(edge)` exactly once per + /// confirmed transition, `None` otherwise. + pub fn update(&mut self, sample: DoorLevel) -> Option { + if sample == self.committed { + self.streak = 0; + return None; + } + // Sample disagrees with the committed value. + self.streak = self.streak.saturating_add(1); + if self.streak >= N { + let edge = match (self.committed, sample) { + (DoorLevel::Closed, DoorLevel::Open) => Edge::Opened, + (DoorLevel::Open, DoorLevel::Closed) => Edge::Closed, + // Same-state case ruled out above by the equality check. + _ => unreachable!(), + }; + self.committed = sample; + self.streak = 0; + Some(edge) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_input_never_fires() { + let mut d: Debouncer = Debouncer::new(DoorLevel::Closed); + for _ in 0..1000 { + assert!(d.update(DoorLevel::Closed).is_none()); + } + assert_eq!(d.level(), DoorLevel::Closed); + } + + #[test] + fn short_glitch_is_ignored() { + let mut d: Debouncer = Debouncer::new(DoorLevel::Closed); + // 10 ms of "open" then back to closed → never crosses 50 ticks. + for _ in 0..10 { + assert!(d.update(DoorLevel::Open).is_none()); + } + for _ in 0..5 { + assert!(d.update(DoorLevel::Closed).is_none()); + } + assert_eq!(d.level(), DoorLevel::Closed); + } + + #[test] + fn fifty_ms_hold_fires_once() { + let mut d: Debouncer = Debouncer::new(DoorLevel::Closed); + let mut edges = 0; + for i in 0..200 { + // First 49 "open" samples must NOT fire; the 50th does. + let r = d.update(DoorLevel::Open); + if r.is_some() { + edges += 1; + assert_eq!(r, Some(Edge::Opened)); + assert_eq!(i, 49, "edge must fire on the 50th sample"); + } + } + assert_eq!(edges, 1); + assert_eq!(d.level(), DoorLevel::Open); + } + + #[test] + fn opens_then_closes() { + let mut d: Debouncer<3> = Debouncer::new(DoorLevel::Closed); + for _ in 0..3 { + d.update(DoorLevel::Open); + } + assert_eq!(d.level(), DoorLevel::Open); + let mut last: Option = None; + for _ in 0..3 { + if let Some(e) = d.update(DoorLevel::Closed) { + last = Some(e); + } + } + assert_eq!(last, Some(Edge::Closed)); + assert_eq!(d.level(), DoorLevel::Closed); + } + + #[test] + fn level_from_high() { + assert_eq!(DoorLevel::from_high(true), DoorLevel::Open); + assert_eq!(DoorLevel::from_high(false), DoorLevel::Closed); + assert_eq!(DoorLevel::Open.as_value(), 1); + assert_eq!(DoorLevel::Closed.as_value(), 0); + } + + proptest::proptest! { + /// Pumping noise into the debouncer never produces an edge if + /// the disagreeing run never reaches the stable threshold. + #[test] + fn no_spurious_edge_below_threshold( + run_len in 0u16..50, + ) { + let mut d: Debouncer = Debouncer::new(DoorLevel::Closed); + for _ in 0..run_len { + proptest::prop_assert!(d.update(DoorLevel::Open).is_none()); + } + // Return to closed before stable threshold reached. + proptest::prop_assert_eq!(d.update(DoorLevel::Closed), None); + proptest::prop_assert_eq!(d.level(), DoorLevel::Closed); + } + } +} diff --git a/crates/wohl-fw-door/src/door.rs b/crates/wohl-fw-door/src/door.rs new file mode 100644 index 0000000..e98a250 --- /dev/null +++ b/crates/wohl-fw-door/src/door.rs @@ -0,0 +1,136 @@ +//! High-level door state machine. +//! +//! Composes [`debounce`](crate::debounce) and [`ccsds`](crate::ccsds): +//! every confirmed debounced edge produces one CCSDS packet with a +//! monotonically increasing sequence counter. Wrap-around at 14 bits +//! is intentional — the hub re-syncs through CCSDS sequence-flag rules. + +use crate::ccsds::{PACKET_SIZE, QUALITY_GOOD, SENSOR_CONTACT, SensorPacket, encode}; +use crate::debounce::{DEFAULT_STABLE_TICKS, Debouncer, DoorLevel, Edge}; + +/// Identity + persistence used by the door firmware. Fields are +/// `Copy` so they live in a single `&mut State` without allocation. +#[derive(Clone, Copy, Debug)] +pub struct DoorState { + /// CCSDS APID for this node (0-2047). + pub device_id: u16, + /// Zone/room identifier (free-form within the deployment). + pub zone_id: u16, + /// Next sequence number to emit. Wraps at 2^14. + pub next_sequence: u16, + /// Debouncer with the project-default stable window. + pub debouncer: Debouncer, +} + +impl DoorState { + /// Construct fresh state. `initial_level` is the GPIO read once + /// at boot, after pull-up has settled (typically a `for _ in 0..1000` + /// nop loop, or simply reading after clock setup). + pub const fn new(device_id: u16, zone_id: u16, initial_level: DoorLevel) -> Self { + Self { + device_id, + zone_id, + next_sequence: 0, + debouncer: Debouncer::new(initial_level), + } + } + + /// Feed a sample; if a confirmed edge fires, encode and return a + /// packet ready for UART TX. Otherwise returns `None`. + pub fn step(&mut self, sample: DoorLevel) -> Option<[u8; PACKET_SIZE]> { + let edge = self.debouncer.update(sample)?; + let value = match edge { + Edge::Opened => DoorLevel::Open.as_value(), + Edge::Closed => DoorLevel::Closed.as_value(), + }; + let packet = SensorPacket { + device_id: self.device_id, + sequence: self.next_sequence & 0x3FFF, + sensor_type: SENSOR_CONTACT, + quality: QUALITY_GOOD, + zone_id: self.zone_id, + value, + }; + // Wrap explicitly at 14 bits to make the bound obvious in `&mut`. + self.next_sequence = self.next_sequence.wrapping_add(1) & 0x3FFF; + let mut buf = [0u8; PACKET_SIZE]; + encode(&packet, &mut buf); + Some(buf) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ccsds::SENSOR_CONTACT; + + fn drain Option<[u8; PACKET_SIZE]>>( + state: &mut DoorState, + mut step: F, + n: usize, + ) -> Vec<[u8; PACKET_SIZE]> { + let mut out = Vec::new(); + for _ in 0..n { + if let Some(buf) = step(state) { + out.push(buf); + } + } + out + } + + #[test] + fn open_event_emits_one_packet() { + let mut s = DoorState::new(0x42, 0x0103, DoorLevel::Closed); + let pkts = drain(&mut s, |s| s.step(DoorLevel::Open), 60); + assert_eq!(pkts.len(), 1); + let p = &pkts[0]; + // Sensor type byte at offset 6 must be SENSOR_CONTACT. + assert_eq!(p[6], SENSOR_CONTACT); + // value at [10..14] must be 1 (open). + assert_eq!(&p[10..14], &1i32.to_le_bytes()); + // Sequence count starts at 0. + assert_eq!(((p[2] as u16 & 0x3F) << 8) | p[3] as u16, 0); + } + + #[test] + fn close_event_after_open_increments_sequence() { + let mut s = DoorState::new(0x42, 0x0103, DoorLevel::Closed); + let opens = drain(&mut s, |s| s.step(DoorLevel::Open), 60); + let closes = drain(&mut s, |s| s.step(DoorLevel::Closed), 60); + assert_eq!(opens.len(), 1); + assert_eq!(closes.len(), 1); + // Second packet has sequence = 1. + let p = &closes[0]; + assert_eq!(((p[2] as u16 & 0x3F) << 8) | p[3] as u16, 1); + // value at [10..14] must be 0 (closed). + assert_eq!(&p[10..14], &0i32.to_le_bytes()); + } + + #[test] + fn stable_input_emits_nothing() { + let mut s = DoorState::new(0x42, 0x0103, DoorLevel::Closed); + let pkts = drain(&mut s, |s| s.step(DoorLevel::Closed), 10_000); + assert!(pkts.is_empty()); + } + + #[test] + fn sequence_wraps_at_14_bits() { + let mut s = DoorState::new(0x42, 0x0103, DoorLevel::Closed); + // Force the sequence counter to wrap by setting it just below. + s.next_sequence = 0x3FFE; + // Sample 50 ticks of "open" → emit one packet with seq=0x3FFE. + let pkts = drain(&mut s, |s| s.step(DoorLevel::Open), 60); + assert_eq!(pkts.len(), 1); + let p = &pkts[0]; + assert_eq!(((p[2] as u16 & 0x3F) << 8) | p[3] as u16, 0x3FFE); + // Now state.next_sequence == 0x3FFF. + assert_eq!(s.next_sequence, 0x3FFF); + // Sample 50 ticks of "closed" → emit one packet with seq=0x3FFF; + // counter then wraps to 0. + let pkts = drain(&mut s, |s| s.step(DoorLevel::Closed), 60); + assert_eq!(pkts.len(), 1); + let p = &pkts[0]; + assert_eq!(((p[2] as u16 & 0x3F) << 8) | p[3] as u16, 0x3FFF); + assert_eq!(s.next_sequence, 0); + } +} diff --git a/crates/wohl-fw-door/src/lib.rs b/crates/wohl-fw-door/src/lib.rs new file mode 100644 index 0000000..8c86304 --- /dev/null +++ b/crates/wohl-fw-door/src/lib.rs @@ -0,0 +1,27 @@ +//! Wohl door/window sensor firmware — pure-logic library. +//! +//! This crate is split in two: +//! +//! - **`lib.rs`** (this file) — pure-Rust, `no_std`, `no_alloc` modules: +//! - [`ccsds`] — 14-byte CCSDS sensor wire encoder (byte-identical +//! to `relay-ccsds::sensor_wire::encode_packet`). +//! - [`debounce`] — generic edge-debouncer for the reed switch. +//! - [`door`] — high-level state machine that turns debounced edges +//! into [`ccsds::SensorPacket`]s with a monotonic sequence counter. +//! +//! These modules have no MCU-specific code, build on any target, and +//! are unit-tested on the host (`cargo test -p wohl-fw-door`). +//! - **`main.rs`** — the actual firmware binary. Wires GPIO + SysTick + +//! USART1 of an STM32G031 into the modules above. Gated behind +//! `#[cfg(target_os = "none")]` so `cargo test` on the host skips it. +//! +//! The crate is `no_std` / `no_alloc` end-to-end (see Wohl `CLAUDE.md`, +//! "Rules"). The library never panics on valid input; encoder writes +//! into a caller-provided fixed-size buffer. + +#![cfg_attr(not(test), no_std)] +#![forbid(unsafe_code)] + +pub mod ccsds; +pub mod debounce; +pub mod door; diff --git a/crates/wohl-fw-door/src/main.rs b/crates/wohl-fw-door/src/main.rs new file mode 100644 index 0000000..0404101 --- /dev/null +++ b/crates/wohl-fw-door/src/main.rs @@ -0,0 +1,115 @@ +//! Wohl door-sensor firmware binary — STM32G031. +//! +//! Wakes from reset, configures clocks, GPIO, SysTick and USART1, then +//! polls the reed switch every SysTick tick (~1 ms). Every confirmed +//! debounced edge produces a CCSDS sensor packet and transmits it over +//! USART1 at 115200 8N1. +//! +//! All MCU-specific code lives in this file. The library half of the +//! crate (in `lib.rs`) has no HAL dependency and is unit-tested on the +//! host. See `boards/stm32g0/README.md` for pin mapping. +//! +//! This binary is only meaningfully built for `thumbv6m-none-eabi`. +//! Host targets emit a tiny stub so `cargo build` / `cargo clippy` work +//! without requiring `--target`. + +// `no_std`/`no_main` only apply on bare-metal targets; on host targets +// the binary is a regular Rust program with `fn main()` so that +// `cargo test`, `cargo clippy`, and `cargo build` (without `--target`) +// all work without nightly or `-Zbuild-std`. +#![cfg_attr(target_os = "none", no_std)] +#![cfg_attr(target_os = "none", no_main)] +#![cfg_attr(not(target_os = "none"), allow(dead_code))] + +// ── Host-target stub ─────────────────────────────────────────────── +#[cfg(not(target_os = "none"))] +fn main() {} + +// ── Real firmware ────────────────────────────────────────────────── +#[cfg(target_os = "none")] +mod firmware { + use core::fmt::Write as _; + + use cortex_m_rt::entry; + use nb::block; + use panic_halt as _; + use stm32g0xx_hal::{prelude::*, serial::FullConfig, stm32}; + + use wohl_fw_door::ccsds::PACKET_SIZE; + use wohl_fw_door::debounce::DoorLevel; + use wohl_fw_door::door::DoorState; + + /// CCSDS APID for this node. One device per APID, set per build. + /// Provisioning will turn this into a flash-resident value later. + const DEVICE_ID: u16 = 0x012; + /// Zone identifier (matches Wohl deployment YAMLs in `artifacts/`). + const ZONE_ID: u16 = 0x0103; + + #[entry] + fn main() -> ! { + // ── Peripheral acquisition ────────────────────────────────── + let dp = stm32::Peripherals::take().expect("device peripherals already taken"); + let cp = cortex_m::Peripherals::take().expect("core peripherals already taken"); + + // Default `constrain()` leaves the chip on HSI16 (16 MHz). + // Adequate for 115200 baud (< 0.2 % error). + let mut rcc = dp.RCC.constrain(); + + // ── GPIO ──────────────────────────────────────────────────── + let gpioa = dp.GPIOA.split(&mut rcc); + // PA0 — reed switch, internal pull-up. High = door open. + let reed = gpioa.pa0.into_pull_up_input(); + // PA9 / PA10 — USART1 TX / RX (AF1). + let tx_pin = gpioa.pa9; + let rx_pin = gpioa.pa10; + + // ── USART1 @ 115200 8N1 ──────────────────────────────────── + let usart = dp + .USART1 + .usart( + (tx_pin, rx_pin), + FullConfig::default().baudrate(115200.bps()), + &mut rcc, + ) + .expect("USART1 init failed"); + let (mut tx, _rx) = usart.split(); + + // ── SysTick-driven 1 kHz tick for the debouncer ──────────── + let mut delay = cp.SYST.delay(&mut rcc); + + // ── Door state machine ───────────────────────────────────── + // Read initial level once at boot (pull-up has settled by now). + let initial = DoorLevel::from_high(reed.is_high().unwrap_or(true)); + let mut state = DoorState::new(DEVICE_ID, ZONE_ID, initial); + + // Boot banner so the hub can spot a freshly-reset node. + // Errors are ignored: a banner is best-effort, not safety. + let _ = writeln!( + tx, + "wohl-fw-door boot apid=0x{:03X} zone=0x{:04X}\r", + DEVICE_ID, ZONE_ID + ); + + loop { + // 1 ms cadence → 50 ms debounce default = 50 samples. + delay.delay(1u32.millis()); + + let level = DoorLevel::from_high(reed.is_high().unwrap_or(true)); + if let Some(packet) = state.step(level) { + // Push the 14 raw bytes — CCSDS is self-delimiting via + // the length field in the header. + for byte in &packet { + // `block!` busy-waits on `WouldBlock`. Hardware errors + // (overrun etc.) are intentionally dropped; the hub + // will detect a corrupted packet via CCSDS sequence. + let _ = block!(tx.write(*byte)); + } + // Flush so we don't return while the holding register + // still has bytes (matters if the loop iterates again + // within one byte time, e.g. on a fast burst). + let _ = block!(tx.flush()); + let _: &[u8; PACKET_SIZE] = &packet; // size sanity (compile-time). + } + } + } +} From a7ef1ea63bd7e1029a8131a4656634697ec4fd76 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Tue, 19 May 2026 20:28:55 +0200 Subject: [PATCH 2/6] WORKSPACE_INTEGRATION.md: orchestrator handoff for wohl-fw-door Documents the exact Cargo.toml edit (one line in the workspace members list) the orchestrator needs to apply when integrating the new firmware crate, plus the rationale for deferring CI changes until cross-compilation for thumbv6m-none-eabi has been scoped. Co-Authored-By: Claude Opus 4.7 (1M context) --- WORKSPACE_INTEGRATION.md | 119 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 WORKSPACE_INTEGRATION.md diff --git a/WORKSPACE_INTEGRATION.md b/WORKSPACE_INTEGRATION.md new file mode 100644 index 0000000..a4dd513 --- /dev/null +++ b/WORKSPACE_INTEGRATION.md @@ -0,0 +1,119 @@ +# Workspace integration — `wohl-fw-door` + +This crate (`crates/wohl-fw-door/`) is **not yet** a workspace member. +This document lists the exact edits the orchestrator needs to make to +`Cargo.toml` and (optionally) `.github/workflows/ci.yml` to integrate +the firmware into the workspace and into CI. + +The firmware itself has been verified standalone — see the *Verification* +section near the bottom — by temporarily adding the crate to the +`members` list, running the four gates, then reverting that one line. +After the orchestrator applies the change below, the same verification +will pass from the workspace. + +--- + +## 1. `Cargo.toml` — add the firmware crate to the workspace + +**One-line edit.** Add `crates/wohl-fw-door` to the `members` list: + +```diff + [workspace] + resolver = "3" + members = [ + "crates/wohl-leak", + "crates/wohl-temp", + "crates/wohl-air", + "crates/wohl-door", + "crates/wohl-power", + "crates/wohl-alert", + "crates/wohl-hub", + "crates/wohl-integration", ++ "crates/wohl-fw-door", + ] +``` + +That is the **only** change strictly required. + +### Optional but recommended — share the HAL pin + +If you'd like firmware versions to be pinned at the workspace level (so +future `wohl-fw-climate`, `wohl-fw-air`, etc. share a single HAL +version), add four `[workspace.dependencies]` lines: + +```diff + [workspace.dependencies] + relay-lc = { path = "../relay/crates/relay-lc" } + … + proptest = "1" ++# Firmware shared deps (consumed by crates/wohl-fw-*) ++cortex-m = { version = "0.7.7", default-features = false } ++cortex-m-rt = { version = "0.7.5", default-features = false } ++panic-halt = { version = "1.0.0", default-features = false } ++stm32g0xx-hal = { version = "0.2.0", features = ["stm32g031", "rt"] } +``` + +…and then in `crates/wohl-fw-door/Cargo.toml` you'd switch the +`[target.'cfg(target_os = "none")'.dependencies]` lines from explicit +versions to `{ workspace = true }`. This is **purely cosmetic**; the +crate works as-is with versions pinned locally. Defer this until a +second firmware crate exists. + +## 2. `.github/workflows/ci.yml` — out of scope + +Per the task spec, **do not touch CI in this PR**. The firmware target +needs either: + +- a `rustup target add thumbv6m-none-eabi` step (cheap), or +- a separate `cross` matrix entry (more isolated). + +Both options should be discussed and added in a follow-up PR. Until +then, the existing `cargo test --workspace` job will exercise +`wohl-fw-door`'s host-side tests automatically once the crate is added +to `members` — because `cargo test` cross-compiles to the *host* by +default, and on the host the firmware binary collapses to a +zero-cost stub (`fn main() {}`), avoiding any need for the ARMv6-M +toolchain in CI. + +--- + +## Verification (already run locally on this branch) + +From the worktree root, with the one-line `members` edit applied +temporarily, all four gates passed: + +``` +cargo build -p wohl-fw-door --target thumbv6m-none-eabi --release # PASS +cargo test -p wohl-fw-door # PASS — 16/16 tests +cargo fmt -p wohl-fw-door --check # PASS +cargo clippy -p wohl-fw-door --all-targets -- -D warnings # PASS +``` + +Cross-clippy (`cargo clippy -p wohl-fw-door --target thumbv6m-none-eabi +--lib --bins -- -D warnings`) is also clean — only `--all-targets` on +the bare-metal target fails, because the dev-dependency `proptest` +pulls in non-`no_std` crates. That's expected and not a regression. + +## Files added by this PR + +``` +boards/stm32g0/README.md # Hardware/pin docs +crates/wohl-fw-door/Cargo.toml # Crate manifest +crates/wohl-fw-door/build.rs # memory.x → OUT_DIR +crates/wohl-fw-door/memory.x # STM32G031K8 linker map +crates/wohl-fw-door/.cargo/config.toml # default target + linker flags +crates/wohl-fw-door/src/lib.rs # Pure-logic library root +crates/wohl-fw-door/src/ccsds.rs # 14-byte CCSDS encoder +crates/wohl-fw-door/src/debounce.rs # Reed-switch debouncer +crates/wohl-fw-door/src/door.rs # State machine +crates/wohl-fw-door/src/main.rs # MCU binary (entry, USART) +WORKSPACE_INTEGRATION.md # This file +``` + +## Files NOT touched + +- `Cargo.toml` — orchestrator applies the one-line `members` edit. +- `Cargo.lock` — regenerated when `members` is updated. +- `.github/workflows/ci.yml` — follow-up. +- `crates/wohl-*/` (existing monitors) — untouched. +- `crates/wohl-hub/` — untouched. From 0b9f6c0aff4a99718218ddda8830b95088cad5a3 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 20 May 2026 06:18:55 +0200 Subject: [PATCH 3/6] AADL: add DoorWindowNode.WiredG0 variant for the STM32G0 door firmware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The firmware in crates/wohl-fw-door targets an STM32G031K8 with a wired UART link to the hub. The AADL only modelled DoorWindowNode.Battery (nRF52840 + Thread mesh), so model and code disagreed on the door node. Architect's call: keep BOTH deployment shapes modelled rather than replacing the battery variant. - wohl_hardware.aadl: new `processor STM32G031` (Cortex-M0+, UART, no radio) + `memory SRAM_STM32G031` (8 KB) + `memory Flash_STM32G031` (64 KB). - wohl_nodes.aadl: new `system implementation DoorWindowNode.WiredG0` wiring the reed switch + STM32G031 + UART bus + DoorFirmwareProcess. Also fixes crates/wohl-fw-door/memory.x: the header said "STM32G031K8" but FLASH LENGTH was 32K. The K8 part is 64 KB flash — corrected so the linker map, the AADL Flash_STM32G031 size, and the part number all agree. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/wohl-fw-door/memory.x | 4 ++-- spar/wohl_hardware.aadl | 28 ++++++++++++++++++++++++++++ spar/wohl_nodes.aadl | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/wohl-fw-door/memory.x b/crates/wohl-fw-door/memory.x index ace08f6..2baa0ae 100644 --- a/crates/wohl-fw-door/memory.x +++ b/crates/wohl-fw-door/memory.x @@ -1,8 +1,8 @@ -/* Linker memory map for STM32G031K8 (32 KB Flash, 8 KB SRAM). +/* Linker memory map for STM32G031K8 (64 KB Flash, 8 KB SRAM). * Consumed by `cortex-m-rt`'s `link.x.in` via the `memory.x` search * path that `build.rs` adds to OUT_DIR. */ MEMORY { - FLASH : ORIGIN = 0x08000000, LENGTH = 32K + FLASH : ORIGIN = 0x08000000, LENGTH = 64K RAM : ORIGIN = 0x20000000, LENGTH = 8K } diff --git a/spar/wohl_hardware.aadl b/spar/wohl_hardware.aadl index 878bd9b..093d759 100644 --- a/spar/wohl_hardware.aadl +++ b/spar/wohl_hardware.aadl @@ -78,6 +78,20 @@ public Memory_Size => 1 MByte; end Flash_nRF52840; + memory SRAM_STM32G031 + -- 8 KB SRAM on STMicro STM32G031. + properties + Word_Size => 32 bits; + Memory_Size => 8 KByte; + end SRAM_STM32G031; + + memory Flash_STM32G031 + -- 64 KB on-chip flash on STMicro STM32G031K8. + properties + Word_Size => 32 bits; + Memory_Size => 64 KByte; + end Flash_STM32G031; + memory SRAM_ESP32S3 -- 512 KB SRAM on ESP32-S3. properties @@ -123,6 +137,20 @@ public Wohl_Properties::Zephyr_Binding => "nordic,nrf52840"; end NRF52840; + processor STM32G031 + -- STMicro STM32G031K8: Cortex-M0+, 64 MHz, 8 KB RAM, 64 KB flash. + -- No integrated radio — sensor data leaves over a wired UART link. + -- Bare-metal Rust firmware (crates/wohl-fw-door), not Zephyr. + features + uart_out: requires bus access UART_Bus; + properties + Deployment::Execution_Platform => Native; + Scheduling_Protocol => (Rate_Monotonic_Protocol); + Clock_Period => 15625 ps; -- 64 MHz + Wohl_Properties::Sleep_Current_uA => 1.8; + Wohl_Properties::Active_Current_uA => 5000.0; + end STM32G031; + processor ESP32_S3 -- Espressif ESP32-S3: Xtensa LX7 dual-core, 240 MHz, 512 KB SRAM. -- Integrated WiFi + BLE. Used for mains-powered nodes. diff --git a/spar/wohl_nodes.aadl b/spar/wohl_nodes.aadl index 80f4823..c5a7e58 100644 --- a/spar/wohl_nodes.aadl +++ b/spar/wohl_nodes.aadl @@ -48,6 +48,38 @@ public Wohl_Properties::Expected_Battery_Life_Years => 2.5; end DoorWindowNode.Battery; + -- ══════════════════════════════════════════════════════════════ + -- DoorWindowNode.WiredG0 + -- MC-38 reed on an STM32G031K8, mains/USB-powered, wired UART to + -- the hub. Bare-metal Rust firmware (crates/wohl-fw-door): debounce + -- the reed edge, emit a 14-byte CCSDS SensorPacket over UART. + -- No radio, no battery — the first physically-deployed Wohl node. + -- ══════════════════════════════════════════════════════════════ + + system implementation DoorWindowNode.WiredG0 + subcomponents + mcu: processor Wohl_Hardware::STM32G031; + ram: memory Wohl_Hardware::SRAM_STM32G031; + rom: memory Wohl_Hardware::Flash_STM32G031; + reed: device Wohl_Hardware::ReedSwitch_MC38; + fw: process Wohl_Firmware::DoorFirmwareProcess.Impl; + gpio: bus Wohl_Hardware::GPIO_Line; + uart: bus Wohl_Hardware::UART_Bus; + connections + c_reed_bus: bus access gpio -> reed.gpio; + c_uart_bus: bus access uart -> mcu.uart_out; + c_reed_to_fw: port reed.event_out -> fw.firmware.reed_in; + c_fw_out: port fw.firmware.packet_out -> packet_out; + c_ota_in: port image_in -> fw.image_in; + c_attest_out: port fw.attest_out -> attest_out; + properties + Actual_Processor_Binding => (reference (mcu)) applies to fw.firmware; + Actual_Processor_Binding => (reference (mcu)) applies to fw.bootloader; + Actual_Memory_Binding => (reference (ram)) applies to fw; + Actual_Memory_Binding => (reference (ram)) applies to fw.firmware; + Actual_Memory_Binding => (reference (ram)) applies to fw.bootloader; + end DoorWindowNode.WiredG0; + -- ══════════════════════════════════════════════════════════════ -- ClimateNode.Battery -- SHT30 + BMP280 + AM312 on a XIAO nRF52840, 2×AA lithium. From 18c89c898ae91422726371e0b5ecc7541c0e53ba Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 20 May 2026 06:45:36 +0200 Subject: [PATCH 4/6] Reframe Track B as wohl-fw-door-bench; integrate into workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The STM32G0 + wired-UART firmware is a bench/development tool, not a field door sensor — point-to-point UART does not scale to a real home. Field door nodes will use STM32WL55 (sub-GHz) or a CAN-FD bus with a transport-agnostic CCSDS layer (tracked separately). - Renamed crate wohl-fw-door -> wohl-fw-door-bench (package, [lib], [[bin]], doc comments, boot banner, .cargo/config comment). - Root Cargo.toml: added crates/wohl-fw-door-bench to workspace members (applies the WORKSPACE_INTEGRATION.md handoff; that file is removed). - boards/stm32g0/README.md: reframed as the bench board; fixed the STM32G031K8 flash size (it is 64 KB, not 32 KB — the K8 suffix denotes 64 KB; the linker-map example block was also wrong). - spar AADL: DoorWindowNode.WiredG0 comment now states it is a bench/development node; processor STM32G031 comment updated. The wireless field shape stays modelled as DoorWindowNode.Battery. Verified from the worktree: cargo build -p wohl-fw-door-bench --target thumbv6m-none-eabi --release OK cargo test -p wohl-fw-door-bench 16/16 cargo +1.85.0 clippy -p wohl-fw-door-bench --all-targets -- -D warnings clean cargo +1.85.0 fmt -p wohl-fw-door-bench -- --check clean Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.toml | 1 + WORKSPACE_INTEGRATION.md | 119 ------------------ boards/stm32g0/README.md | 71 ++++++----- .../.cargo/config.toml | 4 +- .../Cargo.toml | 8 +- .../build.rs | 0 .../memory.x | 0 .../src/ccsds.rs | 0 .../src/debounce.rs | 0 .../src/door.rs | 0 .../src/lib.rs | 11 +- .../src/main.rs | 8 +- spar/wohl_hardware.aadl | 4 +- spar/wohl_nodes.aadl | 11 +- 14 files changed, 67 insertions(+), 170 deletions(-) delete mode 100644 WORKSPACE_INTEGRATION.md rename crates/{wohl-fw-door => wohl-fw-door-bench}/.cargo/config.toml (87%) rename crates/{wohl-fw-door => wohl-fw-door-bench}/Cargo.toml (84%) rename crates/{wohl-fw-door => wohl-fw-door-bench}/build.rs (100%) rename crates/{wohl-fw-door => wohl-fw-door-bench}/memory.x (100%) rename crates/{wohl-fw-door => wohl-fw-door-bench}/src/ccsds.rs (100%) rename crates/{wohl-fw-door => wohl-fw-door-bench}/src/debounce.rs (100%) rename crates/{wohl-fw-door => wohl-fw-door-bench}/src/door.rs (100%) rename crates/{wohl-fw-door => wohl-fw-door-bench}/src/lib.rs (65%) rename crates/{wohl-fw-door => wohl-fw-door-bench}/src/main.rs (96%) diff --git a/Cargo.toml b/Cargo.toml index e042d6b..b11ec7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/wohl-alert", "crates/wohl-hub", "crates/wohl-integration", + "crates/wohl-fw-door-bench", ] [workspace.package] diff --git a/WORKSPACE_INTEGRATION.md b/WORKSPACE_INTEGRATION.md deleted file mode 100644 index a4dd513..0000000 --- a/WORKSPACE_INTEGRATION.md +++ /dev/null @@ -1,119 +0,0 @@ -# Workspace integration — `wohl-fw-door` - -This crate (`crates/wohl-fw-door/`) is **not yet** a workspace member. -This document lists the exact edits the orchestrator needs to make to -`Cargo.toml` and (optionally) `.github/workflows/ci.yml` to integrate -the firmware into the workspace and into CI. - -The firmware itself has been verified standalone — see the *Verification* -section near the bottom — by temporarily adding the crate to the -`members` list, running the four gates, then reverting that one line. -After the orchestrator applies the change below, the same verification -will pass from the workspace. - ---- - -## 1. `Cargo.toml` — add the firmware crate to the workspace - -**One-line edit.** Add `crates/wohl-fw-door` to the `members` list: - -```diff - [workspace] - resolver = "3" - members = [ - "crates/wohl-leak", - "crates/wohl-temp", - "crates/wohl-air", - "crates/wohl-door", - "crates/wohl-power", - "crates/wohl-alert", - "crates/wohl-hub", - "crates/wohl-integration", -+ "crates/wohl-fw-door", - ] -``` - -That is the **only** change strictly required. - -### Optional but recommended — share the HAL pin - -If you'd like firmware versions to be pinned at the workspace level (so -future `wohl-fw-climate`, `wohl-fw-air`, etc. share a single HAL -version), add four `[workspace.dependencies]` lines: - -```diff - [workspace.dependencies] - relay-lc = { path = "../relay/crates/relay-lc" } - … - proptest = "1" -+# Firmware shared deps (consumed by crates/wohl-fw-*) -+cortex-m = { version = "0.7.7", default-features = false } -+cortex-m-rt = { version = "0.7.5", default-features = false } -+panic-halt = { version = "1.0.0", default-features = false } -+stm32g0xx-hal = { version = "0.2.0", features = ["stm32g031", "rt"] } -``` - -…and then in `crates/wohl-fw-door/Cargo.toml` you'd switch the -`[target.'cfg(target_os = "none")'.dependencies]` lines from explicit -versions to `{ workspace = true }`. This is **purely cosmetic**; the -crate works as-is with versions pinned locally. Defer this until a -second firmware crate exists. - -## 2. `.github/workflows/ci.yml` — out of scope - -Per the task spec, **do not touch CI in this PR**. The firmware target -needs either: - -- a `rustup target add thumbv6m-none-eabi` step (cheap), or -- a separate `cross` matrix entry (more isolated). - -Both options should be discussed and added in a follow-up PR. Until -then, the existing `cargo test --workspace` job will exercise -`wohl-fw-door`'s host-side tests automatically once the crate is added -to `members` — because `cargo test` cross-compiles to the *host* by -default, and on the host the firmware binary collapses to a -zero-cost stub (`fn main() {}`), avoiding any need for the ARMv6-M -toolchain in CI. - ---- - -## Verification (already run locally on this branch) - -From the worktree root, with the one-line `members` edit applied -temporarily, all four gates passed: - -``` -cargo build -p wohl-fw-door --target thumbv6m-none-eabi --release # PASS -cargo test -p wohl-fw-door # PASS — 16/16 tests -cargo fmt -p wohl-fw-door --check # PASS -cargo clippy -p wohl-fw-door --all-targets -- -D warnings # PASS -``` - -Cross-clippy (`cargo clippy -p wohl-fw-door --target thumbv6m-none-eabi ---lib --bins -- -D warnings`) is also clean — only `--all-targets` on -the bare-metal target fails, because the dev-dependency `proptest` -pulls in non-`no_std` crates. That's expected and not a regression. - -## Files added by this PR - -``` -boards/stm32g0/README.md # Hardware/pin docs -crates/wohl-fw-door/Cargo.toml # Crate manifest -crates/wohl-fw-door/build.rs # memory.x → OUT_DIR -crates/wohl-fw-door/memory.x # STM32G031K8 linker map -crates/wohl-fw-door/.cargo/config.toml # default target + linker flags -crates/wohl-fw-door/src/lib.rs # Pure-logic library root -crates/wohl-fw-door/src/ccsds.rs # 14-byte CCSDS encoder -crates/wohl-fw-door/src/debounce.rs # Reed-switch debouncer -crates/wohl-fw-door/src/door.rs # State machine -crates/wohl-fw-door/src/main.rs # MCU binary (entry, USART) -WORKSPACE_INTEGRATION.md # This file -``` - -## Files NOT touched - -- `Cargo.toml` — orchestrator applies the one-line `members` edit. -- `Cargo.lock` — regenerated when `members` is updated. -- `.github/workflows/ci.yml` — follow-up. -- `crates/wohl-*/` (existing monitors) — untouched. -- `crates/wohl-hub/` — untouched. diff --git a/boards/stm32g0/README.md b/boards/stm32g0/README.md index e6b56c2..f273a69 100644 --- a/boards/stm32g0/README.md +++ b/boards/stm32g0/README.md @@ -1,33 +1,36 @@ # `boards/stm32g0` -Hardware board notes for STM32G0-class sensor nodes in the Wohl home -supervision system. +Hardware board notes for the STM32G0 **bench/development** door sensor in +the Wohl home supervision system. This directory holds **board-level documentation and linker artifacts** only — no Rust code. The firmware itself lives in -[`crates/wohl-fw-door/`](../../crates/wohl-fw-door/). +[`crates/wohl-fw-door-bench/`](../../crates/wohl-fw-door-bench/). -## Role +## Role — bench tool, not a field node -STM32G031 is the architect's choice (see -[`spar/wohl_system.aadl`](../../spar/wohl_system.aadl)) for the smallest, -cheapest sensor nodes: +This STM32G031 board is a **bench/development sensor**. It emits CCSDS +sensor packets over a wired point-to-point UART so the hub's `--ccsds` +ingest path can be exercised without radio or bus hardware. -> *Sensor nodes: ESP32-C3 or STM32G031 (Cortex-M0+, 64MHz, 8KB SRAM)* +It is **not** a field deployment. A real door/window sensor must be +wireless or on a multi-drop home-automation bus — point-to-point UART +does not scale to a house. The field door firmware targets: -For the **Door / Window** node — a reed switch glued to a doorframe — -STM32G031 in particular fits the bill: +- **STM32WL55** — sub-GHz (868/915 MHz) for wireless nodes +- **CAN-FD** — multi-drop wired bus for cabled installs -- **Ultra-low quiescent current** in `STOP1`/`STANDBY` (door is shut most - of the time, MCU sleeps until reed edge). -- **Cortex-M0+ / ARMv6-M** — Rust target `thumbv6m-none-eabi`. -- **8 KB SRAM, 32 KB Flash** — plenty for a 14-byte CCSDS encoder + UART - driver + bootloader stub. -- **Tiny QFN-32 package** runs from a CR2032 coin cell. +both carrying the same transport-agnostic 14-byte CCSDS payload. That +work is tracked separately in the issue tracker. + +## Why STM32G031 for the bench tool -The first revision targets the **STM32G031K8** (32 KB flash, 8 KB SRAM, -LQFP-32). Other G0 variants (G030, G031J6, G071) require only a -feature-flag swap in `wohl-fw-door`'s `Cargo.toml`. +- **Cortex-M0+ / ARMv6-M** — Rust target `thumbv6m-none-eabi`. +- **8 KB SRAM, 64 KB Flash** (STM32G031K8) — ample for a 14-byte CCSDS + encoder + UART driver. +- **Tiny QFN/LQFP-32 package**, cheap, USB/bench-powered. +- Stays in the STM32 family — same toolchain as the future STM32WL55 / + CAN-FD field firmware. ## Pin mapping (STM32G031K8, LQFP-32) @@ -36,10 +39,10 @@ feature-flag swap in `wohl-fw-door`'s `Cargo.toml`. | `PA9` | `UART1_TX` | USART1 TX → hub (115200 8N1) | AF1 | | `PA10` | `UART1_RX` | USART1 RX from hub (currently unused) | AF1 | | `PA0` | `REED` | Reed-switch input, internal pull-up, EXTI | Closed = `0` (magnet present) | -| `PC14` | `LED` | Status LED (open-drain, optional) | Low = on (saves cap on CR2032 droop) | +| `PC14` | `LED` | Status LED (open-drain, optional) | Low = on | | `PA13` | `SWDIO` | SWD debug | Reserved — do not reuse | | `PA14` | `SWCLK` | SWD debug | Reserved — do not reuse | -| `VDD` | `+3V0` | CR2032 via low-Iq LDO (or direct) | | +| `VDD` | `+3V0` | Bench/USB supply via LDO | | | `VSS` | `GND` | | | The reed switch is wired between `PA0` and `GND`; firmware enables the @@ -64,38 +67,38 @@ is fixed at **115200 8N1** to match the hub's default UART config. ## Clocking Default firmware boots on the internal **HSI16** (16 MHz). No external -crystal is fitted on the reference board; CR2032 cannot reliably drive -one through ageing. UART baud error at 115200 / 16 MHz is < 0.2 %, well +crystal is fitted; UART baud error at 115200 / 16 MHz is < 0.2 %, well within the 2.5 % asynchronous tolerance. ## Linker / memory -The reference build uses `cortex-m-rt`'s default `link.x`. A -chip-specific `memory.x` will be added here once we standardise on a -single G0 variant for the production batch: +`wohl-fw-door-bench` ships its own `memory.x` for the STM32G031K8 so +`cargo build --release` produces a flashable ELF without extra config: ```text MEMORY { - FLASH : ORIGIN = 0x08000000, LENGTH = 32K + FLASH : ORIGIN = 0x08000000, LENGTH = 64K RAM : ORIGIN = 0x20000000, LENGTH = 8K } ``` -Until then `wohl-fw-door` ships its own `memory.x` for the G031K8 so -that `cargo build --release` produces a flashable ELF without any extra -configuration. +(STM32G031**K8** is 64 KB flash / 8 KB SRAM — the `K8` suffix denotes +64 KB. Other G0 variants need only a `memory.x` + feature-flag swap.) ## Related AADL - `spar/wohl_firmware.aadl` — `DoorFirmware` thread (sporadic, 2 ms WCET, 100 ms deadline, 2 KB stack). -- `spar/wohl_nodes.aadl` — `DoorWindowNode` system (currently models the - nRF52840 + Thread variant; an STM32G0 + wired-UART variant is the - hardware target for this firmware). +- `spar/wohl_nodes.aadl` — `DoorWindowNode.WiredG0` (this bench board: + STM32G031 + wired UART) and `DoorWindowNode.Battery` (wireless field + variant). +- `spar/wohl_hardware.aadl` — `processor STM32G031` + `SRAM_STM32G031` + + `Flash_STM32G031`. ## Out of scope for this directory -- Firmware code (lives in `crates/wohl-fw-door/`). +- Firmware code (lives in `crates/wohl-fw-door-bench/`). - Probe-rs / defmt-test wiring (follow-up — see issue tracker). +- STM32WL55 sub-GHz + CAN-FD field firmware (separate tracked effort). - Schematics / PCB Gerbers (separate `hardware/` repo). diff --git a/crates/wohl-fw-door/.cargo/config.toml b/crates/wohl-fw-door-bench/.cargo/config.toml similarity index 87% rename from crates/wohl-fw-door/.cargo/config.toml rename to crates/wohl-fw-door-bench/.cargo/config.toml index 11714ef..730b8ae 100644 --- a/crates/wohl-fw-door/.cargo/config.toml +++ b/crates/wohl-fw-door-bench/.cargo/config.toml @@ -1,9 +1,9 @@ -# Cargo config for `wohl-fw-door`. +# Cargo config for `wohl-fw-door-bench`. # # This only takes effect when cargo is invoked from inside this crate # directory (Cargo searches upward for `.cargo/config.toml` per the # canonical Cargo behaviour). When the crate is built from the parent -# workspace via `cargo build -p wohl-fw-door --target thumbv6m-none-eabi`, +# workspace via `cargo build -p wohl-fw-door-bench --target thumbv6m-none-eabi`, # the parent workspace's config (if any) wins and the user must pass # `--target` explicitly — which is exactly what the verification step # in the task description does. diff --git a/crates/wohl-fw-door/Cargo.toml b/crates/wohl-fw-door-bench/Cargo.toml similarity index 84% rename from crates/wohl-fw-door/Cargo.toml rename to crates/wohl-fw-door-bench/Cargo.toml index db0ef09..8d4f83c 100644 --- a/crates/wohl-fw-door/Cargo.toml +++ b/crates/wohl-fw-door-bench/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "wohl-fw-door" -description = "Wohl door/window sensor firmware — STM32G0 + reed switch + CCSDS over UART" +name = "wohl-fw-door-bench" +description = "Wohl door sensor — bench/dev firmware: STM32G0 + reed switch + CCSDS over wired UART. Not a field deployment; the STM32WL55 sub-GHz / CAN-FD field firmware is tracked separately." version = "0.1.0" edition = "2024" license = "Apache-2.0" @@ -10,14 +10,14 @@ rust-version = "1.85.0" # Pure-logic crate (packet encoder, debouncer). Builds on host for tests, # and on thumbv6m-none-eabi as part of the firmware binary. [lib] -name = "wohl_fw_door" +name = "wohl_fw_door_bench" path = "src/lib.rs" # The actual firmware binary. Only meaningful when built for an ARMv6-M # target; on the host the binary still compiles but `#[entry]` and the # HAL are gated behind `cfg(target_os = "none")` so `cargo test` is happy. [[bin]] -name = "wohl-fw-door" +name = "wohl-fw-door-bench" path = "src/main.rs" test = false bench = false diff --git a/crates/wohl-fw-door/build.rs b/crates/wohl-fw-door-bench/build.rs similarity index 100% rename from crates/wohl-fw-door/build.rs rename to crates/wohl-fw-door-bench/build.rs diff --git a/crates/wohl-fw-door/memory.x b/crates/wohl-fw-door-bench/memory.x similarity index 100% rename from crates/wohl-fw-door/memory.x rename to crates/wohl-fw-door-bench/memory.x diff --git a/crates/wohl-fw-door/src/ccsds.rs b/crates/wohl-fw-door-bench/src/ccsds.rs similarity index 100% rename from crates/wohl-fw-door/src/ccsds.rs rename to crates/wohl-fw-door-bench/src/ccsds.rs diff --git a/crates/wohl-fw-door/src/debounce.rs b/crates/wohl-fw-door-bench/src/debounce.rs similarity index 100% rename from crates/wohl-fw-door/src/debounce.rs rename to crates/wohl-fw-door-bench/src/debounce.rs diff --git a/crates/wohl-fw-door/src/door.rs b/crates/wohl-fw-door-bench/src/door.rs similarity index 100% rename from crates/wohl-fw-door/src/door.rs rename to crates/wohl-fw-door-bench/src/door.rs diff --git a/crates/wohl-fw-door/src/lib.rs b/crates/wohl-fw-door-bench/src/lib.rs similarity index 65% rename from crates/wohl-fw-door/src/lib.rs rename to crates/wohl-fw-door-bench/src/lib.rs index 8c86304..32679b0 100644 --- a/crates/wohl-fw-door/src/lib.rs +++ b/crates/wohl-fw-door-bench/src/lib.rs @@ -1,4 +1,11 @@ -//! Wohl door/window sensor firmware — pure-logic library. +//! Wohl door sensor — bench/development firmware, pure-logic library. +//! +//! This is the **bench variant**: STM32G0 + reed switch emitting CCSDS +//! over a wired point-to-point UART. It exists to exercise the hub's +//! `--ccsds` ingest path without radio or bus hardware. It is NOT a +//! field deployment — point-to-point UART does not scale to a real +//! home. The field door firmware (STM32WL55 sub-GHz + CAN-FD, with a +//! transport-agnostic CCSDS layer) is tracked separately. //! //! This crate is split in two: //! @@ -10,7 +17,7 @@ //! into [`ccsds::SensorPacket`]s with a monotonic sequence counter. //! //! These modules have no MCU-specific code, build on any target, and -//! are unit-tested on the host (`cargo test -p wohl-fw-door`). +//! are unit-tested on the host (`cargo test -p wohl-fw-door-bench`). //! - **`main.rs`** — the actual firmware binary. Wires GPIO + SysTick + //! USART1 of an STM32G031 into the modules above. Gated behind //! `#[cfg(target_os = "none")]` so `cargo test` on the host skips it. diff --git a/crates/wohl-fw-door/src/main.rs b/crates/wohl-fw-door-bench/src/main.rs similarity index 96% rename from crates/wohl-fw-door/src/main.rs rename to crates/wohl-fw-door-bench/src/main.rs index 0404101..3e12cf4 100644 --- a/crates/wohl-fw-door/src/main.rs +++ b/crates/wohl-fw-door-bench/src/main.rs @@ -35,9 +35,9 @@ mod firmware { use panic_halt as _; use stm32g0xx_hal::{prelude::*, serial::FullConfig, stm32}; - use wohl_fw_door::ccsds::PACKET_SIZE; - use wohl_fw_door::debounce::DoorLevel; - use wohl_fw_door::door::DoorState; + use wohl_fw_door_bench::ccsds::PACKET_SIZE; + use wohl_fw_door_bench::debounce::DoorLevel; + use wohl_fw_door_bench::door::DoorState; /// CCSDS APID for this node. One device per APID, set per build. /// Provisioning will turn this into a flash-resident value later. @@ -86,7 +86,7 @@ mod firmware { // Errors are ignored: a banner is best-effort, not safety. let _ = writeln!( tx, - "wohl-fw-door boot apid=0x{:03X} zone=0x{:04X}\r", + "wohl-fw-door-bench boot apid=0x{:03X} zone=0x{:04X}\r", DEVICE_ID, ZONE_ID ); diff --git a/spar/wohl_hardware.aadl b/spar/wohl_hardware.aadl index 093d759..b865bb5 100644 --- a/spar/wohl_hardware.aadl +++ b/spar/wohl_hardware.aadl @@ -140,7 +140,9 @@ public processor STM32G031 -- STMicro STM32G031K8: Cortex-M0+, 64 MHz, 8 KB RAM, 64 KB flash. -- No integrated radio — sensor data leaves over a wired UART link. - -- Bare-metal Rust firmware (crates/wohl-fw-door), not Zephyr. + -- Bare-metal Rust firmware (crates/wohl-fw-door-bench), not Zephyr. + -- Bench/development node only — field nodes use STM32WL55 (sub-GHz) + -- or a CAN-FD bus. features uart_out: requires bus access UART_Bus; properties diff --git a/spar/wohl_nodes.aadl b/spar/wohl_nodes.aadl index c5a7e58..f784c19 100644 --- a/spar/wohl_nodes.aadl +++ b/spar/wohl_nodes.aadl @@ -50,10 +50,13 @@ public -- ══════════════════════════════════════════════════════════════ -- DoorWindowNode.WiredG0 - -- MC-38 reed on an STM32G031K8, mains/USB-powered, wired UART to - -- the hub. Bare-metal Rust firmware (crates/wohl-fw-door): debounce - -- the reed edge, emit a 14-byte CCSDS SensorPacket over UART. - -- No radio, no battery — the first physically-deployed Wohl node. + -- MC-38 reed on an STM32G031K8, bench/USB-powered, wired UART to + -- the hub. Bare-metal Rust firmware (crates/wohl-fw-door-bench): + -- debounce the reed edge, emit a 14-byte CCSDS SensorPacket. + -- No radio, no battery — a BENCH/DEVELOPMENT node: it exercises the + -- hub's CCSDS ingest without radio or bus hardware. Field door + -- nodes use STM32WL55 (sub-GHz) or a CAN-FD bus, not point-to-point + -- UART. See DoorWindowNode.Battery for the wireless field shape. -- ══════════════════════════════════════════════════════════════ system implementation DoorWindowNode.WiredG0 From 8b231d986649ca35901e4ad0c5f06d9808da3008 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 20 May 2026 06:45:59 +0200 Subject: [PATCH 5/6] Update Cargo.lock for the wohl-fw-door-bench rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lockfile follow-up to the crate rename — the package entry is now wohl-fw-door-bench. No dependency-version changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 192 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ccd3fa..a6d19ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,21 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bare-metal" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5deb64efa5bd81e31fcd1938615a6d98c82eafcbcd787162b6f63b91d6bac5b3" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "bare-metal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fe8f5a8a398345e52358e18ff07cc17a568fbca5c6f73873d3a62056309603" + [[package]] name = "bit-set" version = "0.8.0" @@ -29,6 +44,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitfield" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46afbd2983a5d5a7bd740ccb198caf5b82f45c40c09c0eed36052d91cb92e719" + [[package]] name = "bitflags" version = "2.11.0" @@ -41,6 +62,55 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cortex-m" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9" +dependencies = [ + "bare-metal 0.2.5", + "bitfield", + "critical-section", + "embedded-hal", + "volatile-register", +] + +[[package]] +name = "cortex-m-rt" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d4dec46b34c299ccf6b036717ae0fce602faa4f4fe816d9013b9a7c9f5ba6" +dependencies = [ + "cortex-m-rt-macros", +] + +[[package]] +name = "cortex-m-rt-macros" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e37549a379a9e0e6e576fd208ee60394ccb8be963889eebba3ffe0980364f472" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[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 = "equivalent" version = "1.0.2" @@ -75,6 +145,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "fugit" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e639847d312d9a82d2e75b0edcc1e934efcc64e6cb7aa94f0b1fbec0bc231d6" +dependencies = [ + "gcd", +] + +[[package]] +name = "gcd" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" + [[package]] name = "getrandom" version = "0.3.4" @@ -175,6 +260,21 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[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 = "num-traits" version = "0.2.19" @@ -190,6 +290,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "panic-halt" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a513e167849a384b7f9b746e517604398518590a9142f4846a32e3c2a4de7b11" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -376,6 +482,15 @@ dependencies = [ "wit-bindgen 0.41.0", ] +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver 0.9.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -401,12 +516,27 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + [[package]] name = "serde" version = "1.0.228" @@ -459,6 +589,33 @@ dependencies = [ "serde", ] +[[package]] +name = "stm32g0" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc2ac544cea741c92a501bfd027d197354cd22ee92b439aea26d2ee0b55bcd7" +dependencies = [ + "bare-metal 1.0.0", + "cortex-m", + "cortex-m-rt", + "vcell", +] + +[[package]] +name = "stm32g0xx-hal" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae457e81f9601121c5b92dca20e3612c80ea957898f8e0e68efcaab6b242067" +dependencies = [ + "bare-metal 1.0.0", + "cortex-m", + "embedded-hal", + "fugit", + "nb 1.1.0", + "stm32g0", + "void", +] + [[package]] name = "syn" version = "2.0.117" @@ -542,6 +699,27 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "vcell" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "volatile-register" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de437e2a6208b014ab52972a27e59b33fa2920d3e00fe05026167a1c509d19cc" +dependencies = [ + "vcell", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -600,7 +778,7 @@ dependencies = [ "bitflags", "hashbrown 0.15.5", "indexmap", - "semver", + "semver 1.0.28", ] [[package]] @@ -731,7 +909,7 @@ dependencies = [ "id-arena", "indexmap", "log", - "semver", + "semver 1.0.28", "serde", "serde_derive", "serde_json", @@ -758,6 +936,18 @@ dependencies = [ name = "wohl-door" version = "0.1.0" +[[package]] +name = "wohl-fw-door-bench" +version = "0.1.0" +dependencies = [ + "cortex-m", + "cortex-m-rt", + "nb 1.1.0", + "panic-halt", + "proptest", + "stm32g0xx-hal", +] + [[package]] name = "wohl-hub" version = "0.1.0" From 4bf4f0a177b65516539ace43925e6692de59358d Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 20 May 2026 07:33:57 +0200 Subject: [PATCH 6/6] Track B review fix: AADL STM32G031 clock matches the firmware Reviewer flagged that the processor declared Clock_Period => 15625 ps (64 MHz) while the firmware boots on HSI16 at 16 MHz (main.rs leaves the PLL off). Model the actual boot clock: 62500 ps / 16 MHz, with a comment noting 64 MHz is the unused PLL ceiling. Co-Authored-By: Claude Opus 4.7 (1M context) --- spar/wohl_hardware.aadl | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spar/wohl_hardware.aadl b/spar/wohl_hardware.aadl index b865bb5..aa6f58b 100644 --- a/spar/wohl_hardware.aadl +++ b/spar/wohl_hardware.aadl @@ -138,7 +138,9 @@ public end NRF52840; processor STM32G031 - -- STMicro STM32G031K8: Cortex-M0+, 64 MHz, 8 KB RAM, 64 KB flash. + -- STMicro STM32G031K8: Cortex-M0+, 8 KB RAM, 64 KB flash. + -- 64 MHz PLL ceiling; the bench firmware runs on HSI16 (16 MHz) — + -- the PLL is not enabled, 16 MHz is ample for the reed poll + UART. -- No integrated radio — sensor data leaves over a wired UART link. -- Bare-metal Rust firmware (crates/wohl-fw-door-bench), not Zephyr. -- Bench/development node only — field nodes use STM32WL55 (sub-GHz) @@ -148,7 +150,7 @@ public properties Deployment::Execution_Platform => Native; Scheduling_Protocol => (Rate_Monotonic_Protocol); - Clock_Period => 15625 ps; -- 64 MHz + Clock_Period => 62500 ps; -- 16 MHz (HSI16, as the firmware runs) Wohl_Properties::Sleep_Current_uA => 1.8; Wohl_Properties::Active_Current_uA => 5000.0; end STM32G031;