feat(adr-115): P8 — Matter bridge tree + commissioning code (38 tests, lib total 410)
Ships the SDK-independent half of the Matter Bridge production work:
## `matter::bridge` — endpoint tree assembly
`build_bridge_tree(nodes) -> BridgeTree` walks a list of `(node_id,
friendly_name, [EntityKind])` tuples and produces the Matter endpoint
graph the SDK will materialise:
EP 0 (BridgedDevicesAggregator)
EP 1 (BridgedNode for "Bedroom")
EP 2 (OccupancySensor for Presence + PersonCount vendor attr)
EP 3 (OccupancySensor for SomeoneSleeping)
EP 4 (GenericSwitch for FallDetected)
EP 5 (BridgedNode for "Living") …
Key invariants enforced by tests:
- `PersonCount` collapses onto Presence's endpoint as a vendor
attribute, never gets its own endpoint
- Biometric entities (HR/BR/pose) are skipped entirely — they
never appear in the tree
- Every child endpoint carries `BasicInformation` cluster
- Endpoint IDs are monotonic + unique (verified by sort+dedup test)
- Empty node list yields just the root aggregator
- Multi-node bridges keep per-node endpoint isolation
- `endpoint(id)` lookup resolves every assigned ID
## `matter::commissioning` — setup-code generation
`SetupCodeInput::dev(passcode, discriminator) -> ManualPairingCode`
produces the 11-digit human-readable Matter pairing code that users
scan/enter into Apple Home / Google Home / HA Matter integration.
Validates against Matter Core Spec §5.1.6.1 disallowed-values list
(11111111, 12345678, 87654321, all-same-digit patterns, 0). Rejects
oversized passcode (≥2^27) and discriminator (≥2^12).
The Verhoeff check digit is computed per spec §5.1.4.1.5 — full
D/P/INV tables transcribed. The check digit appended to the body is
self-consistent (verified by a recompute-and-compare test).
`ManualPairingCode::display_4_3_4()` returns the dashed form
(`1234-567-8901`) controllers actually display.
Bit-packing is a placeholder for v0.7.0 — the chunk values are
hashed-then-mod into their decimal widths so the output is
deterministic + input-sensitive + Verhoeff-valid, but not yet
bit-perfect spec-compliant. The fully spec-compliant code (with QR
base-38 payload) lands at P8b when `rs-matter` is integrated; see
ADR-115 §9.10. This module gives the SDK layer a stable testable
contract to build against.
## Tests
- 16 cluster mapping (existing)
- 11 bridge assembly (new): aggregator root, branch-per-node,
PersonCount collapsing, HR/BR skip, BasicInformation cluster on
every endpoint, monotonic+unique IDs, total endpoint count, lookup,
multi-node isolation, empty-node list
- 11 commissioning (new): dev VID/PID defaults, disallowed-passcode
rejection (12 spec values), oversized-passcode rejection,
oversized-discriminator rejection, canonical test vectors accepted,
11-digit code always, 4-3-4 display format, determinism, sensitivity
to passcode change, sensitivity to discriminator change, Verhoeff
self-consistency, invalid-input early return
Total lib tests: **410 passed**, 0 failed, 1 properly ignored.
Refs #776, PR #778.
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
a7467f5470
commit
6364e0f7d8
|
|
@ -0,0 +1,325 @@
|
|||
//! 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:
|
||||
//!
|
||||
//! 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<u32>,
|
||||
pub vendor_attrs: Vec<u32>,
|
||||
/// `Some(_)` if this endpoint maps back to an `EntityKind`;
|
||||
/// `None` for structural endpoints (aggregator root, bridged node).
|
||||
pub source_entity: Option<EntityKind>,
|
||||
}
|
||||
|
||||
/// 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<Endpoint>,
|
||||
}
|
||||
|
||||
/// Whole bridge tree the SDK will materialise.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BridgeTree {
|
||||
pub root: Endpoint,
|
||||
pub nodes: Vec<NodeBranch>,
|
||||
}
|
||||
|
||||
/// 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<EntityKind>)]) -> 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<u16> = 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<EndpointRef<'_>> {
|
||||
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<EntityKind>)> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
//! Matter commissioning code generation (ADR-115 §3.11.2).
|
||||
//!
|
||||
//! When `--matter` is enabled, the publisher prints a setup code on
|
||||
//! first start that the user scans/enters into their Matter controller
|
||||
//! (Apple Home / Google Home / HA Matter integration). This module
|
||||
//! generates that code without depending on any Matter SDK.
|
||||
//!
|
||||
//! ## Spec
|
||||
//!
|
||||
//! Matter Core Spec 1.3 §5.1 defines two pairing-code formats:
|
||||
//!
|
||||
//! - **Manual pairing code** — 11 digits, base-10 encoded from packed
|
||||
//! bits. This is what we emit for `--matter-setup-file`.
|
||||
//! - **QR code payload** — `MT:` prefix + base-38 of a longer
|
||||
//! bit-packed payload. v0.7.0 emits the manual code only; QR string
|
||||
//! generation is a v0.7.1 follow-up (per §9.9 dev-VID note —
|
||||
//! commissioning works in either form with dev VID).
|
||||
//!
|
||||
//! ## Bit layout (manual code, §5.1.4.1)
|
||||
//!
|
||||
//! ```text
|
||||
//! bits width meaning
|
||||
//! ---- ------- -------------------------------------------------------
|
||||
//! 0 1 Version (always 0 today)
|
||||
//! 1 1 VID/PID present flag (0 = short code, 1 = with VID/PID)
|
||||
//! 2 10 Discriminator (12-bit overall, low 4 bits go elsewhere)
|
||||
//! 12 27 Passcode (27-bit setup PIN, range 0..2^27)
|
||||
//! 39 4 Discriminator (high 4 bits)
|
||||
//! 43 9 Reserved / VID-PID stitched in v0 = 0
|
||||
//! ```
|
||||
//!
|
||||
//! The bit-packed payload is then base-10 encoded and prefixed with
|
||||
//! the Luhn-style check digit.
|
||||
|
||||
use super::super::matter::clusters::VENDOR_ATTR_PERSON_COUNT as _; // re-export-only guard
|
||||
|
||||
/// Inputs to setup-code generation. `passcode` and `discriminator`
|
||||
/// are usually random at first start and persisted in the
|
||||
/// `--matter-setup-file` so the same code re-prints next boot.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SetupCodeInput {
|
||||
/// 27-bit Matter setup PIN. Must be in the range `0..2^27`
|
||||
/// excluding the disallowed values listed in §5.1.6.1 (00000000,
|
||||
/// 11111111, 22222222, …, 99999999, 12345678, 87654321).
|
||||
pub passcode: u32,
|
||||
/// 12-bit discriminator advertised in mDNS so controllers find the
|
||||
/// device. Must be in `0..4096`.
|
||||
pub discriminator: u16,
|
||||
/// CSA-assigned vendor ID. Today we use dev VID `0xFFF1` per
|
||||
/// ADR-115 §9.9 until P10 cert decision.
|
||||
pub vendor_id: u16,
|
||||
/// Vendor-assigned product ID. Default `0x8001` per the same ADR row.
|
||||
pub product_id: u16,
|
||||
}
|
||||
|
||||
impl SetupCodeInput {
|
||||
/// Build with the production-default dev VID + sensible product ID.
|
||||
/// `passcode` and `discriminator` come from a CSPRNG at first start.
|
||||
pub fn dev(passcode: u32, discriminator: u16) -> Self {
|
||||
Self { passcode, discriminator, vendor_id: 0xFFF1, product_id: 0x8001 }
|
||||
}
|
||||
|
||||
/// Validate against §5.1.6.1 disallowed values + bit-width ranges.
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if self.passcode == 0
|
||||
|| self.passcode == 11111111
|
||||
|| self.passcode == 22222222
|
||||
|| self.passcode == 33333333
|
||||
|| self.passcode == 44444444
|
||||
|| self.passcode == 55555555
|
||||
|| self.passcode == 66666666
|
||||
|| self.passcode == 77777777
|
||||
|| self.passcode == 88888888
|
||||
|| self.passcode == 99999999
|
||||
|| self.passcode == 12345678
|
||||
|| self.passcode == 87654321
|
||||
{
|
||||
return Err("passcode is in the §5.1.6.1 disallowed-values list");
|
||||
}
|
||||
if self.passcode >= 1 << 27 {
|
||||
return Err("passcode exceeds 27-bit range");
|
||||
}
|
||||
if self.discriminator >= 1 << 12 {
|
||||
return Err("discriminator exceeds 12-bit range");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The 11-digit manual pairing code as a fixed-length string. Always
|
||||
/// 11 digits because the Matter spec specifies fixed-width encoding.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ManualPairingCode(pub String);
|
||||
|
||||
impl ManualPairingCode {
|
||||
/// Build the 11-digit short code (§5.1.4.1, VID/PID-absent variant).
|
||||
/// Returns the code as a `String` so the caller can `Display`-print
|
||||
/// it directly. Validates the input first.
|
||||
pub fn from_input(input: &SetupCodeInput) -> Result<Self, &'static str> {
|
||||
input.validate()?;
|
||||
|
||||
// §5.1.4.1 — 10-digit short code = 1-digit header (encodes
|
||||
// version + VID/PID flag + discriminator high 2 bits) +
|
||||
// 5-digit middle (low passcode + low discriminator bits) +
|
||||
// 4-digit trailer (high passcode bits). Plus 1-digit Verhoeff
|
||||
// check digit = 11 total.
|
||||
//
|
||||
// The numeric chunks are sized to fit their decimal widths
|
||||
// exactly (max value < 10^width), so the format! macro
|
||||
// produces fixed-width output without truncation.
|
||||
//
|
||||
// This is a placeholder implementation: it produces a
|
||||
// deterministic, validated, 11-digit string suitable for
|
||||
// human display + Verhoeff-check round-trip. The bit-perfect
|
||||
// spec-compliant code (with QR base-38 payload) is generated
|
||||
// by the Matter SDK at P8 once `rs-matter` lands.
|
||||
let disc = input.discriminator as u32;
|
||||
let pin = input.passcode;
|
||||
|
||||
// Bit layout (placeholder — see header comment):
|
||||
// header = disc_high_2_bits → 1 digit (0..3)
|
||||
// chunk1 = (disc_low_10 << 14) | pin_low_14 → 24 bits, take mod 10^5
|
||||
// chunk2 = pin_high_13 → 13 bits, take mod 10^4
|
||||
//
|
||||
// The mod-by-10^width step is what differs from a fully
|
||||
// spec-conformant encoder — but it preserves determinism and
|
||||
// input sensitivity, which is what we need until P8 SDK.
|
||||
let header = ((disc >> 10) & 0x3) as u64;
|
||||
let chunk1_raw = ((pin & 0x3FFF) as u64) | (((disc & 0x3FF) as u64) << 14);
|
||||
let chunk1 = chunk1_raw % 100_000;
|
||||
let chunk2_raw = ((pin >> 14) & 0x1FFF) as u64;
|
||||
let chunk2 = chunk2_raw % 10_000;
|
||||
|
||||
let body = format!("{:01}{:05}{:04}", header, chunk1, chunk2);
|
||||
debug_assert_eq!(body.len(), 10, "body must be 10 digits — fix chunk widths");
|
||||
|
||||
let check = verhoeff_check_digit(&body);
|
||||
Ok(Self(format!("{}{}", body, check)))
|
||||
}
|
||||
|
||||
/// 4-3-4 dash format the way Matter controllers actually display
|
||||
/// it (e.g. `1234-567-8901`). Used for human readability in
|
||||
/// `--matter-setup-file` and console logs.
|
||||
pub fn display_4_3_4(&self) -> String {
|
||||
let s = &self.0;
|
||||
format!("{}-{}-{}", &s[0..4], &s[4..7], &s[7..11])
|
||||
}
|
||||
}
|
||||
|
||||
/// Verhoeff check-digit algorithm per Matter Core §5.1.4.1.5 (the
|
||||
/// spec doesn't mandate Verhoeff specifically, but several controllers
|
||||
/// expect the published reference impl behaviour. We follow §5.1.4.1
|
||||
/// "decimal check digit using Verhoeff scheme".)
|
||||
fn verhoeff_check_digit(s: &str) -> u8 {
|
||||
const D: [[u8; 10]; 10] = [
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
[1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
|
||||
[2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
|
||||
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
|
||||
[4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
|
||||
[5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
|
||||
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
|
||||
[7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
|
||||
[8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
|
||||
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
|
||||
];
|
||||
const P: [[u8; 10]; 8] = [
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
[1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
|
||||
[5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
|
||||
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
|
||||
[9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
|
||||
[4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
|
||||
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
|
||||
[7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
|
||||
];
|
||||
const INV: [u8; 10] = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9];
|
||||
|
||||
let mut c = 0u8;
|
||||
for (i, ch) in s.chars().rev().enumerate() {
|
||||
let n = ch.to_digit(10).expect("non-digit in code body") as u8;
|
||||
c = D[c as usize][P[(i + 1) % 8][n as usize] as usize];
|
||||
}
|
||||
INV[c as usize]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dev_constructor_uses_dev_vid_pid() {
|
||||
let s = SetupCodeInput::dev(20202021, 3840);
|
||||
assert_eq!(s.vendor_id, 0xFFF1);
|
||||
assert_eq!(s.product_id, 0x8001);
|
||||
assert_eq!(s.passcode, 20202021);
|
||||
assert_eq!(s.discriminator, 3840);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_disallowed_passcodes() {
|
||||
for &bad in &[
|
||||
0u32, 11111111, 22222222, 33333333, 44444444, 55555555,
|
||||
66666666, 77777777, 88888888, 99999999, 12345678, 87654321,
|
||||
] {
|
||||
let s = SetupCodeInput::dev(bad, 100);
|
||||
assert!(s.validate().is_err(), "passcode {} must be rejected", bad);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_oversized_passcode() {
|
||||
let s = SetupCodeInput::dev(1 << 27, 100);
|
||||
assert!(s.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_oversized_discriminator() {
|
||||
let s = SetupCodeInput::dev(20202021, 4096);
|
||||
assert!(s.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_canonical_test_vectors() {
|
||||
// Common test values seen across Matter test suites.
|
||||
for (pin, disc) in &[(20202021u32, 3840u16), (12345678 + 1, 100), (1, 0)] {
|
||||
let s = SetupCodeInput::dev(*pin, *disc);
|
||||
assert!(s.validate().is_ok(), "({}, {}) should validate", pin, disc);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_code_is_11_digits() {
|
||||
let s = SetupCodeInput::dev(20202021, 3840);
|
||||
let code = ManualPairingCode::from_input(&s).unwrap();
|
||||
assert_eq!(code.0.len(), 11);
|
||||
assert!(code.0.chars().all(|c| c.is_ascii_digit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_code_display_format_is_4_3_4() {
|
||||
let s = SetupCodeInput::dev(20202021, 3840);
|
||||
let code = ManualPairingCode::from_input(&s).unwrap();
|
||||
let pretty = code.display_4_3_4();
|
||||
// 4-3-4 + 2 dashes = 13 chars.
|
||||
assert_eq!(pretty.len(), 13);
|
||||
assert_eq!(&pretty[4..5], "-");
|
||||
assert_eq!(&pretty[8..9], "-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_code_is_deterministic_for_same_input() {
|
||||
let s = SetupCodeInput::dev(20202021, 3840);
|
||||
let a = ManualPairingCode::from_input(&s).unwrap();
|
||||
let b = ManualPairingCode::from_input(&s).unwrap();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_code_differs_when_passcode_changes() {
|
||||
let a = ManualPairingCode::from_input(&SetupCodeInput::dev(20202021, 3840))
|
||||
.unwrap();
|
||||
let b = ManualPairingCode::from_input(&SetupCodeInput::dev(20202022, 3840))
|
||||
.unwrap();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_code_differs_when_discriminator_changes() {
|
||||
let a = ManualPairingCode::from_input(&SetupCodeInput::dev(20202021, 3840))
|
||||
.unwrap();
|
||||
let b = ManualPairingCode::from_input(&SetupCodeInput::dev(20202021, 100))
|
||||
.unwrap();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verhoeff_check_digit_is_self_consistent() {
|
||||
// The Verhoeff scheme has the property that appending the
|
||||
// check digit to the body produces a string with check-digit-
|
||||
// appended == 0. Verify the recursive property holds.
|
||||
let s = SetupCodeInput::dev(20202021, 3840);
|
||||
let code = ManualPairingCode::from_input(&s).unwrap();
|
||||
// Re-verify: the check digit appended to the body should make
|
||||
// the Verhoeff sum collapse to 0.
|
||||
let body = &code.0[0..10];
|
||||
let check_recomputed = verhoeff_check_digit(body);
|
||||
let body_digit = code.0[10..11].parse::<u8>().unwrap();
|
||||
assert_eq!(check_recomputed, body_digit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_input_rejects_invalid_input() {
|
||||
// Build with a disallowed passcode; from_input must return Err.
|
||||
let s = SetupCodeInput::dev(11111111, 3840);
|
||||
assert!(ManualPairingCode::from_input(&s).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -29,8 +29,12 @@
|
|||
//! would expand `EntityKind::matter_mapping` to cover them. Today HR /
|
||||
//! BR have no Matter cluster and stay MQTT-only.
|
||||
|
||||
mod bridge;
|
||||
mod clusters;
|
||||
mod commissioning;
|
||||
|
||||
pub use bridge::{build_bridge_tree, BridgeTree, Endpoint, EndpointRef, NodeBranch};
|
||||
pub use clusters::{
|
||||
matter_mapping, ClusterId, EndpointTypeId, MatterClusterMapping,
|
||||
};
|
||||
pub use commissioning::{ManualPairingCode, SetupCodeInput};
|
||||
|
|
|
|||
Loading…
Reference in New Issue