diff --git a/Cargo.toml b/Cargo.toml index bd6b067..696ff7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/wohl-integration", "crates/wohl-fw-door-bench", "crates/wohl-ota", + "crates/wohl-matter-bridge", ] [workspace.package] @@ -41,4 +42,5 @@ wohl-door = { path = "crates/wohl-door" } wohl-power = { path = "crates/wohl-power" } wohl-alert = { path = "crates/wohl-alert" } wohl-ota = { path = "crates/wohl-ota" } +wohl-matter-bridge = { path = "crates/wohl-matter-bridge" } proptest = "1" diff --git a/WORKSPACE_INTEGRATION.md b/WORKSPACE_INTEGRATION.md new file mode 100644 index 0000000..e698a78 --- /dev/null +++ b/WORKSPACE_INTEGRATION.md @@ -0,0 +1,105 @@ +# WORKSPACE_INTEGRATION.md + +Notes for the orchestrator merging branch `0.2.0/matter-bridge-scaffold`. + +## What this branch ships + +A new sibling crate, `crates/wohl-matter-bridge`, plus a minimal `--matter` +flag in `crates/wohl-hub`. The new crate is currently referenced from +`wohl-hub` via a direct path dep (`wohl-matter-bridge = { path = "../wohl-matter-bridge" }`) +which builds correctly out of the worktree because both crates are members +of the same Cargo workspace tree. + +However, the crate is **not yet a workspace member**, which means it does +not pick up workspace lints, won't get touched by `cargo test --workspace`, +and won't show up in `cargo build --workspace`. It does build, fmt, clippy, +and test cleanly under `cargo +1.85.0 ... -p wohl-matter-bridge -p wohl-hub`. + +## Required root `Cargo.toml` edits + +In `/Users/r/git/pulseengine/wohl/Cargo.toml`: + +1. Add `"crates/wohl-matter-bridge"` to `[workspace] members = [...]`. +2. Add the crate to `[workspace.dependencies]` so future consumers can + use `wohl-matter-bridge.workspace = true`. + +Concrete diff: + +```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-bench", + "crates/wohl-ota", ++ "crates/wohl-matter-bridge", + ] + + ... + + [workspace.dependencies] + ... + wohl-alert = { path = "crates/wohl-alert" } + wohl-ota = { path = "crates/wohl-ota" } ++wohl-matter-bridge = { path = "crates/wohl-matter-bridge" } + proptest = "1" +``` + +After applying those edits, **also** update `crates/wohl-hub/Cargo.toml` +to use workspace inheritance for consistency: + +```diff +-wohl-matter-bridge = { path = "../wohl-matter-bridge" } ++wohl-matter-bridge.workspace = true +``` + +This is a one-line follow-up; the current direct path form is correct +and works, it just stylistically diverges from the rest of the file. + +## Verification after orchestrator edits + +Run, from repo root: + +```bash +cargo +1.85.0 fmt --check +cargo +1.85.0 clippy --workspace --all-targets -- -D warnings +cargo +1.85.0 test --workspace +``` + +All three should pass without changes to this branch. + +## Verified line untouched + +This PR makes **zero** edits to: + +- `crates/wohl-{leak,temp,air,door,power,alert}/` +- `crates/wohl-ota/` +- `crates/wohl-fw-door-bench/` + +Kani verification on `wohl-alert` (and the other verified crates) is +unaffected. The Matter scaffold lives wholly on the hub side, outside the +verified sensor / dispatcher boundary, exactly as +[SWARCH-WOHL-006](artifacts/swarch/SWARCH-WOHL-006.yaml) prescribes. + +## What's NOT in this PR + +- No rs-matter dependency. The Cargo.toml is deliberately + rs-matter-free. The 0.3.0 follow-up adds it behind an + `rs-matter-backend` feature gate. See + `crates/wohl-matter-bridge/DESIGN.md` § 3. +- No commissioning, no mDNS, no UDP, no Matter wire bytes. 0.3.0 scope. +- No attestation cert plumbing. 0.3.x / 0.4.0 scope (gated on CSA + vendor ID acquisition). + +## Branch + PR posture + +Branch: `0.2.0/matter-bridge-scaffold` (this branch). +Commit: see `git log -1 --format=%H 0.2.0/matter-bridge-scaffold`. +Not pushed. Not opened as PR. Orchestrator decides when to push + open. diff --git a/crates/wohl-hub/Cargo.toml b/crates/wohl-hub/Cargo.toml index 4c2f724..f4ad4ad 100644 --- a/crates/wohl-hub/Cargo.toml +++ b/crates/wohl-hub/Cargo.toml @@ -18,6 +18,7 @@ wohl-air.workspace = true wohl-door.workspace = true wohl-power.workspace = true wohl-alert.workspace = true +wohl-matter-bridge.workspace = true # Relay engines relay-lc.workspace = true diff --git a/crates/wohl-hub/src/main.rs b/crates/wohl-hub/src/main.rs index 9854303..3e0930f 100644 --- a/crates/wohl-hub/src/main.rs +++ b/crates/wohl-hub/src/main.rs @@ -10,6 +10,10 @@ use std::io::{BufRead, Read}; use relay_ccsds::sensor_wire; use serde::{Deserialize, Serialize}; +use wohl_matter_bridge::{ + AlertKind as MatterAlertKind, BridgedAlert, LoggingBridge, MatterBridge, ReadingKind, + SensorReading, +}; // ── Configuration types ──────────────────────────────────────── @@ -179,6 +183,10 @@ struct WohlHub { // Track the last tick time for housekeeping last_tick: u64, + + // Optional Matter bridge — populated when --matter / WOHL_MATTER is set. + // When None (the default), wohl-hub behaves identically to 0.1.0. + matter_bridge: Option>, } impl WohlHub { @@ -198,12 +206,54 @@ impl WohlHub { contact_zones: [(0, 0); 32], contact_zone_count: 0, last_tick: 0, + matter_bridge: None, }; hub.configure(config); hub } + /// Install an optional Matter bridge. When set, dispatched alerts and + /// sensor readings are forwarded to the bridge in addition to the + /// existing stdout JSON output. + fn set_matter_bridge(&mut self, bridge: Box) { + self.matter_bridge = Some(bridge); + } + + /// Push a sensor reading to the bridge, if configured. Best-effort — + /// the bridge is a side-channel and must never block the monitor + /// dispatch path. + fn bridge_reading(&self, kind: ReadingKind, endpoint_id: u32, value: i64, time: u64) { + if let Some(b) = self.matter_bridge.as_ref() { + b.publish_reading(SensorReading { + kind, + endpoint_id, + value, + time, + }); + } + } + + /// Push a dispatched alert (one that already survived dedup + + /// rate-limit) to the bridge, if configured. Reads the same + /// `AlertOutput` we emit on stdout to keep the wiring minimal. + fn bridge_alert(&self, alert: &AlertOutput) { + let Some(b) = self.matter_bridge.as_ref() else { + return; + }; + let Some(kind) = MatterAlertKind::from_tag(&alert.alert) else { + return; + }; + b.publish_alert(BridgedAlert { + kind, + zone_id: alert.zone, + contact_id: alert.contact, + circuit_id: alert.circuit, + value: alert.value, + time: alert.time, + }); + } + fn configure(&mut self, config: &HubConfig) { // Register zones with monitors for zone in &config.zones { @@ -355,6 +405,7 @@ impl WohlHub { self.monitor_counters[0] += 1; self.health .update_counter(APP_TEMP, self.monitor_counters[0]); + self.bridge_reading(ReadingKind::Temperature, zone, value as i64, time); let result = self.temp.process_reading(zone, value, time); for i in 0..result.alert_count as usize { @@ -388,6 +439,12 @@ impl WohlHub { self.monitor_counters[1] += 1; self.health .update_counter(APP_LEAK, self.monitor_counters[1]); + self.bridge_reading( + ReadingKind::WaterPresence, + zone, + if wet { 1 } else { 0 }, + time, + ); let action = self.leak.process_event(zone, wet, time); if action == wohl_leak::engine::LeakAction::NewLeak @@ -416,6 +473,13 @@ impl WohlHub { self.monitor_counters[2] += 1; self.health .update_counter(APP_AIR, self.monitor_counters[2]); + self.bridge_reading(ReadingKind::Co2, zone, co2 as i64, time); + if let Some(v) = pm25 { + self.bridge_reading(ReadingKind::Pm25, zone, v as i64, time); + } + if let Some(v) = voc { + self.bridge_reading(ReadingKind::Voc, zone, v as i64, time); + } let reading = wohl_air::engine::AirReading { zone_id: zone, @@ -466,6 +530,7 @@ impl WohlHub { self.monitor_counters[3] += 1; self.health .update_counter(APP_DOOR, self.monitor_counters[3]); + self.bridge_reading(ReadingKind::Contact, id, if open { 1 } else { 0 }, time); let result = self.door.process_event(id, open, time); for i in 0..result.alert_count as usize { @@ -502,6 +567,7 @@ impl WohlHub { self.monitor_counters[4] += 1; self.health .update_counter(APP_POWER, self.monitor_counters[4]); + self.bridge_reading(ReadingKind::Power, circuit, watts as i64, time); let result = self.power.process_reading(circuit, watts, time); for i in 0..result.alert_count as usize { @@ -594,6 +660,13 @@ impl WohlHub { } } + // Forward every dispatched alert to the Matter bridge, if one is + // installed. The bridge is a side-channel: with --matter off this + // is a no-op and wohl-hub behaves identically to 0.1.0. + for a in &alerts { + self.bridge_alert(a); + } + alerts } } @@ -862,12 +935,32 @@ fn input_mode_is_ccsds() -> bool { matches!(std::env::var("WOHL_INPUT").as_deref(), Ok("ccsds")) } +/// True if the operator opted into the Matter bridge (off by default). +/// Recognized: `--matter` CLI flag, or `WOHL_MATTER=1` / `WOHL_MATTER=true`. +fn matter_bridge_enabled() -> bool { + if std::env::args().any(|a| a == "--matter") { + return true; + } + matches!( + std::env::var("WOHL_MATTER").as_deref(), + Ok("1") | Ok("true") + ) +} + // ── Main ─────────────────────────────────────────────────────── fn main() { let config = load_config(); let mut hub = WohlHub::new(&config); + if matter_bridge_enabled() { + eprintln!( + "[wohl-hub] --matter: installing LoggingBridge stub (0.2.0 scaffold; \ + real rs-matter integration lands in 0.3.0)" + ); + hub.set_matter_bridge(Box::new(LoggingBridge::to_stderr())); + } + if input_mode_is_ccsds() { run_ccsds_mode(&mut hub); } else { @@ -1439,6 +1532,80 @@ dedup_cooldown_sec = 60 assert!(packet_to_event(&pkt, 0).is_none()); } + /// Counting Matter bridge stub for tests — records every reading / + /// alert call so we can assert wohl-hub forwards them when --matter + /// is set. + #[derive(Default)] + struct CountingBridge { + readings: std::sync::Mutex>, + alerts: std::sync::Mutex>, + } + + impl MatterBridge for CountingBridge { + fn publish_reading(&self, reading: SensorReading) { + self.readings.lock().unwrap().push(reading.kind); + } + fn publish_alert(&self, alert: BridgedAlert) { + self.alerts.lock().unwrap().push(alert.kind.as_tag().into()); + } + } + + #[test] + fn test_bridge_off_by_default_behaves_like_0_1_0() { + // Default hub: no bridge installed. The reading/alert paths must + // still produce the same JSON alerts as before. + let mut hub = test_hub(); + assert!(hub.matter_bridge.is_none()); + let alerts = hub.process_event(SensorEvent::Temp { + zone: 1, + value: -100, + time: 1000, + }); + assert_eq!(alerts[0].alert, "freeze"); + } + + #[test] + fn test_bridge_receives_readings_and_alerts_when_installed() { + let mut hub = test_hub(); + let bridge = std::sync::Arc::new(CountingBridge::default()); + // Install via a thin Arc-cloning adapter so the test can still + // observe the counts after the hub takes ownership. + struct ArcAdapter(std::sync::Arc); + impl MatterBridge for ArcAdapter { + fn publish_reading(&self, r: SensorReading) { + self.0.publish_reading(r); + } + fn publish_alert(&self, a: BridgedAlert) { + self.0.publish_alert(a); + } + } + hub.set_matter_bridge(Box::new(ArcAdapter(bridge.clone()))); + + // Below-freeze temp → one Temperature reading + one freeze alert. + let alerts = hub.process_event(SensorEvent::Temp { + zone: 1, + value: -100, + time: 1000, + }); + assert_eq!(alerts.len(), 1); + + let readings = bridge.readings.lock().unwrap(); + assert!( + readings + .iter() + .any(|k| matches!(k, ReadingKind::Temperature)), + "expected a Temperature reading: got {:?}", + *readings + ); + + let alerts_fwd = bridge.alerts.lock().unwrap(); + assert!( + alerts_fwd.iter().any(|s| s == "freeze"), + "expected freeze forwarded to bridge: got {:?}", + *alerts_fwd + ); + } + #[test] fn test_end_to_end_ccsds_freeze() { // Encode a below-freeze temp reading, decode, translate, feed to hub. diff --git a/crates/wohl-matter-bridge/Cargo.toml b/crates/wohl-matter-bridge/Cargo.toml new file mode 100644 index 0000000..542dd5b --- /dev/null +++ b/crates/wohl-matter-bridge/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "wohl-matter-bridge" +description = "Wohl Matter Bridge — hub-side translation of Wohl alerts / sensor readings into Matter bridged-endpoint attributes" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +rust-version = "1.85.0" + +[lib] +path = "src/lib.rs" +crate-type = ["rlib"] + +# NOTE: This 0.2.0 deliverable is the *scaffold* — a public trait +# (`MatterBridge`), a typed cluster-mapping table, and a logging stub impl. +# It deliberately does NOT depend on rs-matter so that: +# 1. It compiles fast and reliably on the workspace MSRV (1.85). +# 2. The verified line (wohl-leak/temp/air/door/power/alert/ota) keeps +# a small, audited dependency closure. +# 3. The Matter wire integration can be scoped as its own follow-up PR +# (target: 0.3.0), gated behind a feature flag and pinned rs-matter +# version. See DESIGN.md "rs-matter target version". +# +# When the live integration lands, expect: +# [features] +# default = [] +# rs-matter-backend = ["dep:rs-matter"] +# [dependencies] +# rs-matter = { version = "...", optional = true, default-features = false, features = ["std", "rustcrypto"] } + +[dependencies] + +[dev-dependencies] + +[lints] +workspace = true diff --git a/crates/wohl-matter-bridge/DESIGN.md b/crates/wohl-matter-bridge/DESIGN.md new file mode 100644 index 0000000..6cdc426 --- /dev/null +++ b/crates/wohl-matter-bridge/DESIGN.md @@ -0,0 +1,179 @@ +# wohl-matter-bridge — Design + +Design doc for the **live** rs-matter integration that follows the +0.2.0 scaffold landed in this crate. The 0.3.0 implementor reads this +to know what to build. + +Authoritative architecture record: [SWARCH-WOHL-006](../../artifacts/swarch/SWARCH-WOHL-006.yaml). + +--- + +## 1. Why scaffold + design before live integration + +Wohl's differentiator is **formally verified firmware** (Kani BMC + Verus, +`no_std`, `no_alloc`). The Matter stack (rs-matter or equivalent) is several +hundred KB of allocating Rust pulling in mDNS, TLS, BLE, and a commissioning +state machine — structurally outside Kani/Verus and outside the verified +sensor boundary by design. + +Splitting the work in two PRs: + +1. **0.2.0 (this PR)** — land the trait + cluster mapping + stub. Small, + easy to audit, no new transitive dependencies for the verified line. + `wohl-hub` gets a `--matter` flag that exercises the bridge path + end-to-end against the stub. +2. **0.3.0** — wire the real rs-matter behind the same trait. The + verified line is untouched; the cluster decisions are already + reviewed and unit-tested; the diff is concentrated in one place. + +This keeps the verification claim ("verified end-to-end on the sensor +line") defensible: the Matter stack is explicitly hub-side and explicitly +outside the verified boundary. + +## 2. Cluster mapping decisions + +Encoded in `src/cluster.rs` as a typed enum match, unit-tested in +`mapping_for_alert` / `mapping_for_reading`. Summary: + +| Wohl kind | Matter cluster | id | Attribute | +|------------------------------------------|---------------------------------------------------------|--------|-----------------| +| freeze / overheat / rapid_drop / rapid_rise | TemperatureMeasurement | 0x0402 | MeasuredValue | +| water_leak | BooleanState (Matter 1.0 fallback) | 0x0045 | StateValue | +| co2_warning / co2_critical | CarbonDioxideConcentrationMeasurement | 0x040D | MeasuredValue | +| pm25_warning / pm25_critical | Pm25ConcentrationMeasurement | 0x042A | MeasuredValue | +| voc_warning / voc_critical | TotalVolatileOrganicCompoundsConcentrationMeasurement | 0x042C | MeasuredValue | +| door_open_too_long / door_opened_at_night| BooleanState | 0x0045 | StateValue | +| overconsumption / power_spike / device_left_on | ElectricalPowerMeasurement | 0x0090 | ActivePower | +| health_miss | *(internal — not bridged)* | — | — | + +### Rationale per row + +- **Temperature.** All four temp-alert flavors share `TemperatureMeasurement`. + Matter has no native "freeze threshold crossed" event cluster; the controller + observes `MeasuredValue` and applies its own threshold (Apple Home, Google, + etc. all let users set their own alert bands). The freeze / overheat + semantics live in the wohl notification path (push, SMS), not in Matter. +- **Water leak.** Matter 1.2 added the `WaterLeakDetector` device type + (cluster 0x0048), but as of 2026-05 Apple Home and Google Home both + fall back to BooleanState. We ship the BooleanState mapping for + broadest compatibility and keep `WaterLeakDetector` in the enum for + forward-compat. The 0.3.0 implementor may dual-publish. +- **Air quality.** Three separate concentration-measurement clusters, + all using `MeasuredValue` (0x0000). Same controller-threshold pattern + as temperature. +- **Door / window.** `BooleanState::StateValue` is the universal + pattern. The "too long open" + "opened at night" distinction is a + hub-side policy and is not exposed to Matter as a separate signal. +- **Power.** `ElectricalPowerMeasurement` is Matter 1.3+. ActivePower + (0x0005) is the live wattage. Spike vs over-consumption is again + hub-side policy. +- **Health miss.** Internal-only signal — the bridge drops it. + Controllers should not see hub-internal liveness data. + +## 3. rs-matter target version + +Target: **rs-matter 0.1.x** (latest patch at integration time). + +Rationale: + +- rs-matter is the only mature pure-Rust Matter SDK. +- It's still pre-1.0; we pin a tilde range (`~0.1`) to take bugfix + releases without an unreviewed minor bump. +- As of writing, recent rs-matter releases require **rustc 1.87**, above + the workspace MSRV (1.85). The 0.3.0 PR raises the **hub-only** MSRV + via a `rust-version` override in `crates/wohl-hub/Cargo.toml` and + `crates/wohl-matter-bridge/Cargo.toml`. The verified sensor crates + stay on 1.85. + +Linkage will be feature-gated: + +```toml +[features] +default = [] +rs-matter-backend = ["dep:rs-matter"] +``` + +So the trait + stub remain buildable on 1.85 even after the live impl lands. + +## 4. Commissioning approach (0.3.0) + +- **QR code + manual setup code.** Generated once at first boot. Printed + on stderr (so journald captures it) and written to disk for repeat + display. +- **Persistent commissioning data.** Stored under + `/var/lib/wohl/matter/` on Linux hub deployments: + - `fabric.bin` — fabric table after commissioning (sealed via systemd + credentials or DPAPI-equivalent in future). + - `acl.bin` — access-control list. + - `setup-code.txt` — human-readable manual setup code, recoverable. + - `discriminator` — 12-bit discriminator (random at first boot). +- **Factory reset.** Deleting `/var/lib/wohl/matter/` and restarting the + hub clears commissioning. We expose this as `wohl-hub matter reset` + for operators. +- **First-boot UX.** wohl-hub logs the QR code as ASCII to stderr on + first boot when no fabric data exists. Operators photograph it with + the Home app. + +## 5. Multi-admin (multi-fabric) behavior + +Wohl bridge should accept **multiple fabrics simultaneously** — a user +adding the bridge to Apple Home then later to Google Home must keep +both working without re-pairing. + +- rs-matter supports multi-fabric out of the box; we just don't + fail-fast on a second commissioner. +- Each fabric gets its own ACL entry; we publish the same attribute + updates to all fabrics. +- The "Share with another platform" flow (Matter Multi-Admin) issues a + new pairing code from an already-paired controller. Our bridge does + not need special handling — rs-matter exposes a node-operational + credentials API for this. + +Open: do we expose a "show second pairing code" affordance in +wohl-hub's CLI, or rely entirely on the first-fabric controller's UI +to drive multi-admin? **Default to controller-driven; revisit if +operators complain.** + +## 6. Attestation certificates + +- **0.2.0 (this PR):** N/A — no Matter wire bits. +- **0.3.0 dev:** stub DAC (Device Attestation Certificate) using rs-matter + example certs. Bridge will commission with "uncertified device" + warnings in controllers, which is fine for dev/internal builds. +- **0.3.x or 0.4.0 production:** real DAC issued by CSA. Requires: + - CSA vendor ID (apply with Connectivity Standards Alliance, + one-time fee + annual maintenance). + - Per-bridge-batch CD (Certification Declaration) tied to the + certified Matter Bridge device type. + - PAA/PAI chain — either using the CSA test PAA or our own PAA + rooted at a CSA-recognized authority. + - Secure storage of the device-specific DAC key. On Linux hub + deployments this lands in `/var/lib/wohl/matter/dac.key`, + permissioned to the wohl service user. + +Vendor-ID acquisition is the long-pole — it gates the production cert +chain, not the engineering work. + +## 7. Open questions for the 0.3.0 implementor + +1. **Endpoint id allocation.** wohl-hub today has three separate id + spaces (zone, contact, circuit). The bridge needs a stable + 1:1 → Matter-endpoint mapping. Proposal: deterministic flattening + (`endpoint_id = kind_offset + native_id`, e.g. zones 1..=99, + contacts 100..=199, circuits 200..=255). Persist the map under + `/var/lib/wohl/matter/endpoints.toml` to keep ids stable across + controller re-syncs. **Decide before commissioning ships.** + +2. **WaterLeakDetector dual-publish.** Do we publish to both + `BooleanState` (0x0045) *and* `WaterLeakDetector` (0x0048) so + newer controllers get the proper device type, or stick with + `BooleanState`-only for simplicity? Real-world controller + support (Apple/Google) drives this — needs a test pass against + each platform when the impl lands. + +3. **Reading throttle / rate limit.** Should the bridge throttle + high-frequency readings (e.g. 1Hz power) before publishing, or + pass them through and rely on rs-matter's subscription + reporting cadence? Leaning toward "pass through, let + rs-matter subscription cadence handle it" — but verify the + bridge doesn't accidentally hot-loop the radio. diff --git a/crates/wohl-matter-bridge/README.md b/crates/wohl-matter-bridge/README.md new file mode 100644 index 0000000..7026f1c --- /dev/null +++ b/crates/wohl-matter-bridge/README.md @@ -0,0 +1,31 @@ +# wohl-matter-bridge + +Hub-side scaffold for exposing Wohl sensors as Matter bridged endpoints. + +This 0.2.0 crate is the **interface + stub**, not a live rs-matter integration. +It provides: + +- `MatterBridge` trait — the contract `wohl-hub` calls. +- `MatterClusterMapping` table — typed mapping from each wohl alert / reading + kind to its Matter cluster + attribute (BooleanState, TemperatureMeasurement, + ElectricalPowerMeasurement, …). +- `LoggingBridge` — stderr stub used by `wohl-hub --matter` to validate wiring. + +The live rs-matter integration (commissioning, fabrics, mDNS, attestation) is +the **0.3.0** scope. See [`DESIGN.md`](DESIGN.md) for the full design rationale, +target rs-matter version, commissioning approach, and open questions. + +## Usage in wohl-hub (0.2.0) + +Run with `--matter` (or `WOHL_MATTER=1`) and the hub instantiates a +`LoggingBridge` and forwards every dispatched alert + sensor reading to it +**in addition** to the existing stdout JSON output. With the flag off, +wohl-hub behaves identically to 0.1.0. + +## Why a scaffold first + +Per [SWARCH-WOHL-006](../../artifacts/swarch/SWARCH-WOHL-006.yaml): the +unverified Matter stack must live on the hub, outside the sensor safety +boundary. Landing the trait + cluster mapping as data (and thoroughly +unit-testing it) lets the 0.3.0 implementor focus on the rs-matter wire +integration without renegotiating the cluster choices. diff --git a/crates/wohl-matter-bridge/src/cluster.rs b/crates/wohl-matter-bridge/src/cluster.rs new file mode 100644 index 0000000..c73974a --- /dev/null +++ b/crates/wohl-matter-bridge/src/cluster.rs @@ -0,0 +1,374 @@ +//! Matter cluster mapping table. +//! +//! Maps each wohl [`AlertKind`] / [`ReadingKind`] to the specific Matter +//! cluster + attribute that should reflect the event. +//! +//! Encoded as enums (not strings) so that: +//! - The mapping is exhaustive at compile time — adding a new alert kind +//! forces an explicit decision here (the match in +//! [`matter_cluster_for`] is non-`_`-defaulted). +//! - Downstream code (the future rs-matter impl) gets a typed contract. +//! +//! Cluster IDs and attribute references follow the Matter Application +//! Cluster Specification 1.3 (latest at time of writing). Source for each +//! mapping decision is documented inline. + +use crate::types::{AlertKind, ReadingKind}; + +/// A Matter cluster, identified by its application cluster id (hex). +/// +/// Only the clusters Wohl bridges today are enumerated. Adding a new +/// sensor type is a deliberate change here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MatterCluster { + /// Boolean State (0x0045) — Matter 1.0 generic "is this thing + /// triggered?" cluster. Used for door/window contact and as a + /// 1.0-compatible fallback for water-leak. + BooleanState, + /// Water Leak Detector (0x0048) — Matter 1.2+ specific cluster. + /// We declare it for forward-compat, but ship with BooleanState as + /// the wire-level publish in 0.3.0 for broadest controller support. + WaterLeakDetector, + /// Temperature Measurement (0x0402) — measured temperature in + /// centi-degrees Celsius (signed int16). + TemperatureMeasurement, + /// Carbon Dioxide Concentration Measurement (0x040D). + CarbonDioxideConcentrationMeasurement, + /// PM2.5 Concentration Measurement (0x042A). + Pm25ConcentrationMeasurement, + /// Total Volatile Organic Compounds Concentration Measurement (0x042C). + TotalVolatileOrganicCompoundsConcentrationMeasurement, + /// Electrical Power Measurement (0x0090) — Matter 1.3+. ActivePower + /// attribute carries the live wattage. + ElectricalPowerMeasurement, +} + +impl MatterCluster { + /// Numeric Matter cluster id (the value used on the wire). + pub const fn cluster_id(self) -> u32 { + match self { + Self::BooleanState => 0x0045, + Self::WaterLeakDetector => 0x0048, + Self::TemperatureMeasurement => 0x0402, + Self::CarbonDioxideConcentrationMeasurement => 0x040D, + Self::Pm25ConcentrationMeasurement => 0x042A, + Self::TotalVolatileOrganicCompoundsConcentrationMeasurement => 0x042C, + Self::ElectricalPowerMeasurement => 0x0090, + } + } +} + +/// A specific Matter attribute within a cluster. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MatterAttribute { + /// BooleanState::StateValue (0x0000) — true when contact closed + /// / leak present (per Matter device-type semantics). + StateValue, + /// MeasuredValue (0x0000) — the generic measurement attribute, used + /// by Temperature, CO2, PM2.5, VOC clusters. + MeasuredValue, + /// ElectricalPowerMeasurement::ActivePower (0x0005) — instantaneous + /// active power, milliwatts on the wire. + ActivePower, +} + +impl MatterAttribute { + /// Numeric Matter attribute id. + pub const fn attribute_id(self) -> u32 { + match self { + Self::StateValue => 0x0000, + Self::MeasuredValue => 0x0000, + Self::ActivePower => 0x0005, + } + } +} + +/// A resolved (cluster, attribute) pair, plus a short rationale tag for +/// the implementor reading the mapping table. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MatterClusterMapping { + pub cluster: MatterCluster, + pub attribute: MatterAttribute, +} + +impl MatterClusterMapping { + pub const fn new(cluster: MatterCluster, attribute: MatterAttribute) -> Self { + Self { cluster, attribute } + } +} + +/// Map a wohl alert tag (as emitted by wohl-hub today) to the Matter +/// cluster + attribute that should reflect it. +/// +/// Returns `None` for tags that don't have a meaningful Matter analog — +/// e.g. `"health_miss"`, which is an internal-health signal and is not +/// surfaced to Matter controllers. +/// +/// This wraps [`AlertKind::from_tag`] + [`mapping_for_alert`] for callers +/// that hold the raw string. +pub fn matter_cluster_for(alert_kind: &str) -> Option { + AlertKind::from_tag(alert_kind).and_then(mapping_for_alert) +} + +/// Typed counterpart of [`matter_cluster_for`] — exhaustive over +/// [`AlertKind`]. +pub const fn mapping_for_alert(kind: AlertKind) -> Option { + use MatterAttribute::*; + use MatterCluster::*; + + Some(match kind { + // Temperature monitor — the alert latch reflects the same + // physical attribute Matter exposes (MeasuredValue). Controllers + // already alarm on out-of-band values, so we surface the reading + // and let the value speak. See DESIGN.md "Why no separate + // freeze-alert cluster". + AlertKind::Freeze | AlertKind::Overheat | AlertKind::RapidDrop | AlertKind::RapidRise => { + MatterClusterMapping::new(TemperatureMeasurement, MeasuredValue) + } + + // Water leak. Matter 1.0 generic fallback is BooleanState; + // Matter 1.2 added a specific WaterLeakDetector device type. + // In 0.2.0 the mapping table records BooleanState (broadest + // controller compatibility). 0.3.0 may dual-publish. + AlertKind::WaterLeak => MatterClusterMapping::new(BooleanState, StateValue), + + // Air-quality clusters all share the MeasuredValue attribute id + // (0x0000) within their respective concentration-measurement + // clusters. We surface MeasuredValue; controllers compare it + // against their own thresholds. + AlertKind::Co2Warning | AlertKind::Co2Critical => { + MatterClusterMapping::new(CarbonDioxideConcentrationMeasurement, MeasuredValue) + } + AlertKind::Pm25Warning | AlertKind::Pm25Critical => { + MatterClusterMapping::new(Pm25ConcentrationMeasurement, MeasuredValue) + } + AlertKind::VocWarning | AlertKind::VocCritical => MatterClusterMapping::new( + TotalVolatileOrganicCompoundsConcentrationMeasurement, + MeasuredValue, + ), + + // Door / window contact events surface as BooleanState::StateValue + // toggling. The two alert flavors (open-too-long, opened-at-night) + // both point at the same attribute; the rich detail lives in the + // hub's notification path, not Matter. + AlertKind::DoorOpenTooLong | AlertKind::DoorOpenedAtNight => { + MatterClusterMapping::new(BooleanState, StateValue) + } + + // Power. ElectricalPowerMeasurement::ActivePower is the live + // wattage. Both overconsumption and spike alerts publish the + // wattage that triggered the alert. + AlertKind::Overconsumption | AlertKind::PowerSpike | AlertKind::DeviceLeftOn => { + MatterClusterMapping::new(ElectricalPowerMeasurement, ActivePower) + } + + // Internal: not bridged to Matter. + AlertKind::HealthMiss => return None, + }) +} + +/// Map a periodic sensor reading to its Matter cluster + attribute. +/// Returns `None` for reading kinds without a Matter analog. +pub const fn mapping_for_reading(kind: ReadingKind) -> Option { + use MatterAttribute::*; + use MatterCluster::*; + + Some(match kind { + ReadingKind::Temperature => { + MatterClusterMapping::new(TemperatureMeasurement, MeasuredValue) + } + ReadingKind::Co2 => { + MatterClusterMapping::new(CarbonDioxideConcentrationMeasurement, MeasuredValue) + } + ReadingKind::Pm25 => MatterClusterMapping::new(Pm25ConcentrationMeasurement, MeasuredValue), + ReadingKind::Voc => MatterClusterMapping::new( + TotalVolatileOrganicCompoundsConcentrationMeasurement, + MeasuredValue, + ), + ReadingKind::Power => MatterClusterMapping::new(ElectricalPowerMeasurement, ActivePower), + ReadingKind::Contact | ReadingKind::WaterPresence => { + MatterClusterMapping::new(BooleanState, StateValue) + } + }) +} + +// ── Tests ────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn alert_kind_roundtrip_through_string() { + for kind in [ + AlertKind::Freeze, + AlertKind::Overheat, + AlertKind::RapidDrop, + AlertKind::RapidRise, + AlertKind::WaterLeak, + AlertKind::Co2Warning, + AlertKind::Co2Critical, + AlertKind::Pm25Warning, + AlertKind::Pm25Critical, + AlertKind::VocWarning, + AlertKind::VocCritical, + AlertKind::DoorOpenTooLong, + AlertKind::DoorOpenedAtNight, + AlertKind::Overconsumption, + AlertKind::PowerSpike, + AlertKind::DeviceLeftOn, + AlertKind::HealthMiss, + ] { + let s = kind.as_tag(); + assert_eq!( + AlertKind::from_tag(s), + Some(kind), + "roundtrip failed for {:?}", + kind + ); + } + } + + #[test] + fn unknown_alert_string_returns_none() { + assert!(matter_cluster_for("not_a_real_alert").is_none()); + assert!(matter_cluster_for("").is_none()); + } + + #[test] + fn health_miss_is_not_bridged_to_matter() { + // The mapping must explicitly return None for internal health + // signals; controllers shouldn't see them. + assert!(matter_cluster_for("health_miss").is_none()); + assert!(mapping_for_alert(AlertKind::HealthMiss).is_none()); + } + + #[test] + fn temperature_alerts_publish_to_temperature_measurement() { + for tag in ["freeze", "overheat", "rapid_drop", "rapid_rise"] { + let m = matter_cluster_for(tag).unwrap_or_else(|| panic!("no mapping for {}", tag)); + assert_eq!(m.cluster, MatterCluster::TemperatureMeasurement); + assert_eq!(m.attribute, MatterAttribute::MeasuredValue); + assert_eq!(m.cluster.cluster_id(), 0x0402); + assert_eq!(m.attribute.attribute_id(), 0x0000); + } + } + + #[test] + fn water_leak_maps_to_boolean_state_for_1_0_compat() { + let m = matter_cluster_for("water_leak").unwrap(); + assert_eq!(m.cluster, MatterCluster::BooleanState); + assert_eq!(m.attribute, MatterAttribute::StateValue); + assert_eq!(m.cluster.cluster_id(), 0x0045); + } + + #[test] + fn co2_alerts_publish_to_co2_concentration() { + for tag in ["co2_warning", "co2_critical"] { + let m = matter_cluster_for(tag).unwrap(); + assert_eq!( + m.cluster, + MatterCluster::CarbonDioxideConcentrationMeasurement + ); + assert_eq!(m.cluster.cluster_id(), 0x040D); + } + } + + #[test] + fn pm25_alerts_publish_to_pm25_concentration() { + for tag in ["pm25_warning", "pm25_critical"] { + let m = matter_cluster_for(tag).unwrap(); + assert_eq!(m.cluster, MatterCluster::Pm25ConcentrationMeasurement); + assert_eq!(m.cluster.cluster_id(), 0x042A); + } + } + + #[test] + fn voc_alerts_publish_to_voc_concentration() { + for tag in ["voc_warning", "voc_critical"] { + let m = matter_cluster_for(tag).unwrap(); + assert_eq!( + m.cluster, + MatterCluster::TotalVolatileOrganicCompoundsConcentrationMeasurement + ); + assert_eq!(m.cluster.cluster_id(), 0x042C); + } + } + + #[test] + fn door_alerts_publish_to_boolean_state() { + for tag in ["door_open_too_long", "door_opened_at_night"] { + let m = matter_cluster_for(tag).unwrap(); + assert_eq!(m.cluster, MatterCluster::BooleanState); + assert_eq!(m.attribute, MatterAttribute::StateValue); + } + } + + #[test] + fn power_alerts_publish_to_active_power() { + for tag in ["overconsumption", "power_spike", "device_left_on"] { + let m = matter_cluster_for(tag).unwrap(); + assert_eq!(m.cluster, MatterCluster::ElectricalPowerMeasurement); + assert_eq!(m.attribute, MatterAttribute::ActivePower); + assert_eq!(m.cluster.cluster_id(), 0x0090); + assert_eq!(m.attribute.attribute_id(), 0x0005); + } + } + + #[test] + fn cluster_ids_are_stable_hex_values() { + // Pin specific cluster IDs to catch accidental edits — these are + // the Matter Application Cluster Specification 1.3 values. + assert_eq!(MatterCluster::BooleanState.cluster_id(), 0x0045); + assert_eq!(MatterCluster::WaterLeakDetector.cluster_id(), 0x0048); + assert_eq!(MatterCluster::TemperatureMeasurement.cluster_id(), 0x0402); + assert_eq!( + MatterCluster::CarbonDioxideConcentrationMeasurement.cluster_id(), + 0x040D + ); + assert_eq!( + MatterCluster::Pm25ConcentrationMeasurement.cluster_id(), + 0x042A + ); + assert_eq!( + MatterCluster::TotalVolatileOrganicCompoundsConcentrationMeasurement.cluster_id(), + 0x042C + ); + assert_eq!( + MatterCluster::ElectricalPowerMeasurement.cluster_id(), + 0x0090 + ); + } + + #[test] + fn reading_mappings_cover_all_kinds() { + // Every ReadingKind must have a non-None mapping. If a future + // reading is added without a Matter analog, change this test + // and document the gap. + for k in [ + ReadingKind::Temperature, + ReadingKind::Co2, + ReadingKind::Pm25, + ReadingKind::Voc, + ReadingKind::Power, + ReadingKind::Contact, + ReadingKind::WaterPresence, + ] { + assert!( + mapping_for_reading(k).is_some(), + "no Matter mapping for reading kind {:?}", + k + ); + } + } + + #[test] + fn reading_temperature_matches_alert_temperature() { + // Sanity: a Temp reading and a freeze alert publish to the + // same Matter attribute — they describe the same physical + // value. + let r = mapping_for_reading(ReadingKind::Temperature).unwrap(); + let a = mapping_for_alert(AlertKind::Freeze).unwrap(); + assert_eq!(r, a); + } +} diff --git a/crates/wohl-matter-bridge/src/lib.rs b/crates/wohl-matter-bridge/src/lib.rs new file mode 100644 index 0000000..18703c6 --- /dev/null +++ b/crates/wohl-matter-bridge/src/lib.rs @@ -0,0 +1,57 @@ +//! Wohl Matter Bridge — scaffold for the hub-side Matter integration. +//! +//! Per [SWARCH-WOHL-006], Wohl sensors stay lean and verified, emitting CCSDS +//! packets to the hub. The hub translates that traffic into Matter bridged +//! endpoints. This crate defines the *boundary* between Wohl's alert / +//! sensor-reading domain types and the Matter cluster/attribute model. +//! +//! In 0.2.0 this crate contains: +//! - the [`MatterBridge`] trait wohl-hub calls, +//! - minimal data types ([`SensorReading`], [`BridgedAlert`]) decoupled +//! from wohl-hub's internal alert struct, +//! - a typed [`MatterClusterMapping`] table mapping each alert / reading +//! kind to a concrete Matter cluster + attribute, +//! - [`LoggingBridge`], a stderr-logging stub impl used by +//! `wohl-hub --matter`. +//! +//! The live rs-matter integration (commissioning, fabrics, mDNS, attestation, +//! attribute publication on UDP) is the 0.3.0 scope. See `DESIGN.md`. +//! +//! This crate is `no_std`-compatible at the trait + mapping layer (no alloc, +//! no std), but the [`LoggingBridge`] needs `std` for I/O. We allow `std` +//! here because the bridge always runs on the hub (Pi / STM32H7-class), +//! never on a sensor node. The verified sensor line stays untouched. +//! +//! [SWARCH-WOHL-006]: ../../../artifacts/swarch/SWARCH-WOHL-006.yaml + +#![forbid(unsafe_code)] + +pub mod cluster; +pub mod logging; +pub mod types; + +pub use cluster::{MatterAttribute, MatterCluster, MatterClusterMapping, matter_cluster_for}; +pub use logging::LoggingBridge; +pub use types::{AlertKind, BridgedAlert, ReadingKind, SensorReading}; + +/// The boundary between wohl-hub's alert / reading loop and any Matter +/// implementation. wohl-hub holds a `Box` (or similar) +/// and calls these methods alongside its existing stdout JSON output. +/// +/// Implementations: +/// - [`LoggingBridge`] (0.2.0) — stderr stub used to validate wiring. +/// - `RsMatterBridge` (planned, 0.3.0) — the real rs-matter-backed impl, +/// publishing attribute updates to the Matter fabric. +/// +/// The trait is intentionally narrow. Anything stateful (commissioning, +/// fabric membership, attribute caches) lives behind the implementor. +pub trait MatterBridge: Send + Sync + 'static { + /// A periodic sensor reading arrived. The bridge translates it to the + /// matching Matter cluster attribute and publishes the update. + fn publish_reading(&self, reading: SensorReading); + + /// A monitor produced an alert that survived dedup + rate-limit. The + /// bridge translates it to the matching Matter cluster attribute + /// (typically BooleanState-like) and publishes the update. + fn publish_alert(&self, alert: BridgedAlert); +} diff --git a/crates/wohl-matter-bridge/src/logging.rs b/crates/wohl-matter-bridge/src/logging.rs new file mode 100644 index 0000000..ebcea77 --- /dev/null +++ b/crates/wohl-matter-bridge/src/logging.rs @@ -0,0 +1,207 @@ +//! [`LoggingBridge`] — stub [`MatterBridge`] that logs what it *would* +//! publish to a real Matter fabric. +//! +//! Used by `wohl-hub --matter` in 0.2.0 to validate the wiring end-to-end +//! without pulling in rs-matter. The output format is deliberately +//! human-readable (single line per event, on stderr) — it is not a stable +//! contract. + +use std::io::{self, Write}; +use std::sync::Mutex; + +use crate::MatterBridge; +use crate::cluster::{mapping_for_alert, mapping_for_reading}; +use crate::types::{BridgedAlert, SensorReading}; + +/// Logging stub. Each call writes a single line to a configurable sink +/// (stderr by default) describing the would-be Matter publish. +pub struct LoggingBridge { + sink: Mutex>, +} + +impl LoggingBridge { + /// Default constructor: log to stderr. + pub fn to_stderr() -> Self { + Self { + sink: Mutex::new(Box::new(io::stderr())), + } + } + + /// Inject an alternate sink — used by tests to capture output. + pub fn with_sink(sink: W) -> Self { + Self { + sink: Mutex::new(Box::new(sink)), + } + } + + fn write_line(&self, line: &str) { + if let Ok(mut s) = self.sink.lock() { + let _ = writeln!(s, "{}", line); + } + } +} + +impl Default for LoggingBridge { + fn default() -> Self { + Self::to_stderr() + } +} + +impl MatterBridge for LoggingBridge { + fn publish_reading(&self, reading: SensorReading) { + let Some(m) = mapping_for_reading(reading.kind) else { + return; + }; + self.write_line(&format!( + "[matter-bridge] reading endpoint={} cluster=0x{:04X} attr=0x{:04X} value={} t={}", + reading.endpoint_id, + m.cluster.cluster_id(), + m.attribute.attribute_id(), + reading.value, + reading.time, + )); + } + + fn publish_alert(&self, alert: BridgedAlert) { + let Some(m) = mapping_for_alert(alert.kind) else { + // Unmapped (e.g. HealthMiss). Drop silently — see DESIGN.md. + return; + }; + let endpoint = alert + .zone_id + .or(alert.contact_id) + .or(alert.circuit_id) + .unwrap_or(0); + let value = alert + .value + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".to_string()); + self.write_line(&format!( + "[matter-bridge] alert kind={} endpoint={} cluster=0x{:04X} attr=0x{:04X} value={} t={}", + alert.kind.as_tag(), + endpoint, + m.cluster.cluster_id(), + m.attribute.attribute_id(), + value, + alert.time, + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{AlertKind, ReadingKind}; + use std::sync::{Arc, Mutex as StdMutex}; + + /// Test sink: keeps written bytes in a shared buffer. + #[derive(Clone)] + struct VecSink(Arc>>); + + impl Write for VecSink { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + fn make_bridge() -> (LoggingBridge, Arc>>) { + let buf = Arc::new(StdMutex::new(Vec::new())); + let sink = VecSink(buf.clone()); + (LoggingBridge::with_sink(sink), buf) + } + + fn captured(buf: &Arc>>) -> String { + String::from_utf8(buf.lock().unwrap().clone()).unwrap() + } + + #[test] + fn logs_temperature_reading_with_correct_cluster_and_attribute() { + let (bridge, buf) = make_bridge(); + bridge.publish_reading(SensorReading { + kind: ReadingKind::Temperature, + endpoint_id: 7, + value: -100, + time: 1234, + }); + let out = captured(&buf); + assert!(out.contains("endpoint=7"), "got: {}", out); + assert!(out.contains("cluster=0x0402"), "got: {}", out); + assert!(out.contains("attr=0x0000"), "got: {}", out); + assert!(out.contains("value=-100"), "got: {}", out); + assert!(out.contains("t=1234"), "got: {}", out); + } + + #[test] + fn logs_freeze_alert_with_temperature_cluster() { + let (bridge, buf) = make_bridge(); + bridge.publish_alert(BridgedAlert { + kind: AlertKind::Freeze, + zone_id: Some(3), + contact_id: None, + circuit_id: None, + value: Some(-150), + time: 5000, + }); + let out = captured(&buf); + assert!(out.contains("kind=freeze")); + assert!(out.contains("endpoint=3")); + assert!(out.contains("cluster=0x0402")); + assert!(out.contains("value=-150")); + } + + #[test] + fn logs_water_leak_alert_with_boolean_state() { + let (bridge, buf) = make_bridge(); + bridge.publish_alert(BridgedAlert { + kind: AlertKind::WaterLeak, + zone_id: Some(2), + contact_id: None, + circuit_id: None, + value: None, + time: 99, + }); + let out = captured(&buf); + assert!(out.contains("kind=water_leak")); + assert!(out.contains("cluster=0x0045")); + assert!(out.contains("value=-")); // None rendered as "-" + } + + #[test] + fn health_miss_alert_is_dropped_silently() { + let (bridge, buf) = make_bridge(); + bridge.publish_alert(BridgedAlert { + kind: AlertKind::HealthMiss, + zone_id: None, + contact_id: None, + circuit_id: None, + value: Some(1), + time: 10, + }); + assert!(captured(&buf).is_empty(), "health-miss must not be bridged"); + } + + #[test] + fn endpoint_falls_back_through_zone_contact_circuit() { + let (bridge, buf) = make_bridge(); + bridge.publish_alert(BridgedAlert { + kind: AlertKind::PowerSpike, + zone_id: None, + contact_id: None, + circuit_id: Some(11), + value: Some(15000), + time: 1, + }); + let out = captured(&buf); + assert!(out.contains("endpoint=11"), "got: {}", out); + } + + #[test] + fn trait_object_is_constructible() { + // Compile-time check: LoggingBridge fits the dyn-compatible trait. + let _b: Box = Box::new(LoggingBridge::to_stderr()); + } +} diff --git a/crates/wohl-matter-bridge/src/types.rs b/crates/wohl-matter-bridge/src/types.rs new file mode 100644 index 0000000..ae383a5 --- /dev/null +++ b/crates/wohl-matter-bridge/src/types.rs @@ -0,0 +1,139 @@ +//! Bridge-facing event types. +//! +//! Mirrors wohl-hub's `SensorEvent` / `AlertOutput` shape but with a stable, +//! Matter-oriented vocabulary. Kept deliberately minimal — the bridge only +//! needs enough to pick a cluster and emit a value. + +/// What kind of alert fired. Matches the 1:1 wohl alert names used in +/// wohl-hub's `AlertOutput.alert` string (e.g. `"freeze"`, `"water_leak"`). +/// +/// Encoded as an enum so the cluster mapping is exhaustive at compile time +/// (no stringly-typed lookups). The `from_str` helper does the conversion +/// from wohl-hub's existing string tags. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AlertKind { + Freeze, + Overheat, + RapidDrop, + RapidRise, + WaterLeak, + Co2Warning, + Co2Critical, + Pm25Warning, + Pm25Critical, + VocWarning, + VocCritical, + DoorOpenTooLong, + DoorOpenedAtNight, + Overconsumption, + PowerSpike, + DeviceLeftOn, + HealthMiss, +} + +impl AlertKind { + /// Parse from wohl-hub's `AlertOutput.alert` string tag. Unknown tags + /// return `None` — the bridge skips them rather than crash. + pub fn from_tag(s: &str) -> Option { + Some(match s { + "freeze" => Self::Freeze, + "overheat" => Self::Overheat, + "rapid_drop" => Self::RapidDrop, + "rapid_rise" => Self::RapidRise, + "water_leak" => Self::WaterLeak, + "co2_warning" => Self::Co2Warning, + "co2_critical" => Self::Co2Critical, + "pm25_warning" => Self::Pm25Warning, + "pm25_critical" => Self::Pm25Critical, + "voc_warning" => Self::VocWarning, + "voc_critical" => Self::VocCritical, + "door_open_too_long" => Self::DoorOpenTooLong, + "door_opened_at_night" => Self::DoorOpenedAtNight, + "overconsumption" => Self::Overconsumption, + "power_spike" => Self::PowerSpike, + "device_left_on" => Self::DeviceLeftOn, + "health_miss" => Self::HealthMiss, + _ => return None, + }) + } + + /// Human-readable tag, the inverse of `from_tag`. + pub fn as_tag(self) -> &'static str { + match self { + Self::Freeze => "freeze", + Self::Overheat => "overheat", + Self::RapidDrop => "rapid_drop", + Self::RapidRise => "rapid_rise", + Self::WaterLeak => "water_leak", + Self::Co2Warning => "co2_warning", + Self::Co2Critical => "co2_critical", + Self::Pm25Warning => "pm25_warning", + Self::Pm25Critical => "pm25_critical", + Self::VocWarning => "voc_warning", + Self::VocCritical => "voc_critical", + Self::DoorOpenTooLong => "door_open_too_long", + Self::DoorOpenedAtNight => "door_opened_at_night", + Self::Overconsumption => "overconsumption", + Self::PowerSpike => "power_spike", + Self::DeviceLeftOn => "device_left_on", + Self::HealthMiss => "health_miss", + } + } +} + +/// What kind of periodic reading the sensor produced. Distinguishes the +/// channel from the value's interpretation (the value lives in +/// [`SensorReading::value`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ReadingKind { + /// Temperature, centi-degrees Celsius (matches Wohl wire format). + Temperature, + /// CO₂ concentration, ppm. + Co2, + /// PM2.5 concentration, μg/m³. + Pm25, + /// Volatile organic compounds, index value. + Voc, + /// Active power, watts. + Power, + /// Contact state (open=1 / closed=0). Treated as a reading rather than + /// an alert when the hub forwards bare state changes. + Contact, + /// Wet/dry leak state. + WaterPresence, +} + +/// An alert flagged by a monitor that survived dedup + rate-limit and is +/// ready to be reflected on the Matter fabric. +#[derive(Debug, Clone)] +pub struct BridgedAlert { + pub kind: AlertKind, + /// Wohl zone id (matches `wohl-hub`'s zone numbering). `None` for + /// non-zone-scoped alerts (e.g. health-miss). + pub zone_id: Option, + /// Contact id for door/window alerts. + pub contact_id: Option, + /// Circuit id for power alerts. + pub circuit_id: Option, + /// Reading value at the time the alert fired, if applicable + /// (e.g. the temperature in centi-degrees for a freeze alert). + pub value: Option, + /// Hub wall-clock timestamp, seconds since UNIX epoch. + pub time: u64, +} + +/// A periodic sensor reading — the steady-state stream of values for +/// each bridged endpoint. +#[derive(Debug, Clone)] +pub struct SensorReading { + pub kind: ReadingKind, + /// Endpoint id within the bridge. Maps to a wohl zone, contact, or + /// circuit — the implementor decides how to flatten the three + /// id spaces onto Matter endpoint ids. + pub endpoint_id: u32, + /// The value, in the unit implied by `kind` (centi-degrees for + /// Temperature, ppm for Co2, etc.). + pub value: i64, + /// Hub wall-clock timestamp, seconds since UNIX epoch. + pub time: u64, +}