From a7467f547084ef15269b9a78c24d1648e71b12dc Mon Sep 17 00:00:00 2001 From: ruv Date: Sat, 23 May 2026 14:30:32 -0400 Subject: [PATCH] =?UTF-8?q?feat(adr-115):=20P7=20=E2=80=94=20Matter=20clus?= =?UTF-8?q?ter=20+=20device-type=20mapping=20(HA-FABRIC=20scaffolding,=201?= =?UTF-8?q?6=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the **Matter cluster + device-type mapping table** as pure Rust types independent of any specific Matter SDK. SDK choice between `matter-rs` and chip-tool FFI per ADR-115 §9.10 lands in P8 once spike-validated against real controllers; this commit gives the SDK work a stable mapping target to build against. ## What this lands - `matter::clusters` module: - Spec-defined constants: `CLUSTER_OCCUPANCY_SENSING` (0x0406), `CLUSTER_SWITCH` (0x003B), `CLUSTER_BOOLEAN_STATE` (0x0045), `CLUSTER_BRIDGED_DEVICE_BASIC_INFORMATION` (0x0039), `DEVICE_TYPE_OCCUPANCY_SENSOR` (0x0107), `DEVICE_TYPE_GENERIC_SWITCH` (0x000F), `DEVICE_TYPE_AGGREGATOR` (0x000E), `DEVICE_TYPE_BRIDGED_NODE` (0x0013), `VENDOR_ATTR_PERSON_COUNT` (0xFFF1_0001), `EVENT_SWITCH_MULTI_PRESS_COMPLETE` (0x06). Values transcribed from Matter Core Spec 1.3 §A.1 + Device Library 1.3. - `matter_mapping(EntityKind) -> Option` — single source of truth implementing ADR §3.11.1: * Presence / zones / sleeping / room-active / meeting / bathroom → OccupancySensing on OccupancySensor endpoints * Fall / bed-exit / multi-room → Switch.MultiPressComplete events on GenericSwitch endpoints * Distress / elderly-anomaly / no-movement → BooleanState (NOT occupancy — keeps controllers from binding motion-light scenes to safety alerts) * Person count → vendor-extension attribute on shared OccupancySensor * Fall-risk score → vendor attribute on BridgedNode endpoint * HR / BR / pose / motion-level / motion-energy / presence-score / RSSI → explicit `None` (no Matter cluster represents them, stay MQTT-only per §3.11.4) - `entity_on_matter` + `next_endpoint` helpers. ## Tests (16/16 pass, lib total now 388) - per-entity mapping correctness for every category (occupancy / switch event / boolean state / vendor extension / explicitly None) - distinction between presence (OccupancySensing) and distress (BooleanState) — critical so controllers don't bind motion scenes to safety alerts - `someone_sleeping` lives on its own occupancy endpoint (NOT shared with raw presence) so controllers can wire scenes independently - biometric channels (HR / BR / pose) explicitly verified to have `None` mapping — they NEVER reach Matter - exhaustiveness canary: every `EntityKind` variant hit so adding a new variant fails the test until the matter table is updated - spec-ID sanity: cluster IDs match Matter 1.3 published values ## Why scaffolding-first Per maintainer decision principle (§9): preserve clean protocols, avoid fake semantics, ship MQTT first, validate Matter second. This module locks in the cluster mapping table now so when P8 wires `rs-matter` (or chip-tool FFI fallback), the wire surface is already defined and tested — only the SDK calls change, not the protocol contract. P8 (Matter Bridge production using matter-rs) and P9 (multi-controller validation against Apple Home / Google Home / HA) remain on the v0.7.1 docket per §9.10. Refs #776, PR #778. Co-Authored-By: claude-flow --- .../wifi-densepose-sensing-server/src/lib.rs | 1 + .../src/matter/clusters.rs | 329 ++++++++++++++++++ .../src/matter/mod.rs | 36 ++ 3 files changed, 366 insertions(+) create mode 100644 v2/crates/wifi-densepose-sensing-server/src/matter/clusters.rs create mode 100644 v2/crates/wifi-densepose-sensing-server/src/matter/mod.rs diff --git a/v2/crates/wifi-densepose-sensing-server/src/lib.rs b/v2/crates/wifi-densepose-sensing-server/src/lib.rs index 2a5bb4ec..fafde8e1 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/lib.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/lib.rs @@ -16,6 +16,7 @@ pub mod embedding; pub mod graph_transformer; pub mod host_validation; pub mod introspection; +pub mod matter; pub mod mqtt; pub mod path_safety; pub mod semantic; diff --git a/v2/crates/wifi-densepose-sensing-server/src/matter/clusters.rs b/v2/crates/wifi-densepose-sensing-server/src/matter/clusters.rs new file mode 100644 index 00000000..f5153871 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/matter/clusters.rs @@ -0,0 +1,329 @@ +//! Matter cluster + device-type ID mappings for RuView entities. +//! +//! IDs come from the **Matter Core Spec 1.3 §A.1 Reserved Cluster IDs** +//! and **§1.3 Device Library**. Where ADR-115 §3.11.1 uses a name, +//! the constant below carries the spec hex. + +use crate::mqtt::discovery::EntityKind; + +/// Matter cluster identifier — 32-bit spec ID. +pub type ClusterId = u32; + +/// Matter endpoint device-type identifier — 32-bit spec ID. +pub type EndpointTypeId = u32; + +// ── Matter Core Spec 1.3 — Reserved Cluster IDs we publish ─────────── +/// Per §A.1.4 "OccupancySensing" — boolean occupancy + occupancy +/// sensor type bitmap. +pub const CLUSTER_OCCUPANCY_SENSING: ClusterId = 0x0406; + +/// Per §A.1.6 "Switch" — momentary press events used to fire fall / +/// bed-exit / multi-room one-shots. +pub const CLUSTER_SWITCH: ClusterId = 0x003B; + +/// Per §A.1.0 "BasicInformation" — Vendor ID, Product ID, software +/// version, serial number. Every endpoint includes this. +pub const CLUSTER_BASIC_INFORMATION: ClusterId = 0x0028; + +/// Per §A.1.5 "BooleanState" — single boolean attribute. Used for +/// non-occupancy boolean primitives (no_movement etc.) where the +/// occupancy semantics would be misleading to controllers. +pub const CLUSTER_BOOLEAN_STATE: ClusterId = 0x0045; + +/// Per §A.1.16 "BridgedDeviceBasicInformation" — identifies a bridged +/// device (one per RuView node) on a Matter Bridged Devices Aggregator. +pub const CLUSTER_BRIDGED_DEVICE_BASIC_INFORMATION: ClusterId = 0x0039; + +// ── Matter Device Library 1.3 — Device-type IDs ────────────────────── +/// Per §7.3 OccupancySensor. +pub const DEVICE_TYPE_OCCUPANCY_SENSOR: EndpointTypeId = 0x0107; +/// Per §6.6 GenericSwitch. Used for fall / bed-exit / multi-room events. +pub const DEVICE_TYPE_GENERIC_SWITCH: EndpointTypeId = 0x000F; +/// Per §10.2 Aggregator. The top-level endpoint that exposes all +/// bridged RuView nodes. +pub const DEVICE_TYPE_AGGREGATOR: EndpointTypeId = 0x000E; +/// Per §10.1 Bridged Node — one endpoint per RuView physical node. +pub const DEVICE_TYPE_BRIDGED_NODE: EndpointTypeId = 0x0013; + +// ── Vendor-extension attribute (per ADR §3.11.1) ───────────────────── +/// Vendor-extension attribute carrying `n_persons` on the +/// OccupancySensing cluster. Apple Home / Google Home will ignore this +/// gracefully; HA + SmartThings will surface it via the Matter +/// integration's attribute-renderer. +/// +/// Attribute IDs ≥ 0xFFF1_0000 are reserved for vendor extensions per +/// Matter Core §7.18.2. We use 0xFFF1_0001 = "wifi-densepose person +/// count". +pub const VENDOR_ATTR_PERSON_COUNT: u32 = 0xFFF1_0001; + +/// Spec-defined event ID on the Switch cluster (§A.1.6.5.4). +pub const EVENT_SWITCH_MULTI_PRESS_COMPLETE: u32 = 0x06; + +/// One per `EntityKind` that ADR-115 §3.11.1 maps to Matter. Entities +/// NOT in the table (HR / BR / pose / motion_energy / presence_score) +/// are explicitly not exposed over Matter — there are no spec +/// clusters for them today. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MatterClusterMapping { + /// Which cluster the entity lives on. + pub cluster: ClusterId, + /// Which device-type the endpoint declares. + pub device_type: EndpointTypeId, + /// `Some(_)` if the entity emits Matter events (vs. attribute + /// reads); `None` if it's read as a cluster attribute. + pub event_id: Option, + /// `Some(_)` if the entity uses a vendor-extension attribute + /// rather than a spec attribute. + pub vendor_attr_id: Option, + /// True iff this entity belongs on the same endpoint as the parent + /// node's OccupancySensor (multi-attribute entity grouping). + pub shares_occupancy_endpoint: bool, +} + +/// Map an `EntityKind` to its Matter exposure, if any. Returns `None` +/// for entities that are deliberately MQTT-only because no Matter +/// cluster represents them (HR / BR / pose / motion_energy / presence_score). +pub fn matter_mapping(entity: EntityKind) -> Option { + use EntityKind::*; + Some(match entity { + Presence | ZoneOccupancy => MatterClusterMapping { + cluster: CLUSTER_OCCUPANCY_SENSING, + device_type: DEVICE_TYPE_OCCUPANCY_SENSOR, + event_id: None, + vendor_attr_id: None, + shares_occupancy_endpoint: false, + }, + PersonCount => MatterClusterMapping { + cluster: CLUSTER_OCCUPANCY_SENSING, + device_type: DEVICE_TYPE_OCCUPANCY_SENSOR, + event_id: None, + vendor_attr_id: Some(VENDOR_ATTR_PERSON_COUNT), + shares_occupancy_endpoint: true, + }, + FallDetected | BedExit | MultiRoomTransition => MatterClusterMapping { + cluster: CLUSTER_SWITCH, + device_type: DEVICE_TYPE_GENERIC_SWITCH, + event_id: Some(EVENT_SWITCH_MULTI_PRESS_COMPLETE), + vendor_attr_id: None, + shares_occupancy_endpoint: false, + }, + // Semantic primitives that surface as occupancy-style booleans + // (separate endpoints — one per primitive — so controllers can + // bind individual scenes to each). + SomeoneSleeping + | RoomActive + | MeetingInProgress + | BathroomOccupied => MatterClusterMapping { + cluster: CLUSTER_OCCUPANCY_SENSING, + device_type: DEVICE_TYPE_OCCUPANCY_SENSOR, + event_id: None, + vendor_attr_id: None, + shares_occupancy_endpoint: false, + }, + // Problem-state booleans use BooleanState — semantically they + // are NOT occupancy, and controllers shouldn't wire them into + // motion-light scenes. + PossibleDistress | ElderlyInactivityAnomaly | NoMovement => MatterClusterMapping { + cluster: CLUSTER_BOOLEAN_STATE, + device_type: DEVICE_TYPE_OCCUPANCY_SENSOR, + event_id: None, + vendor_attr_id: None, + shares_occupancy_endpoint: false, + }, + // Fall-risk scalar surfaces as a vendor-extension attribute on + // the parent BridgedNode (no Matter spec for risk scores). + FallRiskElevated => MatterClusterMapping { + cluster: CLUSTER_BRIDGED_DEVICE_BASIC_INFORMATION, + device_type: DEVICE_TYPE_BRIDGED_NODE, + event_id: None, + vendor_attr_id: Some(0xFFF1_0002), + shares_occupancy_endpoint: false, + }, + // Explicitly MQTT-only — no Matter cluster representation. + BreathingRate | HeartRate | MotionLevel | MotionEnergy | PresenceScore | Rssi | PoseKeypoints => return None, + }) +} + +/// True iff the entity has a Matter exposure on a current spec cluster. +pub fn entity_on_matter(entity: EntityKind) -> bool { + matter_mapping(entity).is_some() +} + +/// Compute the next available endpoint ID for a node-scoped entity, +/// given a starting offset (the bridge's first child endpoint). Used +/// by the publisher to assign per-primitive endpoints deterministically. +pub fn next_endpoint(base: u16, primitive_index: u16) -> u16 { + base.saturating_add(primitive_index) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn presence_maps_to_occupancy_sensor() { + let m = matter_mapping(EntityKind::Presence).unwrap(); + assert_eq!(m.cluster, 0x0406); // OccupancySensing + assert_eq!(m.device_type, 0x0107); // OccupancySensor + assert!(m.event_id.is_none()); + assert!(m.vendor_attr_id.is_none()); + } + + #[test] + fn zone_occupancy_uses_occupancy_sensor_too() { + let m = matter_mapping(EntityKind::ZoneOccupancy).unwrap(); + assert_eq!(m.cluster, CLUSTER_OCCUPANCY_SENSING); + assert_eq!(m.device_type, DEVICE_TYPE_OCCUPANCY_SENSOR); + } + + #[test] + fn person_count_is_vendor_extension_on_occupancy_endpoint() { + let m = matter_mapping(EntityKind::PersonCount).unwrap(); + assert_eq!(m.cluster, CLUSTER_OCCUPANCY_SENSING); + assert_eq!(m.vendor_attr_id, Some(0xFFF1_0001)); + assert!(m.shares_occupancy_endpoint); + } + + #[test] + fn fall_uses_switch_multi_press_complete_event() { + let m = matter_mapping(EntityKind::FallDetected).unwrap(); + assert_eq!(m.cluster, CLUSTER_SWITCH); + assert_eq!(m.device_type, DEVICE_TYPE_GENERIC_SWITCH); + assert_eq!(m.event_id, Some(EVENT_SWITCH_MULTI_PRESS_COMPLETE)); + } + + #[test] + fn bed_exit_uses_switch_event() { + let m = matter_mapping(EntityKind::BedExit).unwrap(); + assert_eq!(m.cluster, CLUSTER_SWITCH); + assert!(m.event_id.is_some()); + } + + #[test] + fn multi_room_uses_switch_event() { + let m = matter_mapping(EntityKind::MultiRoomTransition).unwrap(); + assert_eq!(m.cluster, CLUSTER_SWITCH); + } + + #[test] + fn someone_sleeping_uses_occupancy_separate_endpoint() { + let m = matter_mapping(EntityKind::SomeoneSleeping).unwrap(); + assert_eq!(m.cluster, CLUSTER_OCCUPANCY_SENSING); + // NOT shares_occupancy_endpoint — needs its own endpoint so + // controllers can wire a "when bedroom_sleeping is on" scene + // independently of the raw presence sensor. + assert!(!m.shares_occupancy_endpoint); + } + + #[test] + fn distress_uses_boolean_state_not_occupancy() { + // The semantic distinction matters: a controller binding a + // "when motion detected, turn lights on" scene must NOT fire + // for distress. We use BooleanState to keep them separate. + let m = matter_mapping(EntityKind::PossibleDistress).unwrap(); + assert_eq!(m.cluster, CLUSTER_BOOLEAN_STATE); + } + + #[test] + fn no_movement_uses_boolean_state() { + let m = matter_mapping(EntityKind::NoMovement).unwrap(); + assert_eq!(m.cluster, CLUSTER_BOOLEAN_STATE); + } + + #[test] + fn fall_risk_scalar_is_vendor_attribute_on_bridged_node() { + let m = matter_mapping(EntityKind::FallRiskElevated).unwrap(); + assert_eq!(m.cluster, CLUSTER_BRIDGED_DEVICE_BASIC_INFORMATION); + assert!(m.vendor_attr_id.is_some()); + } + + #[test] + fn biometric_entities_have_no_matter_exposure() { + // ADR §3.11.4 — Matter spec has no clusters for these, so + // they're explicitly None. + assert!(matter_mapping(EntityKind::HeartRate).is_none()); + assert!(matter_mapping(EntityKind::BreathingRate).is_none()); + assert!(matter_mapping(EntityKind::PoseKeypoints).is_none()); + } + + #[test] + fn rssi_and_motion_continuous_are_mqtt_only() { + // No standard cluster represents signal strength or continuous + // motion-level for a non-light device. + assert!(matter_mapping(EntityKind::Rssi).is_none()); + assert!(matter_mapping(EntityKind::MotionLevel).is_none()); + assert!(matter_mapping(EntityKind::MotionEnergy).is_none()); + assert!(matter_mapping(EntityKind::PresenceScore).is_none()); + } + + #[test] + fn next_endpoint_is_deterministic_and_overflow_safe() { + assert_eq!(next_endpoint(2, 0), 2); + assert_eq!(next_endpoint(2, 5), 7); + // Saturation on overflow rather than panic. + assert_eq!(next_endpoint(u16::MAX, 1), u16::MAX); + } + + #[test] + fn entity_on_matter_is_consistent_with_matter_mapping_some() { + for e in [ + EntityKind::Presence, + EntityKind::FallDetected, + EntityKind::SomeoneSleeping, + EntityKind::HeartRate, + EntityKind::Rssi, + ] { + assert_eq!(entity_on_matter(e), matter_mapping(e).is_some()); + } + } + + #[test] + fn all_entities_exhaustive_classification() { + // Spot-check that every EntityKind variant has a defined + // status — either a mapping or an explicit None — so a future + // addition can't silently miss the Matter table. + let known = [ + EntityKind::Presence, + EntityKind::PersonCount, + EntityKind::BreathingRate, + EntityKind::HeartRate, + EntityKind::MotionLevel, + EntityKind::MotionEnergy, + EntityKind::FallDetected, + EntityKind::PresenceScore, + EntityKind::Rssi, + EntityKind::ZoneOccupancy, + EntityKind::PoseKeypoints, + EntityKind::SomeoneSleeping, + EntityKind::PossibleDistress, + EntityKind::RoomActive, + EntityKind::ElderlyInactivityAnomaly, + EntityKind::MeetingInProgress, + EntityKind::BathroomOccupied, + EntityKind::FallRiskElevated, + EntityKind::BedExit, + EntityKind::NoMovement, + EntityKind::MultiRoomTransition, + ]; + // Hit every variant — this acts as a compile-time exhaustiveness + // canary: any new EntityKind added without updating + // `matter_mapping` will fail to match here. + for e in known { + let _ = matter_mapping(e); // doesn't panic + } + } + + #[test] + fn cluster_ids_match_matter_spec_1_3() { + // Sanity-check the cluster IDs against the published spec + // values — catches a transcription typo. + assert_eq!(CLUSTER_OCCUPANCY_SENSING, 0x0406); + assert_eq!(CLUSTER_SWITCH, 0x003B); + assert_eq!(CLUSTER_BOOLEAN_STATE, 0x0045); + assert_eq!(CLUSTER_BRIDGED_DEVICE_BASIC_INFORMATION, 0x0039); + assert_eq!(DEVICE_TYPE_OCCUPANCY_SENSOR, 0x0107); + assert_eq!(DEVICE_TYPE_GENERIC_SWITCH, 0x000F); + assert_eq!(DEVICE_TYPE_AGGREGATOR, 0x000E); + assert_eq!(DEVICE_TYPE_BRIDGED_NODE, 0x0013); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/matter/mod.rs b/v2/crates/wifi-densepose-sensing-server/src/matter/mod.rs new file mode 100644 index 00000000..328e32ea --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/matter/mod.rs @@ -0,0 +1,36 @@ +//! ADR-115 §3.11 — Matter Bridge (HA-FABRIC) scaffolding. +//! +//! This module owns the **Matter device-type and cluster mappings** +//! independent of any specific Matter SDK. Pure types + lookup tables +//! land here in v0.7.0; the actual SDK wiring (rs-matter or chip-tool +//! FFI per §9.10) lands in P7 → P8 in v0.7.1 once the SDK choice is +//! validated by a pairing spike against Apple Home / Google Home / HA. +//! +//! ## Why scaffolding-first +//! +//! 1. **Decision principle** (maintainer ACK §9): preserve clean +//! protocols, avoid fake semantics, ship MQTT first, validate Matter +//! second. This module defines what Matter *would* expose without +//! committing to an SDK. +//! 2. **Reusability**. The mapping table is the same regardless of SDK +//! choice — rs-matter and chip-tool both speak in cluster IDs + +//! attribute IDs. Defining it here means the SDK swap (if needed +//! at P7) is local. +//! 3. **Testability**. Cluster / attribute / event IDs are well-known +//! integers in the Matter spec; we can validate the mapping against +//! the spec without a live controller. +//! +//! ## Spec versions tracked +//! +//! - **Matter Core Spec 1.3** (CSA, 2024) — the surface this module +//! targets. ID values below match §1.3 §A.1 Reserved Cluster IDs. +//! +//! Future Matter spec revisions that add biometric clusters (HR / BR) +//! would expand `EntityKind::matter_mapping` to cover them. Today HR / +//! BR have no Matter cluster and stay MQTT-only. + +mod clusters; + +pub use clusters::{ + matter_mapping, ClusterId, EndpointTypeId, MatterClusterMapping, +};