Skip to main content

matter_controller/
builder.rs

1//! Builder for [`MatterController`]. Configures attestation trust and the
2//! admin vendor id before spawning the owning actor.
3
4use std::sync::Arc;
5
6use crate::controller::MatterController;
7use crate::error::Error;
8use crate::store::ControllerStore;
9use crate::trust::AttestationTrust;
10
11/// Default admin vendor id used in `AddNOC` (CSA test VID). Override via
12/// [`MatterControllerBuilder::admin_vendor_id`].
13pub const DEFAULT_ADMIN_VENDOR_ID: u16 = 0xFFF1;
14
15/// The pieces [`MatterControllerBuilder::build`] has assembled by the time the
16/// actor can be spawned. Exists only to keep [`SpawnWithDiscovery`] a
17/// single-argument closure type.
18struct SpawnParts {
19    store: Arc<dyn ControllerStore>,
20    transport: matter_transport::TokioUdpTransport,
21    trust: Option<AttestationTrust>,
22    admin_vendor_id: u16,
23    multicast_if: Option<u32>,
24    response_deadline: std::time::Duration,
25}
26
27/// The deferred actor spawn installed by [`MatterControllerBuilder::discovery`].
28///
29/// What is boxed here is the **spawn step**, not the `Discovery` value. That
30/// distinction is the whole design:
31///
32/// * It keeps `MatterControllerBuilder` a plain, non-generic struct, so adding
33///   this seam is not a breaking change for anyone who names the type.
34/// * It does *not* erase the caller's type. The closure is built inside
35///   `discovery::<D>()`, where the concrete `D` is still in scope, so
36///   `with_components_and_multicast_if` is monomorphised over that `D` and the
37///   actor holds the caller's own type. Every trait call therefore dispatches
38///   to the caller's `impl` — including methods that have a default body in
39///   the trait and which a `Box<dyn Discovery>` shim would silently resolve to
40///   the default instead of the override.
41type SpawnWithDiscovery = Box<dyn FnOnce(SpawnParts) -> Result<MatterController, Error> + Send>;
42
43/// Configures and opens a [`MatterController`].
44pub struct MatterControllerBuilder {
45    store: Arc<dyn ControllerStore>,
46    trust: Option<AttestationTrust>,
47    admin_vendor_id: u16,
48    multicast_if: Option<u32>,
49    response_deadline: std::time::Duration,
50    /// `None` — the default — means no discovery was supplied, and
51    /// [`Self::build`] takes the untouched default path through
52    /// `MatterController::spawn_default`.
53    spawn_with_discovery: Option<SpawnWithDiscovery>,
54}
55
56impl MatterControllerBuilder {
57    pub(crate) fn new(store: Arc<dyn ControllerStore>) -> Self {
58        Self {
59            store,
60            trust: None,
61            admin_vendor_id: DEFAULT_ADMIN_VENDOR_ID,
62            multicast_if: None,
63            response_deadline: crate::actor::DEFAULT_RESPONSE_DEADLINE,
64            spawn_with_discovery: None,
65        }
66    }
67
68    /// Set the device-attestation trust material. Required to `commission`.
69    #[must_use]
70    pub fn attestation_trust(mut self, trust: AttestationTrust) -> Self {
71        self.trust = Some(trust);
72        self
73    }
74
75    /// Override the admin vendor id used in `AddNOC` (default `0xFFF1`).
76    #[must_use]
77    pub fn admin_vendor_id(mut self, vid: u16) -> Self {
78        self.admin_vendor_id = vid;
79        self
80    }
81
82    /// Set the IPv6 multicast egress interface (an `if_nametoindex` value)
83    /// used for group commands (`invoke_group`). On a multi-homed host the
84    /// kernel default has no route for the admin-local `ff35:` group address
85    /// and group sends fail with "No route to host" — pick the LAN-facing
86    /// interface. When unset, the `MATTER_MULTICAST_IF` env var is honored as
87    /// a compat fallback, then the kernel default.
88    #[must_use]
89    pub fn multicast_interface(mut self, if_index: u32) -> Self {
90        self.multicast_if = Some(if_index);
91        self
92    }
93
94    /// Bound how long an operational read/write/invoke waits for its
95    /// Interaction Model response (default 30 s).
96    ///
97    /// Matter's MRP bounds *delivery*, not *response*: once a device
98    /// acknowledges a request, the retransmit timer for that exchange is
99    /// discarded. A device that accepts a request and then never answers it
100    /// therefore has nothing left to expire, and without this deadline the
101    /// call waits forever. Real devices do this — a Tapo H100 bridge silently
102    /// drops the 9th consecutive read on a session — so every operational verb
103    /// is bounded by this value and fails with
104    /// [`Error::ResponseTimeout`] when it elapses.
105    ///
106    /// The request is **not** retried first. Delivery was confirmed, so the
107    /// device may already have executed a non-idempotent command; deciding
108    /// whether a retry is safe belongs to you, not the library. This is
109    /// deliberately unlike a lost-packet timeout, which the controller does
110    /// retry once on a fresh session.
111    ///
112    /// Lower it if you front the controller with your own per-operation
113    /// timeout and would rather see the library's error than your own; raise
114    /// it for devices that are legitimately slow to answer.
115    #[must_use]
116    pub fn response_deadline(mut self, deadline: std::time::Duration) -> Self {
117        self.response_deadline = deadline;
118        self
119    }
120
121    /// Supply your own mDNS stack instead of the built-in one.
122    ///
123    /// # What the default is
124    ///
125    /// Leave this unset and the controller starts
126    /// [`MdnsSdDiscovery`](matter_transport::MdnsSdDiscovery) — a pure-Rust
127    /// responder built on the `mdns-sd` crate, with no system daemon required.
128    /// That remains the default and is not going away; this method exists so
129    /// the mDNS stack is your *choice* rather than something the library
130    /// imposes on you.
131    ///
132    /// # Why you might replace it
133    ///
134    /// * **You already run a system responder.** On a typical Linux host
135    ///   `avahi-daemon` (or `systemd-resolved`) already owns UDP 5353. A second
136    ///   in-process responder is a second cache, a second set of probes, and a
137    ///   second opinion about what is on the network. Delegating to the daemon
138    ///   you already run removes that whole class of disagreement.
139    /// * **You want the OS-native stack.** Bonjour on macOS, or a
140    ///   platform/embedded resolver that is better placed than we are to know
141    ///   about interface changes, sleep/wake, and roaming.
142    /// * **You are testing.** A deterministic test double lets you drive
143    ///   resolution outcomes — a node that never appears, one that appears late,
144    ///   one that resolves to a fixed loopback address — without any real
145    ///   network.
146    ///
147    /// # What your implementation is responsible for
148    ///
149    /// Implement [`matter_transport::Discovery`]; its own documentation is the
150    /// contract. In short: `publish`/`unpublish` advertise and withdraw our
151    /// services, and `query` → `poll_results` → `stop_query` is a browse whose
152    /// records you buffer per handle and hand over on each drain. Read the notes
153    /// on [`query`](matter_transport::Discovery::query) and
154    /// [`stop_query`](matter_transport::Discovery::stop_query) about handle
155    /// lifetime before you start — a handle that is never stopped keeps costing
156    /// resources.
157    ///
158    /// The trait may also grow methods that carry a **default implementation**,
159    /// so that adding one does not break existing implementors. Your type keeps
160    /// compiling when that happens, but it silently takes the generic default
161    /// until you override it — and a default is by definition the unrefined
162    /// path (for instance, a fallback that browses every operational record
163    /// rather than a narrowed subset). When you upgrade, check the trait for
164    /// defaulted methods worth overriding.
165    ///
166    /// # Scope: this covers the controller's own resolution, not the servers
167    ///
168    /// The discovery you pass here is owned by the controller's actor task and
169    /// is what every client operation resolves through — connecting to a node,
170    /// commissioning, resubscribing.
171    ///
172    /// It is **not** used by the self-hosted server entry points
173    /// ([`listen_for_checkin_once`](MatterController::listen_for_checkin_once),
174    /// the `ota` feature's `serve_ota`, and the `unstable-provider` feature's
175    /// `serve_provider_once`). Each of those runs
176    /// off the actor on its own socket and needs a `Discovery` it exclusively
177    /// owns for the duration of the call, which a single value moved into the
178    /// actor cannot provide; they each construct their own `MdnsSdDiscovery`
179    /// and use it only to publish and withdraw one operational record. So if
180    /// you supply an Avahi-backed implementation and then serve OTA, your
181    /// backend does the resolving while that record is still advertised through
182    /// `mdns-sd`. If that matters to you, say so on [issue #113] — closing the
183    /// gap means taking a discovery *factory* here rather than a value, and
184    /// that is worth doing on demand rather than on speculation.
185    ///
186    /// # Example
187    ///
188    /// ```no_run
189    /// # use std::sync::Arc;
190    /// # use matter_controller::{MatterController, ControllerStore};
191    /// # async fn f(
192    /// #     store: Arc<dyn ControllerStore>,
193    /// #     my_discovery: impl matter_transport::Discovery + Send + 'static,
194    /// # ) -> Result<(), Box<dyn std::error::Error>> {
195    /// let controller = MatterController::builder(store)
196    ///     .discovery(my_discovery)
197    ///     .build()
198    ///     .await?;
199    /// # Ok(())
200    /// # }
201    /// ```
202    ///
203    /// [issue #113]: https://github.com/phunapps/matter-rust/issues/113
204    #[must_use]
205    pub fn discovery<D>(mut self, discovery: D) -> Self
206    where
207        // Exactly the bounds `Actor<T, D>` already states — `Send` so the
208        // spawned actor future can move onto the multi-thread runtime. No
209        // `Sync`: only the actor task ever touches it.
210        D: matter_transport::Discovery + Send + 'static,
211    {
212        self.spawn_with_discovery = Some(Box::new(move |parts: SpawnParts| {
213            MatterController::with_components_and_multicast_if(
214                parts.store,
215                parts.transport,
216                discovery,
217                Arc::new(matter_commissioning::SystemNocRng),
218                parts.trust,
219                parts.admin_vendor_id,
220                parts.multicast_if,
221                parts.response_deadline,
222            )
223        }));
224        self
225    }
226
227    /// Bind the socket + discovery, load persisted state, and spawn the actor.
228    ///
229    /// Uses the discovery supplied to [`Self::discovery`], or starts the default
230    /// [`MdnsSdDiscovery`](matter_transport::MdnsSdDiscovery) if none was.
231    ///
232    /// # Errors
233    ///
234    /// [`Error::Store`] / [`Error::Snapshot`] on load failure, or
235    /// [`Error::Operational`] if the socket / mDNS cannot start.
236    pub async fn build(self) -> Result<MatterController, Error> {
237        let Self {
238            store,
239            trust,
240            admin_vendor_id,
241            multicast_if,
242            response_deadline,
243            spawn_with_discovery,
244        } = self;
245
246        let Some(spawn) = spawn_with_discovery else {
247            // Untouched default path: bind the socket AND start `mdns-sd`.
248            return MatterController::spawn_default(
249                store,
250                trust,
251                admin_vendor_id,
252                multicast_if,
253                response_deadline,
254            )
255            .await;
256        };
257
258        // Same bind as `spawn_default` — only the discovery differs.
259        let transport =
260            matter_transport::TokioUdpTransport::bind_with_multicast_if(0, multicast_if)
261                .await
262                .map_err(|e| Error::Operational(format!("bind: {e}")))?;
263        spawn(SpawnParts {
264            store,
265            transport,
266            trust,
267            admin_vendor_id,
268            multicast_if,
269            response_deadline,
270        })
271    }
272}
273
274#[cfg(test)]
275#[allow(clippy::unwrap_used, clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
276mod tests {
277    use std::sync::atomic::{AtomicUsize, Ordering};
278
279    use matter_transport::{Discovery, MatterService, QueryHandle, ServiceKind};
280
281    use super::*;
282    use crate::fabric::FabricConfig;
283    use crate::store::StoreError;
284
285    /// Sentinel carried by the injected discovery's `query` error. Seeing it come
286    /// back out of a controller operation is proof that *this* implementation —
287    /// not a freshly-constructed `MdnsSdDiscovery` — did the resolving.
288    const SENTINEL: &str = "injected-discovery-was-used";
289
290    /// A `Discovery` that counts `query` calls and always fails them, so an
291    /// operation needing resolution fails fast and deterministically instead of
292    /// waiting out a browse that will never produce a record.
293    struct RecordingDiscovery(Arc<AtomicUsize>);
294
295    impl Discovery for RecordingDiscovery {
296        fn publish(&mut self, _s: &MatterService) -> matter_transport::Result<()> {
297            Ok(())
298        }
299        fn unpublish(&mut self, _n: &str, _k: ServiceKind) -> matter_transport::Result<()> {
300            Ok(())
301        }
302        fn query(&mut self, _k: ServiceKind) -> matter_transport::Result<QueryHandle> {
303            self.0.fetch_add(1, Ordering::SeqCst);
304            Err(matter_transport::Error::Mdns(SENTINEL.to_string()))
305        }
306        fn stop_query(&mut self, _h: QueryHandle) {}
307        fn poll_results(&mut self, _h: QueryHandle) -> Vec<MatterService> {
308            Vec::new()
309        }
310    }
311
312    /// In-memory store, mirroring the one in `actor`'s test module.
313    #[derive(Default)]
314    struct MemStore(std::sync::Mutex<Option<Vec<u8>>>);
315
316    impl crate::store::ControllerStore for MemStore {
317        fn load(&self) -> Result<Option<Vec<u8>>, StoreError> {
318            Ok(self.0.lock().unwrap().clone())
319        }
320        fn save(&self, snapshot: &[u8]) -> Result<(), StoreError> {
321            *self.0.lock().unwrap() = Some(snapshot.to_vec());
322            Ok(())
323        }
324    }
325
326    fn fabric_cfg() -> FabricConfig {
327        FabricConfig {
328            fabric_id: 0xAABB_CCDD_0000_0001,
329            rcac_id: 1,
330            commissioner_node_id: 1,
331            validity: (
332                matter_cert::MatterTime::from_unix_secs(1_700_000_000),
333                matter_cert::MatterTime::NO_EXPIRY,
334            ),
335            issue_icac: false,
336        }
337    }
338
339    /// The seam works: a `Discovery` handed to the builder is the one the actor
340    /// resolves through. We drive a `read`, which must connect and therefore
341    /// must open an operational browse — and our double is what answers.
342    #[tokio::test]
343    async fn supplied_discovery_is_used_for_resolution() {
344        let queries = Arc::new(AtomicUsize::new(0));
345        let controller = MatterController::builder(Arc::new(MemStore::default()))
346            .discovery(RecordingDiscovery(queries.clone()))
347            .build()
348            .await
349            .expect("build with injected discovery");
350
351        controller
352            .create_fabric(fabric_cfg())
353            .await
354            .expect("create_fabric");
355
356        // Needs a session → needs a resolve → hits the injected discovery.
357        let err = controller
358            .node(0x1234)
359            .read(&[crate::ReadPath::concrete(0, 0x0028, 0x0001)])
360            .await
361            .expect_err("read must fail: the injected discovery refuses to browse");
362
363        assert!(
364            err.to_string().contains(SENTINEL),
365            "error must originate in the injected discovery, got: {err}"
366        );
367        assert_eq!(
368            queries.load(Ordering::SeqCst),
369            1,
370            "the injected discovery must have been asked to browse exactly once"
371        );
372    }
373
374    /// Regression guard for the non-breaking shape: because the builder stays a
375    /// plain non-generic struct, a caller that never mentions `discovery` still
376    /// compiles with no turbofish and no inference annotation.
377    #[tokio::test]
378    async fn builder_without_discovery_needs_no_turbofish() {
379        let builder = MatterController::builder(Arc::new(MemStore::default()))
380            .admin_vendor_id(0xFFF2)
381            .multicast_interface(0);
382        // Building would start the real `mdns-sd` daemon, which we do not want in
383        // a unit test. Constructing and configuring the builder is what pins the
384        // inference property; `build()`'s default path is covered elsewhere.
385        drop(builder);
386    }
387}