//! Matter bridge-tree assembly (ADR-115 §3.11.2). //! //! Given a list of RuView nodes and the `EntityKind`s enabled for //! each, produce the Matter endpoint tree the SDK will materialise: //! //! ```text //! Endpoint 0 (root: BridgedDevicesAggregator) //! Endpoint 1 (BridgedNode for ruview-node-0) //! Endpoint 2 (OccupancySensor for presence + PersonCount attr) //! Endpoint 3 (OccupancySensor for zone_kitchen) //! Endpoint 4 (OccupancySensor for SomeoneSleeping) //! Endpoint 5 (GenericSwitch for FallDetected) //! … //! Endpoint N (BridgedNode for ruview-node-1) //! … //! ``` //! //! Tree assembly is pure logic — no SDK calls. The SDK layer reads //! this struct and registers the matching clusters. Splitting this //! out keeps the bridge topology testable independently of the //! `rs-matter` / chip-tool choice (per §9.10). use crate::mqtt::discovery::EntityKind; use super::clusters::{ matter_mapping, MatterClusterMapping, DEVICE_TYPE_AGGREGATOR, DEVICE_TYPE_BRIDGED_NODE, }; /// One endpoint on the Matter device tree. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Endpoint { pub endpoint_id: u16, pub device_type: u32, pub label: String, pub clusters: Vec, pub vendor_attrs: Vec, /// `Some(_)` if this endpoint maps back to an `EntityKind`; /// `None` for structural endpoints (aggregator root, bridged node). pub source_entity: Option, } /// One RuView node's slice of the bridge tree. #[derive(Debug, Clone)] pub struct NodeBranch { pub node_id: String, pub friendly_name: String, pub bridged_node_endpoint: u16, pub child_endpoints: Vec, } /// Whole bridge tree the SDK will materialise. #[derive(Debug, Clone)] pub struct BridgeTree { pub root: Endpoint, pub nodes: Vec, } /// Builds a [`BridgeTree`] from a list of `(node_id, friendly_name, /// entities)` tuples. Endpoint IDs are assigned monotonically starting /// at 1 (Matter reserves endpoint 0 for the root). pub fn build_bridge_tree(nodes: &[(String, String, Vec)]) -> BridgeTree { let root = Endpoint { endpoint_id: 0, device_type: DEVICE_TYPE_AGGREGATOR, label: "RuView Bridge".into(), clusters: vec![super::clusters::CLUSTER_BASIC_INFORMATION], vendor_attrs: vec![], source_entity: None, }; let mut next_endpoint: u16 = 1; let mut branches = Vec::with_capacity(nodes.len()); for (node_id, friendly_name, entities) in nodes { let bridged_node_ep = next_endpoint; next_endpoint += 1; let mut children = Vec::new(); // Build a children-by-mapping bucket: entities that share the // OccupancySensor endpoint (e.g. PersonCount attaches to // Presence's endpoint) collapse onto the parent rather than // taking their own endpoint ID. let mut presence_endpoint_id: Option = None; for entity in entities { let Some(m) = matter_mapping(*entity) else { continue; // explicitly MQTT-only }; if m.shares_occupancy_endpoint { if let Some(parent_ep) = presence_endpoint_id { // Attach as vendor attribute on the parent endpoint. if let Some(parent) = children .iter_mut() .find(|c: &&mut Endpoint| c.endpoint_id == parent_ep) { if let Some(va) = m.vendor_attr_id { parent.vendor_attrs.push(va); } parent.source_entity.get_or_insert(*entity); } continue; } } let ep_id = next_endpoint; next_endpoint += 1; let mut ep = Endpoint { endpoint_id: ep_id, device_type: m.device_type, label: format!("{:?}", entity), clusters: vec![m.cluster, super::clusters::CLUSTER_BASIC_INFORMATION], vendor_attrs: m.vendor_attr_id.into_iter().collect(), source_entity: Some(*entity), }; // Switch endpoints need the event cluster declared // (already covered by `clusters` above — but we record it // for the SDK layer's convenience). if matches!(*entity, EntityKind::Presence) { presence_endpoint_id = Some(ep_id); } if let Some(_eid) = m.event_id { // Event support is implicit when the Switch cluster is // present; the SDK reads the cluster and exposes the // event automatically. No extra field needed. } children.push(ep); } branches.push(NodeBranch { node_id: node_id.clone(), friendly_name: friendly_name.clone(), bridged_node_endpoint: bridged_node_ep, child_endpoints: children, }); } BridgeTree { root, nodes: branches, } } impl BridgeTree { /// Total number of endpoints (root + bridged nodes + per-entity). pub fn total_endpoints(&self) -> usize { let per_node: usize = self .nodes .iter() .map(|n| 1 + n.child_endpoints.len()) // BridgedNode + children .sum(); 1 /* root */ + per_node } /// Look up an endpoint by its assigned ID. Returns `None` if no /// endpoint with that ID exists in the tree. pub fn endpoint(&self, id: u16) -> Option> { if self.root.endpoint_id == id { return Some(EndpointRef::Root(&self.root)); } for n in &self.nodes { if n.bridged_node_endpoint == id { return Some(EndpointRef::BridgedNode(n)); } for child in &n.child_endpoints { if child.endpoint_id == id { return Some(EndpointRef::Child { branch: n, child }); } } } None } } /// Resolved endpoint with backref to the owning branch (for logging / /// error messages). pub enum EndpointRef<'a> { Root(&'a Endpoint), BridgedNode(&'a NodeBranch), Child { branch: &'a NodeBranch, child: &'a Endpoint }, } #[cfg(test)] mod tests { use super::*; use crate::mqtt::discovery::EntityKind::*; fn fixture() -> Vec<(String, String, Vec)> { vec![( "node_aabb".into(), "Bedroom".into(), vec![ Presence, PersonCount, // shares Presence's endpoint SomeoneSleeping, FallDetected, HeartRate, // MQTT-only → must NOT add an endpoint ], )] } #[test] fn tree_has_aggregator_root() { let tree = build_bridge_tree(&fixture()); assert_eq!(tree.root.endpoint_id, 0); assert_eq!(tree.root.device_type, DEVICE_TYPE_AGGREGATOR); } #[test] fn one_branch_per_node() { let tree = build_bridge_tree(&fixture()); assert_eq!(tree.nodes.len(), 1); assert_eq!(tree.nodes[0].node_id, "node_aabb"); assert_eq!(tree.nodes[0].friendly_name, "Bedroom"); assert_eq!(tree.nodes[0].bridged_node_endpoint, 1); } #[test] fn person_count_collapses_onto_presence_endpoint() { let tree = build_bridge_tree(&fixture()); let branch = &tree.nodes[0]; // Children: Presence/PersonCount (1 ep), SomeoneSleeping (1 ep), // FallDetected (1 ep) = 3 endpoints. HR/BR → skipped. assert_eq!(branch.child_endpoints.len(), 3); // Find the Presence endpoint — it should carry the PersonCount // vendor attribute. let presence_ep = branch .child_endpoints .iter() .find(|e| e.source_entity == Some(Presence)) .expect("presence endpoint missing"); assert!(presence_ep .vendor_attrs .contains(&super::super::clusters::VENDOR_ATTR_PERSON_COUNT)); } #[test] fn biometric_entities_skip_matter_tree() { let tree = build_bridge_tree(&fixture()); let branch = &tree.nodes[0]; for ep in &branch.child_endpoints { assert!( ep.source_entity != Some(HeartRate), "HeartRate must NOT have a Matter endpoint" ); assert!( ep.source_entity != Some(BreathingRate), "BreathingRate must NOT have a Matter endpoint" ); } } #[test] fn each_child_carries_basic_information_cluster() { let tree = build_bridge_tree(&fixture()); for branch in &tree.nodes { for ep in &branch.child_endpoints { assert!( ep.clusters .contains(&super::super::clusters::CLUSTER_BASIC_INFORMATION), "every endpoint must declare BasicInformation" ); } } } #[test] fn endpoint_ids_are_monotonic_and_unique() { let tree = build_bridge_tree(&fixture()); let mut all_ids = vec![tree.root.endpoint_id]; for branch in &tree.nodes { all_ids.push(branch.bridged_node_endpoint); for ep in &branch.child_endpoints { all_ids.push(ep.endpoint_id); } } let mut sorted = all_ids.clone(); sorted.sort_unstable(); sorted.dedup(); assert_eq!(all_ids.len(), sorted.len(), "endpoint IDs must be unique"); } #[test] fn total_endpoints_matches_explicit_count() { let tree = build_bridge_tree(&fixture()); // 1 root + 1 bridged + 3 children = 5. assert_eq!(tree.total_endpoints(), 5); } #[test] fn endpoint_lookup_resolves_all_ids() { let tree = build_bridge_tree(&fixture()); for id in 0..tree.total_endpoints() as u16 { let er = tree.endpoint(id); assert!(er.is_some(), "endpoint {} not findable", id); } // Unknown ID returns None. assert!(tree.endpoint(999).is_none()); } #[test] fn multi_node_tree_keeps_per_node_isolation() { let nodes = vec![ ("aabb".into(), "Bedroom".into(), vec![Presence, FallDetected]), ("ccdd".into(), "Living".into(), vec![Presence, MeetingInProgress]), ]; let tree = build_bridge_tree(&nodes); assert_eq!(tree.nodes.len(), 2); // Each node's children are isolated to that branch. for branch in &tree.nodes { assert_eq!(branch.child_endpoints.len(), 2); } // Total endpoints: 1 root + (1 bridged + 2 children) × 2 = 7. assert_eq!(tree.total_endpoints(), 7); } #[test] fn empty_node_list_yields_just_root() { let tree = build_bridge_tree(&[]); assert_eq!(tree.nodes.len(), 0); assert_eq!(tree.total_endpoints(), 1); // just the root } }