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}