Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ members = [
"crates/wohl-integration",
"crates/wohl-fw-door-bench",
"crates/wohl-ota",
"crates/wohl-matter-bridge",
]

[workspace.package]
Expand Down Expand Up @@ -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"
105 changes: 105 additions & 0 deletions WORKSPACE_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions crates/wohl-hub/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
167 changes: 167 additions & 0 deletions crates/wohl-hub/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────

Expand Down Expand Up @@ -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<Box<dyn MatterBridge>>,
}

impl WohlHub {
Expand All @@ -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<dyn MatterBridge>) {
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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Vec<ReadingKind>>,
alerts: std::sync::Mutex<Vec<String>>,
}

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<CountingBridge>);
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.
Expand Down
Loading
Loading