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" 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/boards/stm32g0/README.md b/boards/stm32g0/README.md new file mode 100644 index 0000000..f273a69 --- /dev/null +++ b/boards/stm32g0/README.md @@ -0,0 +1,104 @@ +# `boards/stm32g0` + +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-bench/`](../../crates/wohl-fw-door-bench/). + +## Role — bench tool, not a field node + +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. + +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: + +- **STM32WL55** — sub-GHz (868/915 MHz) for wireless nodes +- **CAN-FD** — multi-drop wired bus for cabled installs + +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 + +- **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) + +| 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 | +| `PA13` | `SWDIO` | SWD debug | Reserved — do not reuse | +| `PA14` | `SWCLK` | SWD debug | Reserved — do not reuse | +| `VDD` | `+3V0` | Bench/USB supply via LDO | | +| `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; UART baud error at 115200 / 16 MHz is < 0.2 %, well +within the 2.5 % asynchronous tolerance. + +## Linker / memory + +`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 = 64K + RAM : ORIGIN = 0x20000000, LENGTH = 8K +} +``` + +(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.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-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-bench/.cargo/config.toml b/crates/wohl-fw-door-bench/.cargo/config.toml new file mode 100644 index 0000000..730b8ae --- /dev/null +++ b/crates/wohl-fw-door-bench/.cargo/config.toml @@ -0,0 +1,23 @@ +# 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-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. + +[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-bench/Cargo.toml b/crates/wohl-fw-door-bench/Cargo.toml new file mode 100644 index 0000000..8d4f83c --- /dev/null +++ b/crates/wohl-fw-door-bench/Cargo.toml @@ -0,0 +1,46 @@ +[package] +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" +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_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-bench" +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-bench/build.rs b/crates/wohl-fw-door-bench/build.rs new file mode 100644 index 0000000..17f53eb --- /dev/null +++ b/crates/wohl-fw-door-bench/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-bench/memory.x b/crates/wohl-fw-door-bench/memory.x new file mode 100644 index 0000000..2baa0ae --- /dev/null +++ b/crates/wohl-fw-door-bench/memory.x @@ -0,0 +1,8 @@ +/* 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 = 64K + RAM : ORIGIN = 0x20000000, LENGTH = 8K +} diff --git a/crates/wohl-fw-door-bench/src/ccsds.rs b/crates/wohl-fw-door-bench/src/ccsds.rs new file mode 100644 index 0000000..38b3a69 --- /dev/null +++ b/crates/wohl-fw-door-bench/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-bench/src/debounce.rs b/crates/wohl-fw-door-bench/src/debounce.rs new file mode 100644 index 0000000..1ffb7bf --- /dev/null +++ b/crates/wohl-fw-door-bench/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-bench/src/door.rs b/crates/wohl-fw-door-bench/src/door.rs new file mode 100644 index 0000000..e98a250 --- /dev/null +++ b/crates/wohl-fw-door-bench/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-bench/src/lib.rs b/crates/wohl-fw-door-bench/src/lib.rs new file mode 100644 index 0000000..32679b0 --- /dev/null +++ b/crates/wohl-fw-door-bench/src/lib.rs @@ -0,0 +1,34 @@ +//! 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: +//! +//! - **`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-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. +//! +//! 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-bench/src/main.rs b/crates/wohl-fw-door-bench/src/main.rs new file mode 100644 index 0000000..3e12cf4 --- /dev/null +++ b/crates/wohl-fw-door-bench/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_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. + 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-bench 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). + } + } + } +} diff --git a/spar/wohl_hardware.aadl b/spar/wohl_hardware.aadl index 878bd9b..aa6f58b 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,24 @@ public Wohl_Properties::Zephyr_Binding => "nordic,nrf52840"; end NRF52840; + processor STM32G031 + -- 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) + -- or a CAN-FD bus. + features + uart_out: requires bus access UART_Bus; + properties + Deployment::Execution_Platform => Native; + Scheduling_Protocol => (Rate_Monotonic_Protocol); + 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; + 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..f784c19 100644 --- a/spar/wohl_nodes.aadl +++ b/spar/wohl_nodes.aadl @@ -48,6 +48,41 @@ public Wohl_Properties::Expected_Battery_Life_Years => 2.5; end DoorWindowNode.Battery; + -- ══════════════════════════════════════════════════════════════ + -- DoorWindowNode.WiredG0 + -- 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 + 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.