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