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, +};