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::snapshot;
17use crate::state::ControllerState;
18use crate::store::ControllerStore;
19use crate::trust::AttestationTrust;
20
21const COMMAND_CHANNEL_DEPTH: usize = 32;
22
23/// Addresses to advertise for a self-hosted operational service (OTA provider,
24/// ICD check-in listener). A wildcard bind (`[::]`) reports an unspecified
25/// `local_addr` that a peer cannot resolve to anything routable, so substitute
26/// the host's real routable address(es); fall back to the bind address only if
27/// none can be found (e.g. fully offline).
28fn advertise_addrs(local: std::net::SocketAddr) -> Vec<std::net::IpAddr> {
29    if local.ip().is_unspecified() {
30        let real = matter_transport::local_advertise_addrs();
31        if real.is_empty() {
32            vec![local.ip()]
33        } else {
34            real
35        }
36    } else {
37        vec![local.ip()]
38    }
39}
40
41/// The high-level Matter controller. Cloneable; all clones talk to one
42/// owning task.
43#[derive(Clone)]
44pub struct MatterController {
45    tx: mpsc::Sender<Command>,
46    /// Retained so the OTA provider server ([`Self::serve_provider_once`]) can
47    /// load the stable, committed operational identity without routing through
48    /// the actor (the identity is minted once and never mutated after).
49    store: Arc<dyn ControllerStore>,
50}
51
52impl MatterController {
53    /// Begin configuring a controller (attestation trust, admin vendor id).
54    #[must_use]
55    pub fn builder(store: Arc<dyn ControllerStore>) -> MatterControllerBuilder {
56        MatterControllerBuilder::new(store)
57    }
58
59    /// Open a controller with default settings and **no** attestation trust —
60    /// sufficient for operating already-commissioned devices, but `commission`
61    /// will return [`Error::NoTrust`]. Use [`Self::builder`] to commission.
62    ///
63    /// # Errors
64    ///
65    /// As [`MatterControllerBuilder::build`].
66    pub async fn open(store: Arc<dyn ControllerStore>) -> Result<Self, Error> {
67        Self::spawn_default(store, None, crate::builder::DEFAULT_ADMIN_VENDOR_ID, None).await
68    }
69
70    pub(crate) async fn spawn_default(
71        store: Arc<dyn ControllerStore>,
72        trust: Option<AttestationTrust>,
73        admin_vendor_id: u16,
74        multicast_if: Option<u32>,
75    ) -> Result<Self, Error> {
76        let transport =
77            matter_transport::TokioUdpTransport::bind_with_multicast_if(0, multicast_if)
78                .await
79                .map_err(|e| Error::Operational(format!("bind: {e}")))?;
80        let discovery = matter_transport::MdnsSdDiscovery::new()
81            .map_err(|e| Error::Operational(format!("mdns: {e}")))?;
82        Self::with_components_and_multicast_if(
83            store,
84            transport,
85            discovery,
86            Arc::new(SystemNocRng),
87            trust,
88            admin_vendor_id,
89            multicast_if,
90        )
91    }
92
93    /// Construct over caller-supplied transport + discovery (used by tests to
94    /// inject `InMemoryDatagram` + a mock `Discovery`).
95    ///
96    /// # Errors
97    ///
98    /// [`Error::Store`] / [`Error::Snapshot`] if the persisted snapshot is
99    /// unreadable.
100    #[cfg(test)] // production construction goes through `with_components_and_multicast_if`.
101    pub(crate) fn with_components<T, D>(
102        store: Arc<dyn ControllerStore>,
103        transport: T,
104        discovery: D,
105        rng: Arc<dyn NocRng>,
106        trust: Option<AttestationTrust>,
107        admin_vendor_id: u16,
108    ) -> Result<Self, Error>
109    where
110        T: AsyncDatagram + Send + Sync + 'static,
111        D: Discovery + Send + 'static,
112    {
113        Self::with_components_and_multicast_if(
114            store,
115            transport,
116            discovery,
117            rng,
118            trust,
119            admin_vendor_id,
120            None,
121        )
122    }
123
124    #[allow(clippy::too_many_arguments)] // Component-injection seam; mirrors Actor::new.
125    pub(crate) fn with_components_and_multicast_if<T, D>(
126        store: Arc<dyn ControllerStore>,
127        transport: T,
128        discovery: D,
129        rng: Arc<dyn NocRng>,
130        trust: Option<AttestationTrust>,
131        admin_vendor_id: u16,
132        multicast_if: Option<u32>,
133    ) -> Result<Self, Error>
134    where
135        // `Sync` because the spawned actor future holds `&self.transport`
136        // across awaits (inside `run_case`/`secured_round_trip`); `Send` so the
137        // future can be `tokio::spawn`ed onto the multi-thread runtime.
138        T: AsyncDatagram + Send + Sync + 'static,
139        D: Discovery + Send + 'static,
140    {
141        let state = match store.load()? {
142            Some(bytes) => snapshot::deserialize(&bytes)?,
143            None => ControllerState::default(),
144        };
145        let (tx, rx) = mpsc::channel(COMMAND_CHANNEL_DEPTH);
146        let actor = Actor::new(
147            transport,
148            discovery,
149            store.clone(),
150            rng,
151            state,
152            trust,
153            admin_vendor_id,
154        )
155        .with_multicast_if(multicast_if);
156        tokio::spawn(actor.run(rx));
157        Ok(Self { tx, store })
158    }
159
160    /// Serve the OTA **provider** role once: advertise our operational service,
161    /// accept one inbound CASE session, and dispatch up to `max_invokes`
162    /// server-side `InvokeRequest`s through `handler`, then withdraw the
163    /// advertisement. `handler` maps a parsed request to the encoded
164    /// `InvokeResponse` bytes (e.g. via `matter_interaction::build_invoke_response_*`).
165    ///
166    /// The server runs on its **own** freshly-bound UDP socket and its own mDNS
167    /// daemon — it does not touch the client actor (the long-running accept is
168    /// kept off the proven request/MRP loop). It authenticates as our persisted
169    /// operational identity (the M8 commissioner NOC/IPK/root).
170    ///
171    /// F3 ships the generic plumbing; F4 supplies the OTA `QueryImage` handler
172    /// and the BDX transfer. Note: advertising a wildcard-bound address may not
173    /// be routable to a foreign requestor — see the F3 runbook for the
174    /// interface-selection caveat (the automated validation is the in-process
175    /// loopback test).
176    ///
177    /// # Errors
178    ///
179    /// [`Error::NotCommissioned`] if no fabric exists; [`Error::Operational`] on
180    /// bind / mDNS / clock failure; otherwise any CASE-accept or dispatch error
181    /// from [`crate::provider_server::ProviderServer`].
182    pub async fn serve_provider_once<H>(
183        &self,
184        port: u16,
185        handler: H,
186        max_invokes: usize,
187    ) -> Result<usize, Error>
188    where
189        H: FnMut(&matter_interaction::ParsedInvokeRequest) -> Vec<u8>,
190    {
191        use crate::provider_server::{build_operational_service, ProviderServer};
192
193        // 1. Load our persisted fabric + build the responder identity.
194        let state = match self.store.load()? {
195            Some(bytes) => snapshot::deserialize(&bytes)?,
196            None => return Err(Error::NotCommissioned("no fabric to serve from".into())),
197        };
198        let fabric = state
199            .fabrics
200            .first()
201            .ok_or_else(|| Error::NotCommissioned("no fabric to serve from".into()))?;
202        let (credentials, roots, compressed) = crate::credentials::operational_credentials(fabric)?;
203        let node_id = fabric.commissioner.node_id;
204        let now = crate::actor::current_matter_time()?;
205
206        // 2. Bind our own socket + advertise the operational service.
207        let socket = matter_transport::TokioUdpTransport::bind(port)
208            .await
209            .map_err(|e| Error::Operational(format!("provider bind: {e}")))?;
210        let local = socket
211            .socket()
212            .local_addr()
213            .map_err(|e| Error::Operational(format!("provider local_addr: {e}")))?;
214        let mut discovery = matter_transport::MdnsSdDiscovery::new()
215            .map_err(|e| Error::Operational(format!("provider mdns: {e}")))?;
216        let service =
217            build_operational_service(compressed, node_id, advertise_addrs(local), local.port());
218        matter_transport::Discovery::publish(&mut discovery, &service)?;
219
220        // 3. Accept one session + dispatch up to `max_invokes` invokes.
221        let result = ProviderServer::new(
222            socket,
223            vec![credentials],
224            roots,
225            /* base_session_id */ 0x01,
226            now,
227        )
228        .accept_and_dispatch_once(handler, max_invokes)
229        .await;
230
231        // 4. Withdraw the advertisement regardless of outcome.
232        let _ = matter_transport::Discovery::unpublish(
233            &mut discovery,
234            &service.instance_name,
235            matter_transport::ServiceKind::Operational,
236        );
237        result
238    }
239
240    /// Announce ourselves as an OTA provider to `target_node_id`, advertise our
241    /// operational service, and serve `image` over the full OTA flow (the
242    /// requestor resolves us, opens CASE, queries, BDX-downloads, applies, and
243    /// — possibly after rebooting into the new image — sends
244    /// `NotifyUpdateApplied`). Returns once `NotifyUpdateApplied` is received.
245    ///
246    /// `software_version` is offered in `QueryImageResponse` (must exceed the
247    /// requestor's current version for it to update — and match the version
248    /// baked into the `.ota` header for a live requestor). `port` binds the
249    /// provider socket (0 = ephemeral). The image is served verbatim over BDX
250    /// (unsigned; the requestor parses the `OTAImageHeader`).
251    ///
252    /// Because a real requestor reboots into the new image before notifying,
253    /// the call may block for an extended period. Callers should bound the wait
254    /// with [`tokio::time::timeout`]. Each accepted CASE session's resumption
255    /// record is persisted immediately via an internal sink (best-effort: a
256    /// failed store only costs a future fast path).
257    ///
258    /// # Errors
259    ///
260    /// [`Error::NotCommissioned`] if no fabric exists; [`Error::Operational`] on
261    /// bind / mDNS / clock failure; otherwise any announce or serve error.
262    pub async fn serve_ota(
263        &self,
264        target_node_id: u64,
265        image: Vec<u8>,
266        software_version: u32,
267        port: u16,
268    ) -> Result<(), Error> {
269        use crate::provider_server::{build_operational_service, ProviderServer};
270
271        // Credential pool: one identity per CASE accept (first session +
272        // post-reboot session + retry slack — see the spec).
273        const PROVIDER_CREDENTIAL_POOL: usize = 4;
274
275        // Identity + offer.
276        let state = match self.store.load()? {
277            Some(bytes) => snapshot::deserialize(&bytes)?,
278            None => return Err(Error::NotCommissioned("no fabric to serve from".into())),
279        };
280        let fabric = state
281            .fabrics
282            .first()
283            .ok_or_else(|| Error::NotCommissioned("no fabric to serve from".into()))?;
284        let mut pool = Vec::with_capacity(PROVIDER_CREDENTIAL_POOL);
285
286        let mut roots_compressed = None;
287        for _ in 0..PROVIDER_CREDENTIAL_POOL {
288            let (c, r, comp) = crate::credentials::operational_credentials(fabric)?;
289            pool.push(c);
290            roots_compressed = Some((r, comp));
291        }
292        let (roots, compressed) =
293            roots_compressed.ok_or_else(|| Error::Operational("empty credential pool".into()))?;
294        let node_id = fabric.commissioner.node_id;
295        let now = crate::actor::current_matter_time()?;
296        let offer = matter_ota::ImageOffer {
297            software_version,
298            software_version_string: software_version.to_string(),
299            image_uri: format!("bdx://{node_id:016X}/fw.ota"),
300            update_token: vec![0xAB; 16],
301        };
302
303        // Bind + advertise.
304        let socket = matter_transport::TokioUdpTransport::bind(port)
305            .await
306            .map_err(|e| Error::Operational(format!("provider bind: {e}")))?;
307        let local = socket
308            .socket()
309            .local_addr()
310            .map_err(|e| Error::Operational(format!("provider local_addr: {e}")))?;
311        let mut discovery = matter_transport::MdnsSdDiscovery::new()
312            .map_err(|e| Error::Operational(format!("provider mdns: {e}")))?;
313        let service =
314            build_operational_service(compressed, node_id, advertise_addrs(local), local.port());
315        matter_transport::Discovery::publish(&mut discovery, &service)?;
316
317        // Announce FIRST (a client invoke to the device over a fresh CASE
318        // connect), and only then build the server: the requestor's QueryImage
319        // Sigma1 requests RESUMPTION of the session the announce just
320        // established, so the server must be seeded with the resumption
321        // record that connect persisted — which exists only after the
322        // announce completes. The provider socket is already bound and
323        // advertised above, so a Sigma1 arriving in the gap merely waits in
324        // the socket buffer (and chip MRP-retransmits it regardless).
325        let node = self.node(target_node_id);
326        let announce_res = node
327            .announce_ota_provider(node_id, crate::builder::DEFAULT_ADMIN_VENDOR_ID, 0)
328            .await;
329        if let Err(e) = announce_res {
330            let _ = matter_transport::Discovery::unpublish(
331                &mut discovery,
332                &service.instance_name,
333                matter_transport::ServiceKind::Operational,
334            );
335            return Err(e);
336        }
337
338        // Fetch the announce connect's resumption record from live actor
339        // state (guaranteed present: the announce rode that session). A
340        // missing/corrupt record only costs the fast path — the server then
341        // declines and falls back to a full handshake.
342        let records = match self.resumption_record_for(target_node_id).await {
343            Ok(Some(r)) => vec![r],
344            Ok(None) | Err(_) => Vec::new(),
345        };
346
347        let sink_controller = self.clone();
348        let server = ProviderServer::new(socket, pool, roots, /* base_session_id */ 0x01, now)
349            .with_resumption_records(records)
350            .with_expected_peer(target_node_id)
351            .with_record_sink(Box::new(move |record| {
352                let c = sink_controller.clone();
353                tokio::spawn(async move {
354                    // Best-effort: a failed store only costs a future fast path.
355                    let node = record.peer.node_id;
356                    let _ = c.store_resumption_record(node, &record).await;
357                });
358            }));
359        // 960 keeps each BDX DataBlock (block + 4-byte counter + BDX/IM
360        // framing) under the transport's 1024-byte secured-payload budget —
361        // 1024 here overflows it by 14 bytes once framed.
362        let serve_res = server
363            .serve_ota_once(offer, image, /* max_block_size */ 960)
364            .await;
365
366        let _ = matter_transport::Discovery::unpublish(
367            &mut discovery,
368            &service.instance_name,
369            matter_transport::ServiceKind::Operational,
370        );
371
372        serve_res?;
373        Ok(())
374    }
375
376    /// Advertise our operational service and listen for ONE inbound Check-In
377    /// from a registered ICD, verify it against the stored registration key
378    /// (enforcing counter monotonicity), and return it — the caller then
379    /// re-establishes a session and reads/subscribes / `stay_active_request`s
380    /// while the device is briefly active.
381    ///
382    /// Runs on its **own** freshly-bound UDP socket + mDNS daemon (the F3
383    /// pattern), off the client actor. Requires at least one registration from
384    /// [`Node::register_icd_client`](crate::Node::register_icd_client).
385    ///
386    /// # Errors
387    ///
388    /// [`Error::NotCommissioned`] if no fabric exists; [`Error::Operational`] if
389    /// no ICD clients are registered, on bind / mDNS failure, or if no
390    /// verifiable Check-In arrives before the internal frame budget is reached.
391    pub async fn listen_for_checkin_once(
392        &self,
393        port: u16,
394    ) -> Result<crate::icd_listener::CheckIn, Error> {
395        use crate::provider_server::build_operational_service;
396
397        // Load registrations + advertising identity from the persisted fabric.
398        let state = match self.store.load()? {
399            Some(bytes) => snapshot::deserialize(&bytes)?,
400            None => return Err(Error::NotCommissioned("no fabric to listen from".into())),
401        };
402        let fabric = state
403            .fabrics
404            .first()
405            .ok_or_else(|| Error::NotCommissioned("no fabric to listen from".into()))?;
406        let registrations = fabric.icd_clients.clone();
407        if registrations.is_empty() {
408            return Err(Error::Operational(
409                "no registered ICD clients to listen for".into(),
410            ));
411        }
412        let (_creds, _roots, compressed) = crate::credentials::operational_credentials(fabric)?;
413        let node_id = fabric.commissioner.node_id;
414
415        // Bind our own socket + advertise (so a registered ICD can resolve us).
416        let socket = matter_transport::TokioUdpTransport::bind(port)
417            .await
418            .map_err(|e| Error::Operational(format!("ICD listener bind: {e}")))?;
419        let local = socket
420            .socket()
421            .local_addr()
422            .map_err(|e| Error::Operational(format!("ICD listener local_addr: {e}")))?;
423        let mut discovery = matter_transport::MdnsSdDiscovery::new()
424            .map_err(|e| Error::Operational(format!("ICD listener mdns: {e}")))?;
425        let service =
426            build_operational_service(compressed, node_id, advertise_addrs(local), local.port());
427        matter_transport::Discovery::publish(&mut discovery, &service)?;
428
429        // Listen for one verifiable Check-In (generous frame budget for noise).
430        let result = crate::icd_listener::recv_checkin_once(&socket, &registrations, 256).await;
431
432        let _ = matter_transport::Discovery::unpublish(
433            &mut discovery,
434            &service.instance_name,
435            matter_transport::ServiceKind::Operational,
436        );
437        result
438    }
439
440    /// Create and persist a new fabric (mints the stable commissioner
441    /// identity). Returns the new fabric id.
442    ///
443    /// # Errors
444    ///
445    /// [`Error::ControllerStopped`] if the task has stopped; otherwise any
446    /// minting / persistence error.
447    pub async fn create_fabric(&self, cfg: FabricConfig) -> Result<u64, Error> {
448        let (reply, rx) = oneshot::channel();
449        self.tx
450            .send(Command::CreateFabric { cfg, reply })
451            .await
452            .map_err(|_| Error::ControllerStopped)?;
453        rx.await.map_err(|_| Error::ControllerStopped)?
454    }
455
456    /// Commission a device from a QR (`MT:...`) or manual pairing code, bring it
457    /// onto the controller's fabric, and persist it. Returns the device's node id.
458    ///
459    /// # Errors
460    ///
461    /// [`Error::NoTrust`] if no attestation trust was configured,
462    /// [`Error::SetupCode`] if the code is invalid, [`Error::ControllerStopped`]
463    /// if the task stopped, or any driver/commissioning error.
464    pub async fn commission(&self, setup_code: &str) -> Result<u64, Error> {
465        let setup_payload = parse_setup_code(setup_code)?;
466        let (reply, rx) = oneshot::channel();
467        self.tx
468            .send(Command::Commission {
469                setup_payload,
470                reply,
471            })
472            .await
473            .map_err(|_| Error::ControllerStopped)?;
474        rx.await.map_err(|_| Error::ControllerStopped)?
475    }
476
477    /// Commission a Wi-Fi or Thread device over **BLE/BTP** (feature `ble`):
478    /// scan for the device by discriminator, open a BTP session, run PASE and
479    /// every pre-operational stage (attestation, NOC install, network
480    /// provisioning) over BTP, then complete the operational CASE session over
481    /// IP once the device joins the operational network. Brings the device
482    /// onto the controller's fabric, persists it, and returns its node id.
483    ///
484    /// `network` selects which provisioning sub-flow runs after `AddNOC`:
485    /// [`NetworkCredentials::WiFi`](matter_commissioning::NetworkCredentials::WiFi)
486    /// or
487    /// [`NetworkCredentials::Thread`](matter_commissioning::NetworkCredentials::Thread).
488    /// Some network credentials are **required** for a BLE-only device with no
489    /// operational connectivity yet — a BLE-only device with no network to
490    /// join is unprovisionable (design D7);
491    /// [`NetworkCredentials::AlreadyOnNetwork`](matter_commissioning::NetworkCredentials::AlreadyOnNetwork)
492    /// only makes sense for a device that already has operational connectivity
493    /// independent of BLE (e.g. Ethernet).
494    ///
495    /// **Requires macOS Bluetooth permission (TCC).** The first call
496    /// instantiates `CoreBluetooth` and may raise the one-time Bluetooth prompt,
497    /// attributed to the terminal application — see
498    /// `docs/runbooks/ble-commissioning.md`.
499    ///
500    /// # Errors
501    ///
502    /// [`Error::NoTrust`] if no attestation trust was configured,
503    /// [`Error::SetupCode`] if the code is invalid, [`Error::ControllerStopped`]
504    /// if the task stopped (including a btleplug-internal panic in the spawned
505    /// commission task), [`Error::Operational`] for a BLE-layer failure (no
506    /// adapter / denied permission, scan timeout, connect, GATT, or BTP
507    /// handshake), or any driver/commissioning error.
508    #[cfg(feature = "ble")]
509    pub async fn commission_ble(
510        &self,
511        setup_code: &str,
512        network: matter_commissioning::NetworkCredentials,
513    ) -> Result<u64, Error> {
514        let setup_payload = parse_setup_code(setup_code)?;
515        let (reply, rx) = oneshot::channel();
516        self.tx
517            .send(Command::CommissionBle {
518                setup_payload,
519                network,
520                reply,
521            })
522            .await
523            .map_err(|_| Error::ControllerStopped)?;
524        rx.await.map_err(|_| Error::ControllerStopped)?
525    }
526
527    /// Handle addressing a device by node id (single-fabric in M8.2).
528    #[must_use]
529    pub fn node(&self, node_id: u64) -> Node {
530        Node {
531            tx: self.tx.clone(),
532            node_id,
533        }
534    }
535
536    /// Create a group key set on the controller's fabric: mints a fresh 16-byte
537    /// epoch key from the CSPRNG, persists a `GroupKeySetConfig` under
538    /// `key_set_id`, and returns the [`GroupKeySet`](crate::GroupKeySet) so the caller can program
539    /// it onto each member device via
540    /// [`Node::write_group_key_set`](crate::Node::write_group_key_set) and map a
541    /// group to it. The key set is stored durably before this returns, so the
542    /// controller can encrypt outbound group messages for it immediately
543    /// (see [`Self::invoke_group`]).
544    ///
545    /// `epoch_start_time` is the Matter-epoch start time recorded in the
546    /// returned `GroupKeySet` (the device-side `KeySetWrite` echoes it).
547    ///
548    /// # Errors
549    ///
550    /// [`Error::NotCommissioned`] if no single fabric exists,
551    /// [`Error::ControllerStopped`] if the task has stopped, or any
552    /// CSPRNG / persistence error.
553    pub async fn create_group(
554        &self,
555        key_set_id: u16,
556        epoch_start_time: u64,
557    ) -> Result<crate::GroupKeySet, Error> {
558        let (reply, rx) = oneshot::channel();
559        self.tx
560            .send(Command::CreateGroup {
561                key_set_id,
562                epoch_start_time,
563                reply,
564            })
565            .await
566            .map_err(|_| Error::ControllerStopped)?;
567        rx.await.map_err(|_| Error::ControllerStopped)?
568    }
569
570    /// Fire-and-forget multicast group invoke: send `path`/`fields` to every
571    /// device in `group_id`, encrypted with the operational group key derived
572    /// from the persisted `key_set_id`. Returns as soon as the datagram is sent
573    /// — group commands are unacknowledged, so there is no response.
574    ///
575    /// The caller supplies `key_set_id` (the key set the group was bound to when
576    /// it was created): the controller's persisted `group_keys` are keyed by
577    /// key set id, avoiding a separate group→key-set map. The outbound group
578    /// message counter is bumped and persisted **before** the send so a counter
579    /// is never reused across a crash.
580    ///
581    /// Real multicast delivery requires the host network to route the Matter
582    /// site-local group address; on a host without it the send still succeeds at
583    /// the socket layer (the bytes are correct — see the loopback test).
584    ///
585    /// # Errors
586    ///
587    /// [`Error::GroupNotProvisioned`] if `key_set_id` has no persisted key set,
588    /// [`Error::NotCommissioned`] if no single fabric exists,
589    /// [`Error::Operational`] on counter exhaustion or send failure,
590    /// [`Error::ControllerStopped`] if the task has stopped, or any
591    /// crypto / persistence error.
592    pub async fn invoke_group(
593        &self,
594        group_id: u16,
595        key_set_id: u16,
596        path: crate::CommandPath,
597        fields: crate::Value,
598    ) -> Result<(), Error> {
599        let fields_tlv = crate::node::value_to_tlv(&fields)?;
600        let (reply, rx) = oneshot::channel();
601        self.tx
602            .send(Command::InvokeGroup {
603                group_id,
604                key_set_id,
605                path,
606                fields_tlv,
607                reply,
608            })
609            .await
610            .map_err(|_| Error::ControllerStopped)?;
611        rx.await.map_err(|_| Error::ControllerStopped)?
612    }
613
614    #[cfg(test)]
615    pub(crate) async fn session_count(&self) -> usize {
616        let (reply, rx) = oneshot::channel();
617        if self.tx.send(Command::SessionCount { reply }).await.is_err() {
618            return 0;
619        }
620        rx.await.unwrap_or(0)
621    }
622
623    /// Fetch the stored CASE resumption record for `node_id` from the actor's
624    /// live state (deserialized; `None` if the device has none). Used by
625    /// [`Self::serve_ota`] to let the provider server accept the requestor's
626    /// resumption attempt.
627    ///
628    /// # Errors
629    ///
630    /// [`Error::ControllerStopped`] if the owning task stopped,
631    /// [`Error::NotCommissioned`] if no sole fabric exists, or a
632    /// [`Error::Snapshot`]/[`Error::Codec`]/[`Error::Cert`] deserialization
633    /// failure for a corrupt stored record.
634    pub(crate) async fn resumption_record_for(
635        &self,
636        node_id: u64,
637    ) -> Result<Option<matter_crypto::ResumptionRecord>, Error> {
638        let (reply, rx) = oneshot::channel();
639        self.tx
640            .send(Command::ResumptionRecordFor { node_id, reply })
641            .await
642            .map_err(|_| Error::ControllerStopped)?;
643        let bytes = rx.await.map_err(|_| Error::ControllerStopped)??;
644        match bytes {
645            Some(b) => Ok(Some(crate::resumption::deserialize_record(&b)?)),
646            None => Ok(None),
647        }
648    }
649
650    /// Store `record` as the CASE resumption record for `node_id` (replacing
651    /// any prior one; best-effort persist). Invoked by [`Self::serve_ota`]'s
652    /// provider server's `record_sink`, once per completed CASE accept.
653    ///
654    /// # Errors
655    ///
656    /// [`Error::ControllerStopped`] if the owning task stopped,
657    /// [`Error::NotCommissioned`] if no sole fabric exists, or
658    /// [`Error::Operational`] if the device has no entry on the fabric.
659    pub(crate) async fn store_resumption_record(
660        &self,
661        node_id: u64,
662        record: &matter_crypto::ResumptionRecord,
663    ) -> Result<(), Error> {
664        let record_bytes = crate::resumption::serialize_record(record)?;
665        let (reply, rx) = oneshot::channel();
666        self.tx
667            .send(Command::StoreResumptionRecord {
668                node_id,
669                record_bytes,
670                reply,
671            })
672            .await
673            .map_err(|_| Error::ControllerStopped)?;
674        rx.await.map_err(|_| Error::ControllerStopped)?
675    }
676}
677
678/// Parse a QR (`MT:...`) or manual pairing code into a [`matter_commissioning::SetupPayload`].
679///
680/// QR codes are identified by the `MT:` prefix (Matter Core Spec §5.1.3.1).
681/// Anything else is treated as a manual pairing code.
682///
683/// # Errors
684///
685/// Returns [`Error::SetupCode`] if the string is not a valid QR or manual code.
686fn parse_setup_code(code: &str) -> Result<matter_commissioning::SetupPayload, Error> {
687    let trimmed = code.trim();
688    let parsed = if trimmed.starts_with("MT:") {
689        matter_commissioning::parse_qr(trimmed)
690    } else {
691        matter_commissioning::parse_manual_code(trimmed)
692    };
693    parsed.map_err(|e| Error::SetupCode(format!("{e:?}")))
694}