Skip to main content

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, &registrations, 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    /// Refuses to create a fabric whose `fabric_id` already exists on this
483    /// controller (issue #110) — call [`Self::fabrics`] first if you are not
484    /// sure whether one does. Call this only on a fresh store, or after
485    /// checking `fabrics()`; do not call it unconditionally on every startup.
486    ///
487    /// # Errors
488    ///
489    /// [`Error::ControllerStopped`] if the task has stopped;
490    /// [`Error::FabricAlreadyExists`] if `cfg.fabric_id` already exists;
491    /// [`Error::InvalidFabricValidity`] if `cfg.validity` names a window
492    /// devices cannot use — a `not_before` at the Matter epoch, a `not_before`
493    /// implausibly far ahead of this host's clock, an already-expired
494    /// `not_after`, or an inverted/empty window (see
495    /// [`FabricConfig::validity`], issue #111);
496    /// otherwise any minting / persistence error.
497    ///
498    /// This call does **not** require a set host clock: the certificates are
499    /// minted entirely from `cfg.validity`, and the clock is only used to
500    /// sanity-check that window, so on a host whose clock reads before the
501    /// Matter epoch (no RTC, before NTP) the clock-relative checks are skipped
502    /// with a warning and the fabric is still created. The clock-independent
503    /// checks still apply — notably a `not_before` of `MatterTime(0)`, which is
504    /// what deriving it from an unset `SystemTime::now()` produces. Operations
505    /// that genuinely need a real time — [`Self::commission`] (it mints the
506    /// device's NOC) and every operational CASE session — do fail with
507    /// [`Error::SystemClockUnset`] until the clock is set.
508    pub async fn create_fabric(&self, cfg: FabricConfig) -> Result<u64, Error> {
509        let (reply, rx) = oneshot::channel();
510        self.tx
511            .send(Command::CreateFabric { cfg, reply })
512            .await
513            .map_err(|_| Error::ControllerStopped)?;
514        rx.await.map_err(|_| Error::ControllerStopped)?
515    }
516
517    /// Commission a device from a QR (`MT:...`) or manual pairing code, bring it
518    /// onto the controller's fabric, and persist it. Returns a [`NodeInfo`] for
519    /// the commissioned device.
520    ///
521    /// After the device is on the fabric, a best-effort `BasicInformation` read
522    /// captures its `VendorID`/`ProductID` into the returned `NodeInfo` and
523    /// persists them on the device entry. That read is best-effort: if it fails,
524    /// commissioning still succeeds and `NodeInfo::vendor_id`/`product_id` are
525    /// left `None` (re-readable later via [`Self::nodes`]).
526    ///
527    /// `label` is an opaque, caller-supplied string (e.g. a friendly name like
528    /// `"kitchen plug"`) persisted on the device's entry atomically with the
529    /// rest of the commissioning result — a crash after this call returns
530    /// either sees the fully-commissioned device with its label, or nothing
531    /// at all, never a device missing its label. Pass `None` if you have no
532    /// label to attach yet; it can be left unset.
533    ///
534    /// # Errors
535    ///
536    /// [`Error::NoTrust`] if no attestation trust was configured,
537    /// [`Error::SetupCode`] if the code is invalid, [`Error::ControllerStopped`]
538    /// if the task stopped, or any driver/commissioning error.
539    pub async fn commission(
540        &self,
541        setup_code: &str,
542        label: Option<String>,
543    ) -> Result<NodeInfo, Error> {
544        let setup_payload = parse_setup_code(setup_code)?;
545        let (reply, rx) = oneshot::channel();
546        self.tx
547            .send(Command::Commission {
548                setup_payload,
549                label,
550                reply,
551            })
552            .await
553            .map_err(|_| Error::ControllerStopped)?;
554        let mut info = rx.await.map_err(|_| Error::ControllerStopped)??;
555        self.capture_basic_info(&mut info).await;
556        Ok(info)
557    }
558
559    /// Commission a Wi-Fi or Thread device over **BLE/BTP** (feature `ble`):
560    /// scan for the device by discriminator, open a BTP session, run PASE and
561    /// every pre-operational stage (attestation, NOC install, network
562    /// provisioning) over BTP, then complete the operational CASE session over
563    /// IP once the device joins the operational network. Brings the device
564    /// onto the controller's fabric, persists it, and returns a [`NodeInfo`]
565    /// (including a best-effort `BasicInformation` `VendorID`/`ProductID`
566    /// capture, exactly as [`Self::commission`]).
567    ///
568    /// `network` selects which provisioning sub-flow runs after `AddNOC`:
569    /// [`NetworkCredentials::WiFi`](matter_commissioning::NetworkCredentials::WiFi)
570    /// or
571    /// [`NetworkCredentials::Thread`](matter_commissioning::NetworkCredentials::Thread).
572    /// Some network credentials are **required** for a BLE-only device with no
573    /// operational connectivity yet — a BLE-only device with no network to
574    /// join is unprovisionable;
575    /// [`NetworkCredentials::AlreadyOnNetwork`](matter_commissioning::NetworkCredentials::AlreadyOnNetwork)
576    /// only makes sense for a device that already has operational connectivity
577    /// independent of BLE (e.g. Ethernet).
578    ///
579    /// **Requires macOS Bluetooth permission (TCC).** The first call
580    /// instantiates `CoreBluetooth` and may raise the one-time Bluetooth prompt,
581    /// attributed to the terminal application — see
582    /// `docs/runbooks/ble-commissioning.md`.
583    ///
584    /// `label` is the same opaque, caller-supplied string as
585    /// [`Self::commission`]'s — persisted on the device's entry atomically
586    /// with the rest of the commissioning result. Pass `None` if you have no
587    /// label to attach yet.
588    ///
589    /// # Errors
590    ///
591    /// [`Error::NoTrust`] if no attestation trust was configured,
592    /// [`Error::SetupCode`] if the code is invalid, [`Error::ControllerStopped`]
593    /// if the task stopped (including a btleplug-internal panic in the spawned
594    /// commission task), [`Error::Operational`] for a BLE-layer failure (no
595    /// adapter / denied permission, scan timeout, connect, GATT, or BTP
596    /// handshake), or any driver/commissioning error.
597    #[cfg(feature = "ble")]
598    pub async fn commission_ble(
599        &self,
600        setup_code: &str,
601        network: matter_commissioning::NetworkCredentials,
602        label: Option<String>,
603    ) -> Result<NodeInfo, Error> {
604        let setup_payload = parse_setup_code(setup_code)?;
605        let (reply, rx) = oneshot::channel();
606        self.tx
607            .send(Command::CommissionBle {
608                setup_payload,
609                network,
610                label,
611                reply,
612            })
613            .await
614            .map_err(|_| Error::ControllerStopped)?;
615        let mut info = rx.await.map_err(|_| Error::ControllerStopped)??;
616        self.capture_basic_info(&mut info).await;
617        Ok(info)
618    }
619
620    /// Best-effort: read `VendorID`/`ProductID` from the device's
621    /// `BasicInformation` cluster (endpoint 0) and persist them onto the node's
622    /// stored entry, filling `info.vendor_id`/`info.product_id`.
623    ///
624    /// Deliberately infallible from the caller's view: commissioning has
625    /// already succeeded and the device is on the fabric, so a flaky metadata
626    /// read (or a device that answers something unexpected) must never turn a
627    /// completed commission into an error. On any failure the ids stay `None`
628    /// and can be re-read later.
629    async fn capture_basic_info(&self, info: &mut NodeInfo) {
630        let node = self.node(info.node_id);
631        let paths = [
632            crate::ReadPath::concrete(0, BASIC_INFORMATION_CLUSTER, BASIC_INFO_ATTR_VENDOR_ID),
633            crate::ReadPath::concrete(0, BASIC_INFORMATION_CLUSTER, BASIC_INFO_ATTR_PRODUCT_ID),
634        ];
635        let Ok(reports) = node.read(&paths).await else {
636            return;
637        };
638        let mut vendor_id = None;
639        let mut product_id = None;
640        for (path, value) in &reports {
641            let crate::Value::Uint(n) = value else {
642                continue;
643            };
644            let Ok(n16) = u16::try_from(*n) else { continue };
645            if path.attribute == BASIC_INFO_ATTR_VENDOR_ID {
646                vendor_id = Some(n16);
647            } else if path.attribute == BASIC_INFO_ATTR_PRODUCT_ID {
648                product_id = Some(n16);
649            }
650        }
651        if vendor_id.is_none() && product_id.is_none() {
652            return;
653        }
654        info.vendor_id = vendor_id;
655        info.product_id = product_id;
656        // Persist best-effort — a store failure here only means a future
657        // `nodes()` re-reads `None`; it does not fail the commission.
658        let (reply, rx) = oneshot::channel();
659        if self
660            .tx
661            .send(Command::SetNodeVidPid {
662                node_id: info.node_id,
663                vendor_id,
664                product_id,
665                reply,
666            })
667            .await
668            .is_ok()
669        {
670            let _ = rx.await;
671        }
672    }
673
674    /// Enumerate every node this controller has commissioned, across all
675    /// fabrics, as typed [`NodeInfo`]. Replaces the need to deserialize the
676    /// on-disk snapshot to discover node ids and metadata.
677    ///
678    /// # Errors
679    ///
680    /// [`Error::ControllerStopped`] if the owning task has stopped.
681    pub async fn nodes(&self) -> Result<Vec<NodeInfo>, Error> {
682        let (reply, rx) = oneshot::channel();
683        self.tx
684            .send(Command::ListNodes { reply })
685            .await
686            .map_err(|_| Error::ControllerStopped)?;
687        rx.await.map_err(|_| Error::ControllerStopped)
688    }
689
690    /// Enumerate every fabric this controller has created, as typed
691    /// [`crate::FabricInfo`]. Check this before calling [`Self::create_fabric`] —
692    /// since issue #110, `create_fabric` refuses to create a second fabric
693    /// with a `fabric_id` that already exists here.
694    ///
695    /// This is **our own** fabric list, read from the controller's store with
696    /// no network traffic. For the fabrics a *device* is commissioned onto
697    /// (including other administrators' fabrics), read the device's own table
698    /// with [`Node::list_fabrics`](crate::Node::list_fabrics).
699    ///
700    /// # Errors
701    ///
702    /// [`Error::ControllerStopped`] if the owning task has stopped.
703    pub async fn fabrics(&self) -> Result<Vec<crate::FabricInfo>, Error> {
704        let (reply, rx) = oneshot::channel();
705        self.tx
706            .send(Command::ListFabrics { reply })
707            .await
708            .map_err(|_| Error::ControllerStopped)?;
709        rx.await.map_err(|_| Error::ControllerStopped)
710    }
711
712    /// Forget a node: drop ALL of the controller's own state for it — the
713    /// persisted device record, any cached CASE session, and its resumption
714    /// data — WITHOUT contacting the device. Use this to reclaim a node that is
715    /// unreachable or already factory-reset (where `remove_fabric` cannot run).
716    ///
717    /// Returns `true` if a node was found and removed, `false` if no such node
718    /// was commissioned. This does NOT remove the controller's fabric from the
719    /// device; a still-live device keeps its NOC until it is reset or its fabric
720    /// removed via `Node::remove_fabric`.
721    ///
722    /// # Errors
723    ///
724    /// [`Error::ControllerStopped`] if the task stopped, or a store error while
725    /// persisting the removal.
726    pub async fn forget_node(&self, node_id: u64) -> Result<bool, Error> {
727        let (reply, rx) = oneshot::channel();
728        self.tx
729            .send(Command::ForgetNode { node_id, reply })
730            .await
731            .map_err(|_| Error::ControllerStopped)?;
732        rx.await.map_err(|_| Error::ControllerStopped)?
733    }
734
735    /// Handle addressing a device by node id (single-fabric).
736    #[must_use]
737    pub fn node(&self, node_id: u64) -> Node {
738        Node {
739            tx: self.tx.clone(),
740            node_id,
741        }
742    }
743
744    /// Create a group key set on the controller's fabric: mints a fresh 16-byte
745    /// epoch key from the CSPRNG, persists a `GroupKeySetConfig` under
746    /// `key_set_id`, and returns the [`GroupKeySet`](crate::GroupKeySet) so the caller can program
747    /// it onto each member device via
748    /// [`Node::write_group_key_set`](crate::Node::write_group_key_set) and map a
749    /// group to it. The key set is stored durably before this returns, so the
750    /// controller can encrypt outbound group messages for it immediately
751    /// (see [`Self::invoke_group`]).
752    ///
753    /// `epoch_start_time` is the Matter-epoch start time recorded in the
754    /// returned `GroupKeySet` (the device-side `KeySetWrite` echoes it).
755    ///
756    /// # Errors
757    ///
758    /// [`Error::NotCommissioned`] if no single fabric exists,
759    /// [`Error::ControllerStopped`] if the task has stopped, or any
760    /// CSPRNG / persistence error.
761    pub async fn create_group(
762        &self,
763        key_set_id: u16,
764        epoch_start_time: u64,
765    ) -> Result<crate::GroupKeySet, Error> {
766        let (reply, rx) = oneshot::channel();
767        self.tx
768            .send(Command::CreateGroup {
769                key_set_id,
770                epoch_start_time,
771                reply,
772            })
773            .await
774            .map_err(|_| Error::ControllerStopped)?;
775        rx.await.map_err(|_| Error::ControllerStopped)?
776    }
777
778    /// Fire-and-forget multicast group invoke: send `path`/`fields` to every
779    /// device in `group_id`, encrypted with the operational group key derived
780    /// from the persisted `key_set_id`. Returns as soon as the datagram is sent
781    /// — group commands are unacknowledged, so there is no response.
782    ///
783    /// The caller supplies `key_set_id` (the key set the group was bound to when
784    /// it was created): the controller's persisted `group_keys` are keyed by
785    /// key set id, avoiding a separate group→key-set map. The outbound group
786    /// message counter is bumped and persisted **before** the send so a counter
787    /// is never reused across a crash.
788    ///
789    /// Real multicast delivery requires the host network to route the Matter
790    /// site-local group address; on a host without it the send still succeeds at
791    /// the socket layer (the bytes are correct — see the loopback test).
792    ///
793    /// # Errors
794    ///
795    /// [`Error::GroupNotProvisioned`] if `key_set_id` has no persisted key set,
796    /// [`Error::NotCommissioned`] if no single fabric exists,
797    /// [`Error::Operational`] on counter exhaustion or send failure,
798    /// [`Error::ControllerStopped`] if the task has stopped, or any
799    /// crypto / persistence error.
800    pub async fn invoke_group(
801        &self,
802        group_id: u16,
803        key_set_id: u16,
804        path: crate::CommandPath,
805        fields: crate::Value,
806    ) -> Result<(), Error> {
807        let fields_tlv = crate::node::value_to_tlv(&fields)?;
808        let (reply, rx) = oneshot::channel();
809        self.tx
810            .send(Command::InvokeGroup {
811                group_id,
812                key_set_id,
813                path,
814                fields_tlv,
815                reply,
816            })
817            .await
818            .map_err(|_| Error::ControllerStopped)?;
819        rx.await.map_err(|_| Error::ControllerStopped)?
820    }
821
822    #[cfg(test)]
823    pub(crate) async fn session_count(&self) -> usize {
824        let (reply, rx) = oneshot::channel();
825        if self.tx.send(Command::SessionCount { reply }).await.is_err() {
826            return 0;
827        }
828        rx.await.unwrap_or(0)
829    }
830
831    /// Fetch the stored CASE resumption record for `node_id` from the actor's
832    /// live state (deserialized; `None` if the device has none). Used by
833    /// `serve_ota` to let the provider server accept the requestor's
834    /// resumption attempt.
835    ///
836    /// # Errors
837    ///
838    /// [`Error::ControllerStopped`] if the owning task stopped,
839    /// [`Error::NotCommissioned`] if no sole fabric exists, or a
840    /// [`Error::Snapshot`]/[`Error::Codec`]/[`Error::Cert`] deserialization
841    /// failure for a corrupt stored record.
842    pub(crate) async fn resumption_record_for(
843        &self,
844        node_id: u64,
845    ) -> Result<Option<matter_crypto::ResumptionRecord>, Error> {
846        let (reply, rx) = oneshot::channel();
847        self.tx
848            .send(Command::ResumptionRecordFor { node_id, reply })
849            .await
850            .map_err(|_| Error::ControllerStopped)?;
851        let bytes = rx.await.map_err(|_| Error::ControllerStopped)??;
852        match bytes {
853            Some(b) => Ok(Some(crate::resumption::deserialize_record(&b)?)),
854            None => Ok(None),
855        }
856    }
857
858    /// Store `record` as the CASE resumption record for `node_id` (replacing
859    /// any prior one; best-effort persist). Invoked by `serve_ota`'s
860    /// provider server's `record_sink`, once per completed CASE accept.
861    ///
862    /// # Errors
863    ///
864    /// [`Error::ControllerStopped`] if the owning task stopped,
865    /// [`Error::NotCommissioned`] if no sole fabric exists, or
866    /// [`Error::Operational`] if the device has no entry on the fabric.
867    pub(crate) async fn store_resumption_record(
868        &self,
869        node_id: u64,
870        record: &matter_crypto::ResumptionRecord,
871    ) -> Result<(), Error> {
872        let record_bytes = crate::resumption::serialize_record(record)?;
873        let (reply, rx) = oneshot::channel();
874        self.tx
875            .send(Command::StoreResumptionRecord {
876                node_id,
877                record_bytes,
878                reply,
879            })
880            .await
881            .map_err(|_| Error::ControllerStopped)?;
882        rx.await.map_err(|_| Error::ControllerStopped)?
883    }
884}
885
886/// Parse a QR (`MT:...`) or manual pairing code into a [`matter_commissioning::SetupPayload`].
887///
888/// QR codes are identified by the `MT:` prefix (Matter Core Spec §5.1.3.1).
889/// Anything else is treated as a manual pairing code.
890///
891/// # Errors
892///
893/// Returns [`Error::SetupCode`] if the string is not a valid QR or manual code.
894fn parse_setup_code(code: &str) -> Result<matter_commissioning::SetupPayload, Error> {
895    let trimmed = code.trim();
896    let parsed = if trimmed.starts_with("MT:") {
897        matter_commissioning::parse_qr(trimmed)
898    } else {
899        matter_commissioning::parse_manual_code(trimmed)
900    };
901    parsed.map_err(|e| Error::SetupCode(format!("{e:?}")))
902}