matter_controller/controller.rs
1//! `MatterController` — the public entry point. A cheap, cloneable handle
2//! over the owning actor task (a crate-internal `tokio` task).
3
4use std::sync::Arc;
5
6use matter_commissioning::driver::AsyncDatagram;
7use matter_commissioning::{NocRng, SystemNocRng};
8use matter_transport::Discovery;
9use tokio::sync::{mpsc, oneshot};
10
11use crate::actor::{Actor, Command};
12use crate::builder::MatterControllerBuilder;
13use crate::error::Error;
14use crate::fabric::FabricConfig;
15use crate::node::Node;
16use crate::node_info::NodeInfo;
17use crate::snapshot;
18use crate::state::ControllerState;
19use crate::store::ControllerStore;
20use crate::trust::AttestationTrust;
21
22/// `BasicInformation` cluster id (Matter §11.1) — read post-commission for the
23/// device's `VendorID`/`ProductID`. Sourced from the generated cluster
24/// definitions so it stays tied to the codegen source of truth.
25const BASIC_INFORMATION_CLUSTER: u32 = matter_clusters::gen::basic_information::CLUSTER_ID;
26/// `BasicInformation.VendorID` attribute id.
27const BASIC_INFO_ATTR_VENDOR_ID: u32 =
28 matter_clusters::gen::basic_information::attribute_id::VENDOR_ID;
29/// `BasicInformation.ProductID` attribute id.
30const BASIC_INFO_ATTR_PRODUCT_ID: u32 =
31 matter_clusters::gen::basic_information::attribute_id::PRODUCT_ID;
32
33const COMMAND_CHANNEL_DEPTH: usize = 32;
34
35/// Addresses to advertise for a self-hosted operational service (OTA provider,
36/// ICD check-in listener). A wildcard bind (`[::]`) reports an unspecified
37/// `local_addr` that a peer cannot resolve to anything routable, so substitute
38/// the host's real routable address(es); fall back to the bind address only if
39/// none can be found (e.g. fully offline).
40fn advertise_addrs(local: std::net::SocketAddr) -> Vec<std::net::IpAddr> {
41 if local.ip().is_unspecified() {
42 let real = matter_transport::local_advertise_addrs();
43 if real.is_empty() {
44 vec![local.ip()]
45 } else {
46 real
47 }
48 } else {
49 vec![local.ip()]
50 }
51}
52
53/// The high-level Matter controller. Cloneable; all clones talk to one
54/// owning task.
55#[derive(Clone)]
56pub struct MatterController {
57 tx: mpsc::Sender<Command>,
58 /// Retained so the OTA provider server (`serve_provider_once`) can
59 /// load the stable, committed operational identity without routing through
60 /// the actor (the identity is minted once and never mutated after).
61 store: Arc<dyn ControllerStore>,
62}
63
64impl MatterController {
65 /// Begin configuring a controller (attestation trust, admin vendor id).
66 #[must_use]
67 pub fn builder(store: Arc<dyn ControllerStore>) -> MatterControllerBuilder {
68 MatterControllerBuilder::new(store)
69 }
70
71 /// Open a controller with default settings and **no** attestation trust —
72 /// sufficient for operating already-commissioned devices, but `commission`
73 /// will return [`Error::NoTrust`]. Use [`Self::builder`] to commission.
74 ///
75 /// # Errors
76 ///
77 /// As [`MatterControllerBuilder::build`].
78 pub async fn open(store: Arc<dyn ControllerStore>) -> Result<Self, Error> {
79 Self::spawn_default(
80 store,
81 None,
82 crate::builder::DEFAULT_ADMIN_VENDOR_ID,
83 None,
84 crate::actor::DEFAULT_RESPONSE_DEADLINE,
85 )
86 .await
87 }
88
89 pub(crate) async fn spawn_default(
90 store: Arc<dyn ControllerStore>,
91 trust: Option<AttestationTrust>,
92 admin_vendor_id: u16,
93 multicast_if: Option<u32>,
94 response_deadline: std::time::Duration,
95 ) -> Result<Self, Error> {
96 let transport =
97 matter_transport::TokioUdpTransport::bind_with_multicast_if(0, multicast_if)
98 .await
99 .map_err(|e| Error::Operational(format!("bind: {e}")))?;
100 let discovery = matter_transport::MdnsSdDiscovery::new()
101 .map_err(|e| Error::Operational(format!("mdns: {e}")))?;
102 Self::with_components_and_multicast_if(
103 store,
104 transport,
105 discovery,
106 Arc::new(SystemNocRng),
107 trust,
108 admin_vendor_id,
109 multicast_if,
110 response_deadline,
111 )
112 }
113
114 /// Construct over caller-supplied transport + discovery (used by tests to
115 /// inject `InMemoryDatagram` + a mock `Discovery`).
116 ///
117 /// # Errors
118 ///
119 /// [`Error::Store`] / [`Error::Snapshot`] if the persisted snapshot is
120 /// unreadable.
121 #[cfg(test)] // production construction goes through `with_components_and_multicast_if`.
122 pub(crate) fn with_components<T, D>(
123 store: Arc<dyn ControllerStore>,
124 transport: T,
125 discovery: D,
126 rng: Arc<dyn NocRng>,
127 trust: Option<AttestationTrust>,
128 admin_vendor_id: u16,
129 ) -> Result<Self, Error>
130 where
131 T: AsyncDatagram + Send + Sync + 'static,
132 D: Discovery + Send + 'static,
133 {
134 Self::with_components_and_multicast_if(
135 store,
136 transport,
137 discovery,
138 rng,
139 trust,
140 admin_vendor_id,
141 None,
142 crate::actor::DEFAULT_RESPONSE_DEADLINE,
143 )
144 }
145
146 #[allow(clippy::too_many_arguments)] // Component-injection seam; mirrors Actor::new.
147 pub(crate) fn with_components_and_multicast_if<T, D>(
148 store: Arc<dyn ControllerStore>,
149 transport: T,
150 discovery: D,
151 rng: Arc<dyn NocRng>,
152 trust: Option<AttestationTrust>,
153 admin_vendor_id: u16,
154 multicast_if: Option<u32>,
155 response_deadline: std::time::Duration,
156 ) -> Result<Self, Error>
157 where
158 // `Sync` because the spawned actor future holds `&self.transport`
159 // across awaits (inside `run_case`/`secured_round_trip`); `Send` so the
160 // future can be `tokio::spawn`ed onto the multi-thread runtime.
161 T: AsyncDatagram + Send + Sync + 'static,
162 D: Discovery + Send + 'static,
163 {
164 let state = match store.load()? {
165 Some(bytes) => snapshot::deserialize(&bytes)?,
166 None => ControllerState::default(),
167 };
168 let (tx, rx) = mpsc::channel(COMMAND_CHANNEL_DEPTH);
169 let actor = Actor::new(
170 transport,
171 discovery,
172 store.clone(),
173 rng,
174 state,
175 trust,
176 admin_vendor_id,
177 )
178 .with_multicast_if(multicast_if)
179 .with_response_deadline(response_deadline);
180 tokio::spawn(actor.run(rx));
181 Ok(Self { tx, store })
182 }
183
184 /// Serve the OTA **provider** role once: advertise our operational service,
185 /// accept one inbound CASE session, and dispatch up to `max_invokes`
186 /// server-side `InvokeRequest`s through `handler`, then withdraw the
187 /// advertisement. `handler` maps a parsed request to the encoded
188 /// `InvokeResponse` bytes (e.g. via `matter_interaction::build_invoke_response_*`).
189 ///
190 /// The server runs on its **own** freshly-bound UDP socket and its own mDNS
191 /// daemon — it does not touch the client actor (the long-running accept is
192 /// kept off the proven request/MRP loop). It authenticates as our persisted
193 /// operational identity (the M8 commissioner NOC/IPK/root).
194 ///
195 /// This ships the generic provider plumbing; the OTA `QueryImage` handler
196 /// and the BDX transfer build on it. Note: advertising a wildcard-bound
197 /// address may not be routable to a foreign requestor — see the runbook
198 /// for the interface-selection caveat (the automated validation is the
199 /// in-process loopback test).
200 ///
201 /// # Errors
202 ///
203 /// [`Error::NotCommissioned`] if no fabric exists; [`Error::Operational`] on
204 /// bind / mDNS / clock failure; otherwise any CASE-accept or dispatch error
205 /// from [`crate::provider_server::ProviderServer`].
206 #[cfg(feature = "unstable-provider")]
207 pub async fn serve_provider_once<H>(
208 &self,
209 port: u16,
210 handler: H,
211 max_invokes: usize,
212 ) -> Result<usize, Error>
213 where
214 H: FnMut(&matter_interaction::ParsedInvokeRequest) -> Vec<u8>,
215 {
216 use crate::provider_server::{build_operational_service, ProviderServer};
217
218 // 1. Load our persisted fabric + build the responder identity.
219 let state = match self.store.load()? {
220 Some(bytes) => snapshot::deserialize(&bytes)?,
221 None => return Err(Error::NotCommissioned("no fabric to serve from".into())),
222 };
223 let fabric = state
224 .fabrics
225 .first()
226 .ok_or_else(|| Error::NotCommissioned("no fabric to serve from".into()))?;
227 let (credentials, roots, compressed) = crate::credentials::operational_credentials(fabric)?;
228 let node_id = fabric.commissioner.node_id;
229 let now = crate::actor::current_matter_time()?;
230
231 // 2. Bind our own socket + advertise the operational service.
232 let socket = matter_transport::TokioUdpTransport::bind(port)
233 .await
234 .map_err(|e| Error::Operational(format!("provider bind: {e}")))?;
235 let local = socket
236 .socket()
237 .local_addr()
238 .map_err(|e| Error::Operational(format!("provider local_addr: {e}")))?;
239 let mut discovery = matter_transport::MdnsSdDiscovery::new()
240 .map_err(|e| Error::Operational(format!("provider mdns: {e}")))?;
241 let service =
242 build_operational_service(compressed, node_id, advertise_addrs(local), local.port());
243 matter_transport::Discovery::publish(&mut discovery, &service)?;
244
245 // 3. Accept one session + dispatch up to `max_invokes` invokes.
246 let result = ProviderServer::new(
247 socket,
248 vec![credentials],
249 roots,
250 /* base_session_id */ 0x01,
251 now,
252 )
253 .accept_and_dispatch_once(handler, max_invokes)
254 .await;
255
256 // 4. Withdraw the advertisement regardless of outcome.
257 let _ = matter_transport::Discovery::unpublish(
258 &mut discovery,
259 &service.instance_name,
260 matter_transport::ServiceKind::Operational,
261 );
262 result
263 }
264
265 /// Announce ourselves as an OTA provider to `target_node_id`, advertise our
266 /// operational service, and serve `image` over the full OTA flow (the
267 /// requestor resolves us, opens CASE, queries, BDX-downloads, applies, and
268 /// — possibly after rebooting into the new image — sends
269 /// `NotifyUpdateApplied`). Returns once `NotifyUpdateApplied` is received.
270 ///
271 /// `software_version` is offered in `QueryImageResponse` (must exceed the
272 /// requestor's current version for it to update — and match the version
273 /// baked into the `.ota` header for a live requestor). `port` binds the
274 /// provider socket (0 = ephemeral). The image is served verbatim over BDX
275 /// (unsigned; the requestor parses the `OTAImageHeader`).
276 ///
277 /// Because a real requestor reboots into the new image before notifying,
278 /// the call may block for an extended period. Callers should bound the wait
279 /// with [`tokio::time::timeout`]. Each accepted CASE session's resumption
280 /// record is persisted immediately via an internal sink (best-effort: a
281 /// failed store only costs a future fast path).
282 ///
283 /// # Errors
284 ///
285 /// [`Error::NotCommissioned`] if no fabric exists; [`Error::Operational`] on
286 /// bind / mDNS / clock failure; otherwise any announce or serve error.
287 #[cfg(feature = "ota")]
288 pub async fn serve_ota(
289 &self,
290 target_node_id: u64,
291 image: Vec<u8>,
292 software_version: u32,
293 port: u16,
294 ) -> Result<(), Error> {
295 // 960 keeps each BDX DataBlock (block + counter + BDX/IM framing) under
296 // the transport's 1024-byte secured-payload budget — correct for Wi-Fi
297 // and IP. For a Thread-routed requestor use
298 // [`Self::serve_ota_with_block_size`] with ~512: at 960 a single block
299 // spans ~a dozen 802.15.4 fragments that must ALL arrive, so a smaller
300 // block cuts the per-block loss probability on the mesh (BDX-4).
301 self.serve_ota_with_block_size(target_node_id, image, software_version, port, 960)
302 .await
303 }
304
305 /// [`Self::serve_ota`] with an explicit BDX `max_block_size`. Pass a smaller
306 /// value (~512) for a Thread-routed requestor so each block fits fewer
307 /// 6LoWPAN fragments; 960 is the Wi-Fi/IP default (see [`Self::serve_ota`]).
308 ///
309 /// # Errors
310 ///
311 /// Same as [`Self::serve_ota`].
312 #[cfg(feature = "ota")]
313 pub async fn serve_ota_with_block_size(
314 &self,
315 target_node_id: u64,
316 image: Vec<u8>,
317 software_version: u32,
318 port: u16,
319 max_block_size: u16,
320 ) -> Result<(), Error> {
321 use crate::provider_server::{build_operational_service, ProviderServer};
322
323 // Credential pool: one identity per CASE accept (first session +
324 // post-reboot session + retry slack — see the spec).
325 const PROVIDER_CREDENTIAL_POOL: usize = 4;
326
327 // Identity + offer.
328 let state = match self.store.load()? {
329 Some(bytes) => snapshot::deserialize(&bytes)?,
330 None => return Err(Error::NotCommissioned("no fabric to serve from".into())),
331 };
332 let fabric = state
333 .fabrics
334 .first()
335 .ok_or_else(|| Error::NotCommissioned("no fabric to serve from".into()))?;
336 let mut pool = Vec::with_capacity(PROVIDER_CREDENTIAL_POOL);
337
338 let mut roots_compressed = None;
339 for _ in 0..PROVIDER_CREDENTIAL_POOL {
340 let (c, r, comp) = crate::credentials::operational_credentials(fabric)?;
341 pool.push(c);
342 roots_compressed = Some((r, comp));
343 }
344 let (roots, compressed) =
345 roots_compressed.ok_or_else(|| Error::Operational("empty credential pool".into()))?;
346 let node_id = fabric.commissioner.node_id;
347 let now = crate::actor::current_matter_time()?;
348 let offer = matter_ota::ImageOffer {
349 software_version,
350 software_version_string: software_version.to_string(),
351 image_uri: format!("bdx://{node_id:016X}/fw.ota"),
352 update_token: vec![0xAB; 16],
353 };
354
355 // Bind + advertise.
356 let socket = matter_transport::TokioUdpTransport::bind(port)
357 .await
358 .map_err(|e| Error::Operational(format!("provider bind: {e}")))?;
359 let local = socket
360 .socket()
361 .local_addr()
362 .map_err(|e| Error::Operational(format!("provider local_addr: {e}")))?;
363 let mut discovery = matter_transport::MdnsSdDiscovery::new()
364 .map_err(|e| Error::Operational(format!("provider mdns: {e}")))?;
365 let service =
366 build_operational_service(compressed, node_id, advertise_addrs(local), local.port());
367 matter_transport::Discovery::publish(&mut discovery, &service)?;
368
369 // Announce FIRST (a client invoke to the device over a fresh CASE
370 // connect), and only then build the server: the requestor's QueryImage
371 // Sigma1 requests RESUMPTION of the session the announce just
372 // established, so the server must be seeded with the resumption
373 // record that connect persisted — which exists only after the
374 // announce completes. The provider socket is already bound and
375 // advertised above, so a Sigma1 arriving in the gap merely waits in
376 // the socket buffer (and chip MRP-retransmits it regardless).
377 let node = self.node(target_node_id);
378 let announce_res = node
379 .announce_ota_provider(node_id, crate::builder::DEFAULT_ADMIN_VENDOR_ID, 0)
380 .await;
381 if let Err(e) = announce_res {
382 let _ = matter_transport::Discovery::unpublish(
383 &mut discovery,
384 &service.instance_name,
385 matter_transport::ServiceKind::Operational,
386 );
387 return Err(e);
388 }
389
390 // Fetch the announce connect's resumption record from live actor
391 // state (guaranteed present: the announce rode that session). A
392 // missing/corrupt record only costs the fast path — the server then
393 // declines and falls back to a full handshake.
394 let records = match self.resumption_record_for(target_node_id).await {
395 Ok(Some(r)) => vec![r],
396 Ok(None) | Err(_) => Vec::new(),
397 };
398
399 let sink_controller = self.clone();
400 let server = ProviderServer::new(socket, pool, roots, /* base_session_id */ 0x01, now)
401 .with_resumption_records(records)
402 .with_expected_peer(target_node_id)
403 .with_record_sink(Box::new(move |record| {
404 let c = sink_controller.clone();
405 tokio::spawn(async move {
406 // Best-effort: a failed store only costs a future fast path.
407 let node = record.peer.node_id;
408 let _ = c.store_resumption_record(node, &record).await;
409 });
410 }));
411 // `max_block_size` must keep each BDX DataBlock (block + 4-byte counter
412 // + BDX/IM framing) under the transport's 1024-byte secured-payload
413 // budget — 960 is the Wi-Fi/IP default (1024 overflows by 14 bytes once
414 // framed); a Thread caller passes ~512 (BDX-4).
415 let serve_res = server.serve_ota_once(offer, image, max_block_size).await;
416
417 let _ = matter_transport::Discovery::unpublish(
418 &mut discovery,
419 &service.instance_name,
420 matter_transport::ServiceKind::Operational,
421 );
422
423 serve_res?;
424 Ok(())
425 }
426
427 /// Advertise our operational service and listen for ONE inbound Check-In
428 /// from a registered ICD, verify it against the stored registration key
429 /// (enforcing counter monotonicity), and return it — the caller then
430 /// re-establishes a session and reads/subscribes / `stay_active_request`s
431 /// while the device is briefly active.
432 ///
433 /// Runs on its **own** freshly-bound UDP socket + mDNS daemon, off the
434 /// client actor. Requires at least one registration from
435 /// [`Node::register_icd_client`](crate::Node::register_icd_client).
436 ///
437 /// # Errors
438 ///
439 /// [`Error::NotCommissioned`] if no fabric exists; [`Error::Operational`] if
440 /// no ICD clients are registered, on bind / mDNS failure, or if no
441 /// verifiable Check-In arrives before the internal frame budget is reached.
442 pub async fn listen_for_checkin_once(
443 &self,
444 port: u16,
445 ) -> Result<crate::icd_listener::CheckIn, Error> {
446 use crate::provider_server::build_operational_service;
447
448 // Load registrations + advertising identity from the persisted fabric.
449 let state = match self.store.load()? {
450 Some(bytes) => snapshot::deserialize(&bytes)?,
451 None => return Err(Error::NotCommissioned("no fabric to listen from".into())),
452 };
453 let fabric = state
454 .fabrics
455 .first()
456 .ok_or_else(|| Error::NotCommissioned("no fabric to listen from".into()))?;
457 let registrations = fabric.icd_clients.clone();
458 if registrations.is_empty() {
459 return Err(Error::Operational(
460 "no registered ICD clients to listen for".into(),
461 ));
462 }
463 let (_creds, _roots, compressed) = crate::credentials::operational_credentials(fabric)?;
464 let node_id = fabric.commissioner.node_id;
465
466 // Bind our own socket + advertise (so a registered ICD can resolve us).
467 let socket = matter_transport::TokioUdpTransport::bind(port)
468 .await
469 .map_err(|e| Error::Operational(format!("ICD listener bind: {e}")))?;
470 let local = socket
471 .socket()
472 .local_addr()
473 .map_err(|e| Error::Operational(format!("ICD listener local_addr: {e}")))?;
474 let mut discovery = matter_transport::MdnsSdDiscovery::new()
475 .map_err(|e| Error::Operational(format!("ICD listener mdns: {e}")))?;
476 let service =
477 build_operational_service(compressed, node_id, advertise_addrs(local), local.port());
478 matter_transport::Discovery::publish(&mut discovery, &service)?;
479
480 // Listen for one verifiable Check-In (generous frame budget for noise).
481 let result = crate::icd_listener::recv_checkin_once(&socket, ®istrations, 256).await;
482
483 let _ = matter_transport::Discovery::unpublish(
484 &mut discovery,
485 &service.instance_name,
486 matter_transport::ServiceKind::Operational,
487 );
488 result
489 }
490
491 /// Create and persist a new fabric (mints the stable commissioner
492 /// identity). Returns the new fabric id.
493 ///
494 /// Refuses to create a fabric whose `fabric_id` already exists on this
495 /// controller (issue #110) — call [`Self::fabrics`] first if you are not
496 /// sure whether one does. Call this only on a fresh store, or after
497 /// checking `fabrics()`; do not call it unconditionally on every startup.
498 ///
499 /// # Errors
500 ///
501 /// [`Error::ControllerStopped`] if the task has stopped;
502 /// [`Error::FabricAlreadyExists`] if `cfg.fabric_id` already exists;
503 /// [`Error::InvalidFabricValidity`] if `cfg.validity` names a window
504 /// devices cannot use — a `not_before` at the Matter epoch, a `not_before`
505 /// implausibly far ahead of this host's clock, an already-expired
506 /// `not_after`, or an inverted/empty window (see
507 /// [`FabricConfig::validity`], issue #111);
508 /// otherwise any minting / persistence error.
509 ///
510 /// This call does **not** require a set host clock: the certificates are
511 /// minted entirely from `cfg.validity`, and the clock is only used to
512 /// sanity-check that window, so on a host whose clock reads before the
513 /// Matter epoch (no RTC, before NTP) the clock-relative checks are skipped
514 /// with a warning and the fabric is still created. The clock-independent
515 /// checks still apply — notably a `not_before` of `MatterTime(0)`, which is
516 /// what deriving it from an unset `SystemTime::now()` produces. Operations
517 /// that genuinely need a real time — [`Self::commission`] (it mints the
518 /// device's NOC) and every operational CASE session — do fail with
519 /// [`Error::SystemClockUnset`] until the clock is set.
520 pub async fn create_fabric(&self, cfg: FabricConfig) -> Result<u64, Error> {
521 let (reply, rx) = oneshot::channel();
522 self.tx
523 .send(Command::CreateFabric { cfg, reply })
524 .await
525 .map_err(|_| Error::ControllerStopped)?;
526 rx.await.map_err(|_| Error::ControllerStopped)?
527 }
528
529 /// Commission a device from a QR (`MT:...`) or manual pairing code, bring it
530 /// onto the controller's fabric, and persist it. Returns a [`NodeInfo`] for
531 /// the commissioned device.
532 ///
533 /// After the device is on the fabric, a best-effort `BasicInformation` read
534 /// captures its `VendorID`/`ProductID` into the returned `NodeInfo` and
535 /// persists them on the device entry. That read is best-effort: if it fails,
536 /// commissioning still succeeds and `NodeInfo::vendor_id`/`product_id` are
537 /// left `None` (re-readable later via [`Self::nodes`]).
538 ///
539 /// `label` is an opaque, caller-supplied string (e.g. a friendly name like
540 /// `"kitchen plug"`) persisted on the device's entry atomically with the
541 /// rest of the commissioning result — a crash after this call returns
542 /// either sees the fully-commissioned device with its label, or nothing
543 /// at all, never a device missing its label. Pass `None` if you have no
544 /// label to attach yet; it can be left unset.
545 ///
546 /// # Errors
547 ///
548 /// [`Error::NoTrust`] if no attestation trust was configured,
549 /// [`Error::SetupCode`] if the code is invalid, [`Error::ControllerStopped`]
550 /// if the task stopped, or any driver/commissioning error.
551 pub async fn commission(
552 &self,
553 setup_code: &str,
554 label: Option<String>,
555 ) -> Result<NodeInfo, Error> {
556 let setup_payload = parse_setup_code(setup_code)?;
557 let (reply, rx) = oneshot::channel();
558 self.tx
559 .send(Command::Commission {
560 setup_payload,
561 label,
562 reply,
563 })
564 .await
565 .map_err(|_| Error::ControllerStopped)?;
566 let mut info = rx.await.map_err(|_| Error::ControllerStopped)??;
567 self.capture_basic_info(&mut info).await;
568 Ok(info)
569 }
570
571 /// Commission a Wi-Fi or Thread device over **BLE/BTP** (feature `ble`):
572 /// scan for the device by discriminator, open a BTP session, run PASE and
573 /// every pre-operational stage (attestation, NOC install, network
574 /// provisioning) over BTP, then complete the operational CASE session over
575 /// IP once the device joins the operational network. Brings the device
576 /// onto the controller's fabric, persists it, and returns a [`NodeInfo`]
577 /// (including a best-effort `BasicInformation` `VendorID`/`ProductID`
578 /// capture, exactly as [`Self::commission`]).
579 ///
580 /// `network` selects which provisioning sub-flow runs after `AddNOC`:
581 /// [`NetworkCredentials::WiFi`](matter_commissioning::NetworkCredentials::WiFi)
582 /// or
583 /// [`NetworkCredentials::Thread`](matter_commissioning::NetworkCredentials::Thread).
584 /// Some network credentials are **required** for a BLE-only device with no
585 /// operational connectivity yet — a BLE-only device with no network to
586 /// join is unprovisionable;
587 /// [`NetworkCredentials::AlreadyOnNetwork`](matter_commissioning::NetworkCredentials::AlreadyOnNetwork)
588 /// only makes sense for a device that already has operational connectivity
589 /// independent of BLE (e.g. Ethernet).
590 ///
591 /// **Requires macOS Bluetooth permission (TCC).** The first call
592 /// instantiates `CoreBluetooth` and may raise the one-time Bluetooth prompt,
593 /// attributed to the terminal application — see
594 /// `docs/runbooks/ble-commissioning.md`.
595 ///
596 /// `label` is the same opaque, caller-supplied string as
597 /// [`Self::commission`]'s — persisted on the device's entry atomically
598 /// with the rest of the commissioning result. Pass `None` if you have no
599 /// label to attach yet.
600 ///
601 /// # Errors
602 ///
603 /// [`Error::NoTrust`] if no attestation trust was configured,
604 /// [`Error::SetupCode`] if the code is invalid, [`Error::ControllerStopped`]
605 /// if the task stopped (including a btleplug-internal panic in the spawned
606 /// commission task), [`Error::Operational`] for a BLE-layer failure (no
607 /// adapter / denied permission, scan timeout, connect, GATT, or BTP
608 /// handshake), or any driver/commissioning error.
609 #[cfg(feature = "ble")]
610 pub async fn commission_ble(
611 &self,
612 setup_code: &str,
613 network: matter_commissioning::NetworkCredentials,
614 label: Option<String>,
615 ) -> Result<NodeInfo, Error> {
616 let setup_payload = parse_setup_code(setup_code)?;
617 let (reply, rx) = oneshot::channel();
618 self.tx
619 .send(Command::CommissionBle {
620 setup_payload,
621 network,
622 label,
623 reply,
624 })
625 .await
626 .map_err(|_| Error::ControllerStopped)?;
627 let mut info = rx.await.map_err(|_| Error::ControllerStopped)??;
628 self.capture_basic_info(&mut info).await;
629 Ok(info)
630 }
631
632 /// Best-effort: read `VendorID`/`ProductID` from the device's
633 /// `BasicInformation` cluster (endpoint 0) and persist them onto the node's
634 /// stored entry, filling `info.vendor_id`/`info.product_id`.
635 ///
636 /// Deliberately infallible from the caller's view: commissioning has
637 /// already succeeded and the device is on the fabric, so a flaky metadata
638 /// read (or a device that answers something unexpected) must never turn a
639 /// completed commission into an error. On any failure the ids stay `None`
640 /// and can be re-read later.
641 async fn capture_basic_info(&self, info: &mut NodeInfo) {
642 let node = self.node(info.node_id);
643 let paths = [
644 crate::ReadPath::concrete(0, BASIC_INFORMATION_CLUSTER, BASIC_INFO_ATTR_VENDOR_ID),
645 crate::ReadPath::concrete(0, BASIC_INFORMATION_CLUSTER, BASIC_INFO_ATTR_PRODUCT_ID),
646 ];
647 let Ok(reports) = node.read(&paths).await else {
648 return;
649 };
650 let mut vendor_id = None;
651 let mut product_id = None;
652 for (path, value) in &reports {
653 let crate::Value::Uint(n) = value else {
654 continue;
655 };
656 let Ok(n16) = u16::try_from(*n) else { continue };
657 if path.attribute == BASIC_INFO_ATTR_VENDOR_ID {
658 vendor_id = Some(n16);
659 } else if path.attribute == BASIC_INFO_ATTR_PRODUCT_ID {
660 product_id = Some(n16);
661 }
662 }
663 if vendor_id.is_none() && product_id.is_none() {
664 return;
665 }
666 info.vendor_id = vendor_id;
667 info.product_id = product_id;
668 // Persist best-effort — a store failure here only means a future
669 // `nodes()` re-reads `None`; it does not fail the commission.
670 let (reply, rx) = oneshot::channel();
671 if self
672 .tx
673 .send(Command::SetNodeVidPid {
674 node_id: info.node_id,
675 vendor_id,
676 product_id,
677 reply,
678 })
679 .await
680 .is_ok()
681 {
682 let _ = rx.await;
683 }
684 }
685
686 /// Enumerate every node this controller has commissioned, across all
687 /// fabrics, as typed [`NodeInfo`]. Replaces the need to deserialize the
688 /// on-disk snapshot to discover node ids and metadata.
689 ///
690 /// # Errors
691 ///
692 /// [`Error::ControllerStopped`] if the owning task has stopped.
693 pub async fn nodes(&self) -> Result<Vec<NodeInfo>, Error> {
694 let (reply, rx) = oneshot::channel();
695 self.tx
696 .send(Command::ListNodes { reply })
697 .await
698 .map_err(|_| Error::ControllerStopped)?;
699 rx.await.map_err(|_| Error::ControllerStopped)
700 }
701
702 /// Enumerate every fabric this controller has created, as typed
703 /// [`crate::FabricInfo`]. Check this before calling [`Self::create_fabric`] —
704 /// since issue #110, `create_fabric` refuses to create a second fabric
705 /// with a `fabric_id` that already exists here.
706 ///
707 /// This is **our own** fabric list, read from the controller's store with
708 /// no network traffic. For the fabrics a *device* is commissioned onto
709 /// (including other administrators' fabrics), read the device's own table
710 /// with [`Node::list_fabrics`](crate::Node::list_fabrics).
711 ///
712 /// # Errors
713 ///
714 /// [`Error::ControllerStopped`] if the owning task has stopped.
715 pub async fn fabrics(&self) -> Result<Vec<crate::FabricInfo>, Error> {
716 let (reply, rx) = oneshot::channel();
717 self.tx
718 .send(Command::ListFabrics { reply })
719 .await
720 .map_err(|_| Error::ControllerStopped)?;
721 rx.await.map_err(|_| Error::ControllerStopped)
722 }
723
724 /// Forget a node: drop ALL of the controller's own state for it — the
725 /// persisted device record, any cached CASE session, and its resumption
726 /// data — WITHOUT contacting the device. Use this to reclaim a node that is
727 /// unreachable or already factory-reset (where `remove_fabric` cannot run).
728 ///
729 /// Returns `true` if a node was found and removed, `false` if no such node
730 /// was commissioned. This does NOT remove the controller's fabric from the
731 /// device; a still-live device keeps its NOC until it is reset or its fabric
732 /// removed via `Node::remove_fabric`.
733 ///
734 /// # Errors
735 ///
736 /// [`Error::ControllerStopped`] if the task stopped, or a store error while
737 /// persisting the removal.
738 pub async fn forget_node(&self, node_id: u64) -> Result<bool, Error> {
739 let (reply, rx) = oneshot::channel();
740 self.tx
741 .send(Command::ForgetNode { node_id, reply })
742 .await
743 .map_err(|_| Error::ControllerStopped)?;
744 rx.await.map_err(|_| Error::ControllerStopped)?
745 }
746
747 /// Handle addressing a device by node id (single-fabric).
748 #[must_use]
749 pub fn node(&self, node_id: u64) -> Node {
750 Node {
751 tx: self.tx.clone(),
752 node_id,
753 }
754 }
755
756 /// Create a group key set on the controller's fabric: mints a fresh 16-byte
757 /// epoch key from the CSPRNG, persists a `GroupKeySetConfig` under
758 /// `key_set_id`, and returns the [`GroupKeySet`](crate::GroupKeySet) so the caller can program
759 /// it onto each member device via
760 /// [`Node::write_group_key_set`](crate::Node::write_group_key_set) and map a
761 /// group to it. The key set is stored durably before this returns, so the
762 /// controller can encrypt outbound group messages for it immediately
763 /// (see [`Self::invoke_group`]).
764 ///
765 /// `epoch_start_time` is the Matter-epoch start time recorded in the
766 /// returned `GroupKeySet` (the device-side `KeySetWrite` echoes it).
767 ///
768 /// # Errors
769 ///
770 /// [`Error::NotCommissioned`] if no single fabric exists,
771 /// [`Error::ControllerStopped`] if the task has stopped, or any
772 /// CSPRNG / persistence error.
773 pub async fn create_group(
774 &self,
775 key_set_id: u16,
776 epoch_start_time: u64,
777 ) -> Result<crate::GroupKeySet, Error> {
778 let (reply, rx) = oneshot::channel();
779 self.tx
780 .send(Command::CreateGroup {
781 key_set_id,
782 epoch_start_time,
783 reply,
784 })
785 .await
786 .map_err(|_| Error::ControllerStopped)?;
787 rx.await.map_err(|_| Error::ControllerStopped)?
788 }
789
790 /// Fire-and-forget multicast group invoke: send `path`/`fields` to every
791 /// device in `group_id`, encrypted with the operational group key derived
792 /// from the persisted `key_set_id`. Returns as soon as the datagram is sent
793 /// — group commands are unacknowledged, so there is no response.
794 ///
795 /// The caller supplies `key_set_id` (the key set the group was bound to when
796 /// it was created): the controller's persisted `group_keys` are keyed by
797 /// key set id, avoiding a separate group→key-set map. The outbound group
798 /// message counter is bumped and persisted **before** the send so a counter
799 /// is never reused across a crash.
800 ///
801 /// Real multicast delivery requires the host network to route the Matter
802 /// site-local group address; on a host without it the send still succeeds at
803 /// the socket layer (the bytes are correct — see the loopback test).
804 ///
805 /// # Errors
806 ///
807 /// [`Error::GroupNotProvisioned`] if `key_set_id` has no persisted key set,
808 /// [`Error::NotCommissioned`] if no single fabric exists,
809 /// [`Error::Operational`] on counter exhaustion or send failure,
810 /// [`Error::ControllerStopped`] if the task has stopped, or any
811 /// crypto / persistence error.
812 pub async fn invoke_group(
813 &self,
814 group_id: u16,
815 key_set_id: u16,
816 path: crate::CommandPath,
817 fields: crate::Value,
818 ) -> Result<(), Error> {
819 let fields_tlv = crate::node::value_to_tlv(&fields)?;
820 let (reply, rx) = oneshot::channel();
821 self.tx
822 .send(Command::InvokeGroup {
823 group_id,
824 key_set_id,
825 path,
826 fields_tlv,
827 reply,
828 })
829 .await
830 .map_err(|_| Error::ControllerStopped)?;
831 rx.await.map_err(|_| Error::ControllerStopped)?
832 }
833
834 #[cfg(test)]
835 pub(crate) async fn session_count(&self) -> usize {
836 let (reply, rx) = oneshot::channel();
837 if self.tx.send(Command::SessionCount { reply }).await.is_err() {
838 return 0;
839 }
840 rx.await.unwrap_or(0)
841 }
842
843 /// Fetch the stored CASE resumption record for `node_id` from the actor's
844 /// live state (deserialized; `None` if the device has none). Used by
845 /// `serve_ota` to let the provider server accept the requestor's
846 /// resumption attempt.
847 ///
848 /// # Errors
849 ///
850 /// [`Error::ControllerStopped`] if the owning task stopped,
851 /// [`Error::NotCommissioned`] if no sole fabric exists, or a
852 /// [`Error::Snapshot`]/[`Error::Codec`]/[`Error::Cert`] deserialization
853 /// failure for a corrupt stored record.
854 pub(crate) async fn resumption_record_for(
855 &self,
856 node_id: u64,
857 ) -> Result<Option<matter_crypto::ResumptionRecord>, Error> {
858 let (reply, rx) = oneshot::channel();
859 self.tx
860 .send(Command::ResumptionRecordFor { node_id, reply })
861 .await
862 .map_err(|_| Error::ControllerStopped)?;
863 let bytes = rx.await.map_err(|_| Error::ControllerStopped)??;
864 match bytes {
865 Some(b) => Ok(Some(crate::resumption::deserialize_record(&b)?)),
866 None => Ok(None),
867 }
868 }
869
870 /// Store `record` as the CASE resumption record for `node_id` (replacing
871 /// any prior one; best-effort persist). Invoked by `serve_ota`'s
872 /// provider server's `record_sink`, once per completed CASE accept.
873 ///
874 /// # Errors
875 ///
876 /// [`Error::ControllerStopped`] if the owning task stopped,
877 /// [`Error::NotCommissioned`] if no sole fabric exists, or
878 /// [`Error::Operational`] if the device has no entry on the fabric.
879 pub(crate) async fn store_resumption_record(
880 &self,
881 node_id: u64,
882 record: &matter_crypto::ResumptionRecord,
883 ) -> Result<(), Error> {
884 let record_bytes = crate::resumption::serialize_record(record)?;
885 let (reply, rx) = oneshot::channel();
886 self.tx
887 .send(Command::StoreResumptionRecord {
888 node_id,
889 record_bytes,
890 reply,
891 })
892 .await
893 .map_err(|_| Error::ControllerStopped)?;
894 rx.await.map_err(|_| Error::ControllerStopped)?
895 }
896}
897
898/// Parse a QR (`MT:...`) or manual pairing code into a [`matter_commissioning::SetupPayload`].
899///
900/// QR codes are identified by the `MT:` prefix (Matter Core Spec §5.1.3.1).
901/// Anything else is treated as a manual pairing code.
902///
903/// # Errors
904///
905/// Returns [`Error::SetupCode`] if the string is not a valid QR or manual code.
906fn parse_setup_code(code: &str) -> Result<matter_commissioning::SetupPayload, Error> {
907 let trimmed = code.trim();
908 let parsed = if trimmed.starts_with("MT:") {
909 matter_commissioning::parse_qr(trimmed)
910 } else {
911 matter_commissioning::parse_manual_code(trimmed)
912 };
913 parsed.map_err(|e| Error::SetupCode(format!("{e:?}")))
914}