mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
feat(adr-115): P7 — Matter cluster + device-type mapping (HA-FABRIC scaffolding, 16 tests)
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<MatterClusterMapping>` —
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 <ruv@ruv.net>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<u32>,
|
||||
/// `Some(_)` if the entity uses a vendor-extension attribute
|
||||
/// rather than a spec attribute.
|
||||
pub vendor_attr_id: Option<u32>,
|
||||
/// 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<MatterClusterMapping> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user