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(
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, &registrations, 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}