matter_controller/node.rs
1//! A cheap handle addressing one device node. Holds no session state.
2
3use tokio::sync::oneshot;
4
5use matter_codec::{Tag, TlvReader, TlvWriter, Value};
6use matter_interaction::{
7 build_invoke_request, build_invoke_request_timed, build_list_write_chunks,
8 build_read_request_full, build_read_request_paths, build_write_request,
9 build_write_request_timed, parse_invoke_response, parse_write_response, AttributePath,
10 AttributeWriteRequest, CommandPath, EventFilter, EventPath, EventReport, ImStatus,
11 InvokeResponse, ReadPath, ReportAccumulator, ReportData,
12};
13
14use crate::actor::Command;
15use crate::error::Error;
16
17pub(crate) const OP_READ_REQUEST: u8 = 0x02;
18const OP_WRITE_REQUEST: u8 = 0x06;
19pub(crate) const OP_INVOKE_REQUEST: u8 = 0x08;
20
21/// Budget for a single `WriteRequestMessage` when writing the ACL list.
22/// Stays well under `MAX_PAYLOAD_LEN` (1024 post-encryption); reserves
23/// headroom for the secured-message header, MRP acks, and AES tag.
24const WRITE_CHUNK_BUDGET: usize = 800;
25
26/// Default timed-interaction timeout (milliseconds) used by
27/// [`Node::write_timed`] / [`Node::invoke_timed`] when the caller passes `None`.
28///
29/// This is the window the **device** holds open for the follow-up Write/Invoke
30/// after our `TimedRequest`. We send the action immediately, so this only needs
31/// to cover the round-trip plus MRP retransmits; a chip-aligned 10s is generous.
32pub const TIMED_DEFAULT_MS: u16 = 10_000;
33
34/// Outcome of [`Node::invoke`].
35#[derive(Clone, Debug, PartialEq)]
36#[non_exhaustive]
37pub enum InvokeResult {
38 /// The device returned a response command with (anonymous-tagged) fields.
39 Data {
40 /// The response command path.
41 path: CommandPath,
42 /// The decoded response fields.
43 fields: Value,
44 },
45 /// The device returned a bare status (e.g. `Success`).
46 Status(ImStatus),
47}
48
49/// `TimeSynchronization.Granularity` (Matter Core §11.17) — how precise the time
50/// passed to [`Node::set_utc_time`] is.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52#[non_exhaustive]
53pub enum TimeGranularity {
54 /// Time is not currently known.
55 NoTime,
56 /// Accurate to the minute.
57 Minutes,
58 /// Accurate to the second.
59 Seconds,
60 /// Accurate to the millisecond.
61 Milliseconds,
62 /// Accurate to the microsecond.
63 Microseconds,
64}
65
66impl TimeGranularity {
67 fn to_u8(self) -> u8 {
68 match self {
69 Self::NoTime => 0,
70 Self::Minutes => 1,
71 Self::Seconds => 2,
72 Self::Milliseconds => 3,
73 Self::Microseconds => 4,
74 }
75 }
76}
77
78/// One `TimeZoneStruct` entry for [`Node::set_time_zone`].
79///
80/// `#[non_exhaustive]`: construct via [`TimeZoneEntry::new`] so future optional
81/// spec fields can be added without a breaking change.
82#[derive(Clone, Debug, PartialEq, Eq)]
83#[non_exhaustive]
84pub struct TimeZoneEntry {
85 /// Offset from UTC in seconds (−43200..=50400).
86 pub offset_seconds: i32,
87 /// The `UTCTime` (epoch µs) at which this offset takes effect.
88 pub valid_at_us: u64,
89 /// Optional IANA time-zone name.
90 pub name: Option<String>,
91}
92
93impl TimeZoneEntry {
94 /// A time-zone entry: `offset_seconds` from UTC (−43200..=50400) taking
95 /// effect at `valid_at_us` (epoch µs), with an optional IANA `name`.
96 #[must_use]
97 pub fn new(offset_seconds: i32, valid_at_us: u64, name: Option<String>) -> Self {
98 Self {
99 offset_seconds,
100 valid_at_us,
101 name,
102 }
103 }
104}
105
106/// One `DSTOffsetStruct` entry for [`Node::set_dst_offset`].
107///
108/// `#[non_exhaustive]`: construct via [`DstOffsetEntry::new`] so future optional
109/// spec fields can be added without a breaking change.
110#[derive(Clone, Debug, PartialEq, Eq)]
111#[non_exhaustive]
112pub struct DstOffsetEntry {
113 /// DST offset in seconds added to the standard offset.
114 pub offset_seconds: i32,
115 /// The `UTCTime` (epoch µs) at which this DST offset starts.
116 pub valid_starting_us: u64,
117 /// The `UTCTime` (epoch µs) at which it stops, or `None` for indefinite.
118 pub valid_until_us: Option<u64>,
119}
120
121impl DstOffsetEntry {
122 /// A DST-offset entry: `offset_seconds` added to the standard offset,
123 /// starting at `valid_starting_us` and ending at `valid_until_us` (both
124 /// epoch µs; `None` end means indefinite).
125 #[must_use]
126 pub fn new(offset_seconds: i32, valid_starting_us: u64, valid_until_us: Option<u64>) -> Self {
127 Self {
128 offset_seconds,
129 valid_starting_us,
130 valid_until_us,
131 }
132 }
133}
134
135/// Extract `SetTimeZoneResponse.DSTOffsetRequired` (ctx0 bool) from a decoded
136/// response `Value`.
137fn dst_required_from_response(fields: &Value) -> Result<bool, Error> {
138 if let Value::Structure(members) = fields {
139 for (tag, v) in members {
140 if *tag == Tag::Context(0) {
141 if let Value::Bool(b) = v {
142 return Ok(*b);
143 }
144 }
145 }
146 }
147 Err(Error::Operational(
148 "SetTimeZoneResponse missing DSTOffsetRequired".into(),
149 ))
150}
151
152/// Extract a `u32` from ctx0 of a decoded response `Value` (used for
153/// `RegisterClientResponse.ICDCounter` and `StayActiveResponse.PromisedActiveDuration`).
154fn u32_ctx0_from_response(fields: &Value, what: &'static str) -> Result<u32, Error> {
155 if let Value::Structure(members) = fields {
156 for (tag, v) in members {
157 if *tag == Tag::Context(0) {
158 if let Value::Uint(n) = v {
159 return u32::try_from(*n)
160 .map_err(|_| Error::Operational(format!("{what} exceeds u32 range")));
161 }
162 }
163 }
164 }
165 Err(Error::Operational(format!("response missing {what}")))
166}
167
168/// Extract `RegisterClientResponse.ICDCounter` (ctx0 u32).
169fn icd_counter_from_response(fields: &Value) -> Result<u32, Error> {
170 u32_ctx0_from_response(fields, "ICDCounter")
171}
172
173/// Extract `StayActiveResponse.PromisedActiveDuration` (ctx0 u32).
174fn promised_duration_from_response(fields: &Value) -> Result<u32, Error> {
175 u32_ctx0_from_response(fields, "PromisedActiveDuration")
176}
177
178/// Encode a `Value` into a standalone anonymous-tagged TLV blob.
179///
180/// Exposed as `pub(crate)` so tests in sibling modules can encode ACL entry
181/// values for chunk-count calculations without reaching through the public API.
182///
183/// # Errors
184///
185/// Returns [`Error::Codec`] if the TLV writer fails.
186pub(crate) fn value_to_tlv(value: &Value) -> Result<Vec<u8>, Error> {
187 let mut buf = Vec::new();
188 let mut w = TlvWriter::new(&mut buf);
189 w.write_value(Tag::Anonymous, value)?;
190 Ok(buf)
191}
192
193/// Decode an anonymous-tagged TLV blob back into a `Value`.
194///
195/// # Errors
196///
197/// Returns [`Error::Codec`] if the TLV reader fails.
198fn tlv_to_value(bytes: &[u8]) -> Result<Value, Error> {
199 let mut r = TlvReader::new(bytes);
200 let (_tag, value) = r.read_value()?;
201 Ok(value)
202}
203
204/// Handle to one commissioned device. Obtain via
205/// [`MatterController::node`](crate::controller::MatterController::node).
206#[derive(Clone)]
207pub struct Node {
208 pub(crate) tx: tokio::sync::mpsc::Sender<Command>,
209 pub(crate) node_id: u64,
210}
211
212impl Node {
213 /// The device's operational node ID.
214 #[must_use]
215 pub fn node_id(&self) -> u64 {
216 self.node_id
217 }
218
219 /// Send a raw secured Interaction-Model payload and await the response
220 /// payload. Establishes/caches the CASE session transparently.
221 ///
222 /// A generic primitive retained for tests that exercise connect/cache/demux
223 /// without IM payloads; the production verbs (`read`/`write`/`invoke`/
224 /// `subscribe`) use the specialized actor commands.
225 ///
226 /// # Errors
227 ///
228 /// [`Error::ControllerStopped`] if the owning task has stopped, or any
229 /// connect / transport / driver error.
230 #[cfg(test)]
231 pub(crate) async fn round_trip(
232 &self,
233 opcode: u8,
234 protocol_id: matter_transport::ProtocolId,
235 payload: Vec<u8>,
236 ) -> Result<Vec<u8>, Error> {
237 let (reply, rx) = oneshot::channel();
238 self.tx
239 .send(Command::RoundTrip {
240 node_id: self.node_id,
241 opcode,
242 protocol_id,
243 payload,
244 reply,
245 })
246 .await
247 .map_err(|_| Error::ControllerStopped)?;
248 rx.await.map_err(|_| Error::ControllerStopped)?
249 }
250
251 /// Send a chunked read request and collect every `ReportData` chunk payload
252 /// in order. A non-chunked read yields a single-element `Vec`.
253 ///
254 /// # Errors
255 ///
256 /// [`Error::ControllerStopped`] if the owning task has stopped, or any
257 /// connect / transport / driver error.
258 pub(crate) async fn round_trip_chunked(
259 &self,
260 payload: Vec<u8>,
261 ) -> Result<Vec<ReportData>, Error> {
262 let (reply, rx) = oneshot::channel();
263 self.tx
264 .send(Command::Read {
265 node_id: self.node_id,
266 payload,
267 reply,
268 })
269 .await
270 .map_err(|_| Error::ControllerStopped)?;
271 rx.await.map_err(|_| Error::ControllerStopped)?
272 }
273
274 /// Run a timed interaction: send a `TimedRequest`, await
275 /// `StatusResponse(SUCCESS)`, then send `action_payload` (opcode
276 /// `action_opcode`) on the same exchange and return its response bytes.
277 ///
278 /// # Errors
279 ///
280 /// [`Error::ControllerStopped`] if the owning task stopped, or any
281 /// connect / transport / driver error.
282 pub(crate) async fn round_trip_timed(
283 &self,
284 timeout_ms: u16,
285 action_opcode: u8,
286 action_payload: Vec<u8>,
287 ) -> Result<Vec<u8>, Error> {
288 let (reply, rx) = oneshot::channel();
289 self.tx
290 .send(Command::TimedRoundTrip {
291 node_id: self.node_id,
292 timeout_ms,
293 action_opcode,
294 action_payload,
295 reply,
296 })
297 .await
298 .map_err(|_| Error::ControllerStopped)?;
299 rx.await.map_err(|_| Error::ControllerStopped)?
300 }
301
302 /// Send a multi-chunk write: each element of `chunks` is one
303 /// `WriteRequestMessage` (built by
304 /// [`build_list_write_chunks`](matter_interaction::build_list_write_chunks),
305 /// which encodes `MoreChunkedMessages` explicitly on every chunk of a
306 /// multi-chunk sequence — `true` on all but the last, explicit `false` on
307 /// the last, as chip's sticky-flag parser requires). All chunks go on
308 /// ONE exchange, ONE in flight at a time — the actor sends the next chunk
309 /// only after the device's `WriteResponseMessage` to the previous one
310 /// (chip's `WriteClient` parity; see `Actor::handle_chunked_write` /
311 /// `Actor::resolve_chunked_write`).
312 ///
313 /// Every chunk's `WriteResponseMessage` is parsed and its per-path
314 /// statuses accumulated; the next chunk is sent regardless of what those
315 /// statuses were (chip's `WriteClient` does not abort on a non-Success
316 /// element status — it pumps every chunk unconditionally). Returns the
317 /// FULL accumulated per-path status list on success.
318 ///
319 /// # Errors
320 ///
321 /// [`Error::ControllerStopped`] if the owning task stopped; an interaction
322 /// error if a `WriteResponse` fails to parse; [`Error::Operational`] if
323 /// the device rejects a chunk outright (e.g. with a `StatusResponse`
324 /// instead of a `WriteResponse`) rather than returning a per-path status;
325 /// or any connect / transport / driver error.
326 pub(crate) async fn chunked_write(
327 &self,
328 chunks: Vec<Vec<u8>>,
329 ) -> Result<Vec<(AttributePath, ImStatus)>, Error> {
330 let (reply, rx) = oneshot::channel();
331 self.tx
332 .send(Command::ChunkedWrite {
333 node_id: self.node_id,
334 chunks,
335 reply,
336 })
337 .await
338 .map_err(|_| Error::ControllerStopped)?;
339 rx.await.map_err(|_| Error::ControllerStopped)?
340 }
341
342 /// The controller's commissioner node id (the sole fabric's
343 /// `commissioner.node_id`). Used by the ACL lockout guard to avoid writing
344 /// an ACL that would lock the commissioner out of the device.
345 ///
346 /// # Errors
347 ///
348 /// [`Error::ControllerStopped`] if the owning task stopped, or
349 /// [`Error::NotCommissioned`] if no sole fabric exists.
350 pub(crate) async fn commissioner_node_id(&self) -> Result<u64, Error> {
351 let (reply, rx) = oneshot::channel();
352 self.tx
353 .send(Command::CommissionerNodeId { reply })
354 .await
355 .map_err(|_| Error::ControllerStopped)?;
356 rx.await.map_err(|_| Error::ControllerStopped)?
357 }
358
359 /// Read attributes (concrete or wildcard paths). Returns the device's
360 /// `(path, value)` reports keyed by the concrete paths it reports. Values
361 /// are raw [`Value`]; decode them with `matter-clusters` codecs.
362 ///
363 /// A wildcard read (e.g. [`ReadPath::all`]) whose response spans multiple
364 /// `ReportData` chunks is reassembled transparently — every chunk is
365 /// solicited and merged through [`ReportAccumulator`], so the result is the
366 /// device's complete attribute set, not just the first chunk.
367 ///
368 /// # Errors
369 ///
370 /// [`Error::ControllerStopped`], any connect/transport error, or
371 /// [`Error::InteractionModel`] if a response chunk cannot be parsed.
372 pub async fn read(&self, paths: &[ReadPath]) -> Result<Vec<(AttributePath, Value)>, Error> {
373 let req = build_read_request_paths(paths);
374 // Chunks arrive already parsed (the actor's receive path parses each
375 // `ReportData` exactly once); merge them without re-walking the TLV.
376 let chunks = self.round_trip_chunked(req).await?;
377 let mut acc = ReportAccumulator::new();
378 for chunk in chunks {
379 acc.push(chunk)?;
380 }
381 Ok(acc.finish())
382 }
383
384 /// Read events for the given (concrete or wildcard) event paths, optionally
385 /// filtered to events with number `>= event_min` (via [`EventFilter`]).
386 /// Returns every reported [`EventReport`] in wire order, reassembled across
387 /// chunks. Decode the event payloads with `matter-clusters` codecs.
388 ///
389 /// Events are discrete records (not list attributes), so — unlike
390 /// [`read`](Self::read) — there is no merge step: each chunk's events are
391 /// concatenated in arrival order.
392 ///
393 /// # Errors
394 ///
395 /// [`Error::ControllerStopped`], any connect/transport error, or
396 /// [`Error::InteractionModel`] if a response chunk cannot be parsed.
397 pub async fn read_events(
398 &self,
399 paths: &[EventPath],
400 filters: &[EventFilter],
401 ) -> Result<Vec<EventReport>, Error> {
402 let req = build_read_request_full(&[], paths, filters);
403 let chunks = self.round_trip_chunked(req).await?;
404 let mut events = Vec::new();
405 for chunk in chunks {
406 events.extend(chunk.events);
407 }
408 Ok(events)
409 }
410
411 /// Run a write/invoke `Action` through the actor: the actor consults the
412 /// learned timed-cache (skips the plain attempt for known-timed paths) and
413 /// transparently retries timed on a `NEEDS_TIMED_INTERACTION` rejection.
414 /// Returns the final response bytes.
415 ///
416 /// `timed_payload` is a *builder*: the timed variant is encoded only if the
417 /// actor actually needs it (cache hit or `0xc6` escalation), so the common
418 /// plain path pays for one encode, not two.
419 async fn action(
420 &self,
421 opcode: u8,
422 plain_payload: Vec<u8>,
423 timed_payload: crate::actor::TimedPayload,
424 keys: Vec<(u32, u32)>,
425 ) -> Result<Vec<u8>, Error> {
426 let (reply, rx) = oneshot::channel();
427 self.tx
428 .send(Command::Action {
429 node_id: self.node_id,
430 opcode,
431 plain_payload,
432 timed_payload,
433 keys,
434 timeout_ms: TIMED_DEFAULT_MS,
435 reply,
436 })
437 .await
438 .map_err(|_| Error::ControllerStopped)?;
439 rx.await.map_err(|_| Error::ControllerStopped)?
440 }
441
442 /// Write attributes. Each `Value` is TLV-encoded into the write payload.
443 /// Returns the per-path statuses the device reported.
444 ///
445 /// Timed writes are handled transparently: if the device rejects the write
446 /// with `NEEDS_TIMED_INTERACTION`, the controller retries it as a timed
447 /// interaction and remembers the path so later writes skip the wasted attempt.
448 /// Use [`write_timed`](Self::write_timed) to force the timed path explicitly.
449 ///
450 /// # Errors
451 ///
452 /// As [`Self::read`], plus [`Error::Codec`] if a value fails to encode.
453 pub async fn write(
454 &self,
455 writes: &[(AttributePath, Value)],
456 ) -> Result<Vec<(AttributePath, ImStatus)>, Error> {
457 let mut reqs = Vec::with_capacity(writes.len());
458 for (path, value) in writes {
459 reqs.push(AttributeWriteRequest {
460 path: *path,
461 value_tlv: value_to_tlv(value)?,
462 });
463 }
464 let keys = writes
465 .iter()
466 .map(|(p, _)| (p.cluster, p.attribute))
467 .collect();
468 let plain = build_write_request(&reqs);
469 let resp = self
470 .action(
471 OP_WRITE_REQUEST,
472 plain,
473 Box::new(move || build_write_request_timed(&reqs)),
474 keys,
475 )
476 .await?;
477 Ok(parse_write_response(&resp)?)
478 }
479
480 /// Like [`write`](Self::write) but always performs a **timed** interaction:
481 /// a `TimedRequest` precedes the write (required by some attributes, e.g.
482 /// certain `DoorLock` settings). `timeout_ms` defaults to [`TIMED_DEFAULT_MS`].
483 ///
484 /// Plain [`write`](Self::write) already auto-upgrades to timed on a
485 /// `NEEDS_TIMED_INTERACTION` rejection; use this when you want to force the
486 /// timed path explicitly (e.g. to avoid the first wasted round-trip, or for
487 /// testing).
488 ///
489 /// # Errors
490 ///
491 /// As [`Self::write`].
492 pub async fn write_timed(
493 &self,
494 writes: &[(AttributePath, Value)],
495 timeout_ms: Option<u16>,
496 ) -> Result<Vec<(AttributePath, ImStatus)>, Error> {
497 let mut reqs = Vec::with_capacity(writes.len());
498 for (path, value) in writes {
499 reqs.push(AttributeWriteRequest {
500 path: *path,
501 value_tlv: value_to_tlv(value)?,
502 });
503 }
504 let payload = build_write_request_timed(&reqs);
505 let resp = self
506 .round_trip_timed(
507 timeout_ms.unwrap_or(TIMED_DEFAULT_MS),
508 OP_WRITE_REQUEST,
509 payload,
510 )
511 .await?;
512 Ok(parse_write_response(&resp)?)
513 }
514
515 /// Invoke a command with raw `Value` fields (TLV-encoded into the payload).
516 ///
517 /// # Errors
518 ///
519 /// As [`Self::read`], plus [`Error::Codec`] if the fields fail to encode
520 /// or the response fields cannot be decoded.
521 pub async fn invoke(&self, path: CommandPath, fields: Value) -> Result<InvokeResult, Error> {
522 self.invoke_tlv(path, value_to_tlv(&fields)?).await
523 }
524
525 /// Invoke a command with **pre-encoded** TLV command fields — e.g. the
526 /// `Vec<u8>` returned by
527 /// `matter_clusters::gen::<cluster>::encode_<command>()` — passed straight
528 /// into the wire payload, avoiding a decode-then-re-encode round trip
529 /// through [`Value`].
530 ///
531 /// `fields_tlv` must be the TLV-encoded command fields structure (an
532 /// anonymous-tagged struct), exactly what the generated `encode_*` helpers
533 /// produce; pass the empty-structure encoding for a no-field command.
534 ///
535 /// # Errors
536 ///
537 /// As [`Self::read`], plus [`Error::Codec`] if the response fields cannot be
538 /// decoded.
539 pub async fn invoke_tlv(
540 &self,
541 path: CommandPath,
542 fields_tlv: Vec<u8>,
543 ) -> Result<InvokeResult, Error> {
544 let plain = build_invoke_request(path, &fields_tlv);
545 let resp = self
546 .action(
547 OP_INVOKE_REQUEST,
548 plain,
549 Box::new(move || build_invoke_request_timed(path, &fields_tlv)),
550 vec![(path.cluster, path.command)],
551 )
552 .await?;
553 match parse_invoke_response(&resp)? {
554 InvokeResponse::Status(s) => Ok(InvokeResult::Status(s)),
555 InvokeResponse::Command { path, fields_tlv } => Ok(InvokeResult::Data {
556 path,
557 fields: tlv_to_value(&fields_tlv)?,
558 }),
559 }
560 }
561
562 /// Like [`invoke`](Self::invoke) but always performs a **timed** interaction
563 /// (a `TimedRequest` precedes the command — required by some commands, e.g.
564 /// `DoorLock` lock/unlock). `timeout_ms` defaults to [`TIMED_DEFAULT_MS`].
565 ///
566 /// Plain [`invoke`](Self::invoke) already auto-upgrades to timed on a
567 /// `NEEDS_TIMED_INTERACTION` rejection; use this to force the timed path.
568 ///
569 /// # Errors
570 ///
571 /// As [`Self::invoke`].
572 pub async fn invoke_timed(
573 &self,
574 path: CommandPath,
575 fields: Value,
576 timeout_ms: Option<u16>,
577 ) -> Result<InvokeResult, Error> {
578 self.invoke_timed_tlv(path, value_to_tlv(&fields)?, timeout_ms)
579 .await
580 }
581
582 /// Like [`invoke_tlv`](Self::invoke_tlv) (pre-encoded TLV fields) but always
583 /// performs a **timed** interaction, mirroring
584 /// [`invoke_timed`](Self::invoke_timed). `timeout_ms` defaults to
585 /// [`TIMED_DEFAULT_MS`].
586 ///
587 /// # Errors
588 ///
589 /// As [`Self::invoke_tlv`].
590 pub async fn invoke_timed_tlv(
591 &self,
592 path: CommandPath,
593 fields_tlv: Vec<u8>,
594 timeout_ms: Option<u16>,
595 ) -> Result<InvokeResult, Error> {
596 let payload = build_invoke_request_timed(path, &fields_tlv);
597 let resp = self
598 .round_trip_timed(
599 timeout_ms.unwrap_or(TIMED_DEFAULT_MS),
600 OP_INVOKE_REQUEST,
601 payload,
602 )
603 .await?;
604 match parse_invoke_response(&resp)? {
605 InvokeResponse::Status(s) => Ok(InvokeResult::Status(s)),
606 InvokeResponse::Command { path, fields_tlv } => Ok(InvokeResult::Data {
607 path,
608 fields: tlv_to_value(&fields_tlv)?,
609 }),
610 }
611 }
612
613 /// Trigger `AnnounceOTAProvider` on this device's
614 /// `OtaSoftwareUpdateRequestor` (0x002A) cluster — telling the device that
615 /// *we* (`provider_node_id`) are an OTA Provider it may query for firmware.
616 /// Sent as a `SimpleAnnouncement` (the device decides when to act): it
617 /// resolves us via operational mDNS, opens a CASE session to us, and invokes
618 /// `QueryImage`.
619 ///
620 /// `provider_node_id` is our own operational node id; `vendor_id` is our
621 /// vendor id; `endpoint` is the endpoint **on us** that hosts the
622 /// `OtaSoftwareUpdateProvider` (0x0029) cluster. The command itself is
623 /// invoked on the device's endpoint 0.
624 ///
625 /// This only fires the announcement — the provider-server half (serving the
626 /// image over BDX) is a separate piece.
627 ///
628 /// # Errors
629 ///
630 /// Returns [`Error::InteractionModel`] if the invoke fails to build or parse,
631 /// or [`Error::Operational`] if the device rejects the command with a
632 /// non-success IM status or answers with an unexpected response command.
633 #[cfg(feature = "ota")]
634 pub async fn announce_ota_provider(
635 &self,
636 provider_node_id: u64,
637 vendor_id: u16,
638 endpoint: u16,
639 ) -> Result<(), Error> {
640 use matter_clusters::gen::ota_software_update_requestor::{
641 command_id::ANNOUNCE_OTA_PROVIDER, encode_announce_ota_provider,
642 AnnouncementReasonEnum, CLUSTER_ID,
643 };
644 let fields_tlv = encode_announce_ota_provider(
645 provider_node_id,
646 vendor_id,
647 AnnouncementReasonEnum::SimpleAnnouncement,
648 None,
649 endpoint,
650 );
651 let fields = tlv_to_value(&fields_tlv)?;
652 let path = CommandPath {
653 endpoint: 0,
654 cluster: CLUSTER_ID,
655 command: ANNOUNCE_OTA_PROVIDER,
656 };
657 match self.invoke(path, fields).await? {
658 InvokeResult::Status(ImStatus::Success) => Ok(()),
659 InvokeResult::Status(ImStatus::Failure(code)) => Err(Error::Operational(format!(
660 "AnnounceOTAProvider rejected (IM status {code:#04x})"
661 ))),
662 InvokeResult::Status(_) => Err(Error::Operational(
663 "unrecognised IM status for AnnounceOTAProvider".into(),
664 )),
665 InvokeResult::Data { .. } => Err(Error::Operational(
666 "unexpected response command for AnnounceOTAProvider".into(),
667 )),
668 }
669 }
670
671 /// Set the device's wall-clock via `TimeSynchronization.SetUTCTime`
672 /// (0x0038 cmd 0x00). `utc_us` is microseconds since the Matter epoch
673 /// (2000-01-01 UTC); `granularity` describes its precision.
674 ///
675 /// # Errors
676 ///
677 /// [`Error::Operational`] if the device rejects it (e.g. it already has a
678 /// finer-granularity time), else an interaction error.
679 pub async fn set_utc_time(
680 &self,
681 utc_us: u64,
682 granularity: TimeGranularity,
683 ) -> Result<(), Error> {
684 let fields = Value::Structure(vec![
685 (Tag::Context(0), Value::Uint(utc_us)),
686 (Tag::Context(1), Value::Uint(u64::from(granularity.to_u8()))),
687 ]);
688 let path = CommandPath {
689 endpoint: 0,
690 cluster: 0x0038,
691 command: 0x00,
692 };
693 match self.invoke(path, fields).await? {
694 InvokeResult::Status(ImStatus::Success) => Ok(()),
695 InvokeResult::Status(ImStatus::Failure(code)) => Err(Error::Operational(format!(
696 "SetUTCTime rejected (IM status {code:#04x})"
697 ))),
698 InvokeResult::Status(_) => Err(Error::Operational(
699 "unrecognised IM status for SetUTCTime".into(),
700 )),
701 InvokeResult::Data { .. } => Err(Error::Operational(
702 "unexpected response command for SetUTCTime".into(),
703 )),
704 }
705 }
706
707 /// Set the device's time zone via `SetTimeZone` (0x0038 cmd 0x02). Returns
708 /// the device's `DSTOffsetRequired` flag (whether you must also call
709 /// [`Self::set_dst_offset`]).
710 ///
711 /// # Errors
712 ///
713 /// [`Error::Operational`] on device rejection or a malformed response, else
714 /// an interaction error.
715 pub async fn set_time_zone(&self, entries: &[TimeZoneEntry]) -> Result<bool, Error> {
716 let list = entries
717 .iter()
718 .map(|e| {
719 let mut members = vec![
720 (Tag::Context(0), Value::Int(i64::from(e.offset_seconds))),
721 (Tag::Context(1), Value::Uint(e.valid_at_us)),
722 ];
723 if let Some(name) = &e.name {
724 members.push((Tag::Context(2), Value::Utf8(name.clone())));
725 }
726 Value::Structure(members)
727 })
728 .collect();
729 let fields = Value::Structure(vec![(Tag::Context(0), Value::Array(list))]);
730 let path = CommandPath {
731 endpoint: 0,
732 cluster: 0x0038,
733 command: 0x02,
734 };
735 match self.invoke(path, fields).await? {
736 InvokeResult::Data { fields, .. } => dst_required_from_response(&fields),
737 InvokeResult::Status(ImStatus::Failure(code)) => Err(Error::Operational(format!(
738 "SetTimeZone rejected (IM status {code:#04x})"
739 ))),
740 InvokeResult::Status(_) => Err(Error::Operational(
741 "SetTimeZone returned status, expected response".into(),
742 )),
743 }
744 }
745
746 /// Set the device's DST offsets via `SetDSTOffset` (0x0038 cmd 0x04).
747 ///
748 /// # Errors
749 ///
750 /// [`Error::Operational`] on device rejection, else an interaction error.
751 pub async fn set_dst_offset(&self, entries: &[DstOffsetEntry]) -> Result<(), Error> {
752 let list = entries
753 .iter()
754 .map(|e| {
755 Value::Structure(vec![
756 (Tag::Context(0), Value::Int(i64::from(e.offset_seconds))),
757 (Tag::Context(1), Value::Uint(e.valid_starting_us)),
758 (
759 Tag::Context(2),
760 e.valid_until_us.map_or(Value::Null, Value::Uint),
761 ),
762 ])
763 })
764 .collect();
765 let fields = Value::Structure(vec![(Tag::Context(0), Value::Array(list))]);
766 let path = CommandPath {
767 endpoint: 0,
768 cluster: 0x0038,
769 command: 0x04,
770 };
771 match self.invoke(path, fields).await? {
772 InvokeResult::Status(ImStatus::Success) => Ok(()),
773 InvokeResult::Status(ImStatus::Failure(code)) => Err(Error::Operational(format!(
774 "SetDSTOffset rejected (IM status {code:#04x})"
775 ))),
776 InvokeResult::Status(_) => Err(Error::Operational(
777 "unrecognised IM status for SetDSTOffset".into(),
778 )),
779 InvokeResult::Data { .. } => Err(Error::Operational(
780 "unexpected response command for SetDSTOffset".into(),
781 )),
782 }
783 }
784
785 /// Read the device's current `UTCTime` (0x0038 attr 0x00). `None` if the
786 /// device reports a null time (clock not set).
787 ///
788 /// # Errors
789 ///
790 /// An interaction error if the read fails.
791 pub async fn read_utc_time(&self) -> Result<Option<u64>, Error> {
792 let reports = self.read(&[ReadPath::concrete(0, 0x0038, 0x0000)]).await?;
793 Ok(reports.iter().find_map(|(p, v)| {
794 if p.attribute == 0x0000 {
795 if let Value::Uint(u) = v {
796 return Some(*u);
797 }
798 }
799 None
800 }))
801 }
802
803 /// Read this device's `Binding` list on `endpoint` (0x001E attr 0x0000) —
804 /// the targets it is wired to send to.
805 ///
806 /// # Errors
807 ///
808 /// An interaction error if the read fails.
809 pub async fn read_binding(
810 &self,
811 endpoint: u16,
812 ) -> Result<Vec<crate::binding::BindingTarget>, Error> {
813 let reports = self
814 .read(&[ReadPath::concrete(
815 endpoint,
816 crate::binding::BINDING_CLUSTER,
817 crate::binding::ATTR_BINDING,
818 )])
819 .await?;
820 Ok(crate::binding::parse_bindings(&reports))
821 }
822
823 /// Replace this device's `Binding` list on `endpoint` with `targets` (a
824 /// full-list, fabric-scoped write). Returns the per-path device status.
825 ///
826 /// # Errors
827 ///
828 /// An interaction error, or a per-path device status.
829 pub async fn write_binding(
830 &self,
831 endpoint: u16,
832 targets: &[crate::binding::BindingTarget],
833 ) -> Result<Vec<(AttributePath, ImStatus)>, Error> {
834 let path = AttributePath {
835 endpoint,
836 cluster: crate::binding::BINDING_CLUSTER,
837 attribute: crate::binding::ATTR_BINDING,
838 };
839 let element_tlvs: Vec<Vec<u8>> = targets
840 .iter()
841 .map(|t| value_to_tlv(&crate::binding::binding_target_value(t)))
842 .collect::<Result<_, _>>()?;
843 let mut chunks = build_list_write_chunks(path, &element_tlvs, WRITE_CHUNK_BUDGET, false);
844 if chunks.len() == 1 {
845 // Single message: reuse the plain Action path. The timed retry —
846 // if the device demands timed — re-sends identical bytes (safe for
847 // a full-list replace), so the builder just hands back the chunk.
848 let chunk = chunks.swap_remove(0);
849 let plain = chunk.clone();
850 let resp = self
851 .action(
852 OP_WRITE_REQUEST,
853 plain,
854 Box::new(move || chunk),
855 vec![(path.cluster, path.attribute)],
856 )
857 .await?;
858 Ok(parse_write_response(&resp)?)
859 } else {
860 self.chunked_write(chunks).await
861 }
862 }
863
864 /// Register the controller as a check-in client with this ICD
865 /// (`IcdManagement.RegisterClient`, 0x0046 cmd 0x00). Generates a fresh
866 /// 16-byte symmetric key, registers our commissioner node id as the
867 /// `CheckInNodeID`, persists an [`IcdRegistration`](crate::IcdRegistration)
868 /// (so the check-in listener can later verify this device's Check-Ins), and
869 /// returns it. `monitored_subject` is the subject the ICD watches for us
870 /// (usually our node id).
871 ///
872 /// # Errors
873 ///
874 /// [`Error::Operational`] on RNG failure or device rejection; an interaction
875 /// error; or a persistence error.
876 pub async fn register_icd_client(
877 &self,
878 monitored_subject: u64,
879 client_type: crate::icd::IcdClientType,
880 ) -> Result<crate::icd::IcdRegistration, Error> {
881 let check_in_node_id = self.commissioner_node_id().await?;
882 let mut key = [0u8; 16];
883 matter_crypto::random_bytes(&mut key)
884 .map_err(|e| Error::Operational(format!("ICD key generation failed: {e}")))?;
885 let fields = crate::icd::register_client_fields(
886 check_in_node_id,
887 monitored_subject,
888 &key,
889 client_type,
890 );
891 let path = CommandPath {
892 endpoint: 0,
893 cluster: crate::icd::ICD_MANAGEMENT_CLUSTER,
894 command: 0x00,
895 };
896 let icd_counter = match self.invoke(path, fields).await? {
897 InvokeResult::Data { fields, .. } => icd_counter_from_response(&fields)?,
898 InvokeResult::Status(ImStatus::Failure(code)) => {
899 return Err(Error::Operational(format!(
900 "RegisterClient rejected (IM status {code:#04x})"
901 )))
902 }
903 InvokeResult::Status(_) => {
904 return Err(Error::Operational(
905 "RegisterClient returned status, expected RegisterClientResponse".into(),
906 ))
907 }
908 };
909 let registration = crate::icd::IcdRegistration::new(
910 self.node_id,
911 check_in_node_id,
912 monitored_subject,
913 key,
914 icd_counter,
915 );
916 let (reply, rx) = oneshot::channel();
917 self.tx
918 .send(Command::PersistIcdRegistration {
919 registration: registration.clone(),
920 reply,
921 })
922 .await
923 .map_err(|_| Error::ControllerStopped)?;
924 rx.await.map_err(|_| Error::ControllerStopped)??;
925 Ok(registration)
926 }
927
928 /// Unregister the controller from this ICD (`UnregisterClient`, cmd 0x02),
929 /// using our commissioner node id as the `CheckInNodeID`.
930 ///
931 /// # Errors
932 ///
933 /// [`Error::Operational`] on device rejection, else an interaction error.
934 pub async fn unregister_icd_client(&self) -> Result<(), Error> {
935 let check_in_node_id = self.commissioner_node_id().await?;
936 let fields = Value::Structure(vec![(Tag::Context(0), Value::Uint(check_in_node_id))]);
937 let path = CommandPath {
938 endpoint: 0,
939 cluster: crate::icd::ICD_MANAGEMENT_CLUSTER,
940 command: 0x02,
941 };
942 match self.invoke(path, fields).await? {
943 InvokeResult::Status(ImStatus::Success) => Ok(()),
944 InvokeResult::Status(ImStatus::Failure(code)) => Err(Error::Operational(format!(
945 "UnregisterClient rejected (IM status {code:#04x})"
946 ))),
947 InvokeResult::Status(_) => Err(Error::Operational(
948 "unrecognised IM status for UnregisterClient".into(),
949 )),
950 InvokeResult::Data { .. } => Err(Error::Operational(
951 "unexpected response command for UnregisterClient".into(),
952 )),
953 }
954 }
955
956 /// Ask this ICD to stay in active mode for at least `stay_active_ms`
957 /// (`StayActiveRequest`, cmd 0x03). Returns the device's promised active
958 /// duration (ms).
959 ///
960 /// # Errors
961 ///
962 /// [`Error::Operational`] on device rejection or a malformed response, else
963 /// an interaction error.
964 pub async fn stay_active_request(&self, stay_active_ms: u32) -> Result<u32, Error> {
965 let fields = Value::Structure(vec![(
966 Tag::Context(0),
967 Value::Uint(u64::from(stay_active_ms)),
968 )]);
969 let path = CommandPath {
970 endpoint: 0,
971 cluster: crate::icd::ICD_MANAGEMENT_CLUSTER,
972 command: 0x03,
973 };
974 match self.invoke(path, fields).await? {
975 InvokeResult::Data { fields, .. } => promised_duration_from_response(&fields),
976 InvokeResult::Status(ImStatus::Failure(code)) => Err(Error::Operational(format!(
977 "StayActiveRequest rejected (IM status {code:#04x})"
978 ))),
979 InvokeResult::Status(_) => Err(Error::Operational(
980 "StayActiveRequest returned status, expected response".into(),
981 )),
982 }
983 }
984
985 /// Read `AdministratorCommissioning` `WindowStatus`, `AdminFabricIndex`, and
986 /// `AdminVendorId` from endpoint 0. Returns a snapshot of the current
987 /// commissioning-window state.
988 ///
989 /// # Errors
990 ///
991 /// An interaction error if the read fails.
992 pub async fn commissioning_window_status(&self) -> Result<crate::admin::WindowStatus, Error> {
993 use crate::admin::{
994 ADMIN_COMMISSIONING_CLUSTER, ATTR_ADMIN_FABRIC_INDEX, ATTR_ADMIN_VENDOR_ID,
995 ATTR_WINDOW_STATUS,
996 };
997 let paths = [
998 ReadPath::concrete(0, ADMIN_COMMISSIONING_CLUSTER, ATTR_WINDOW_STATUS),
999 ReadPath::concrete(0, ADMIN_COMMISSIONING_CLUSTER, ATTR_ADMIN_FABRIC_INDEX),
1000 ReadPath::concrete(0, ADMIN_COMMISSIONING_CLUSTER, ATTR_ADMIN_VENDOR_ID),
1001 ];
1002 let reports = self.read(&paths).await?;
1003 Ok(crate::admin::parse_window_status(&reports))
1004 }
1005
1006 /// Read the device's `Fabrics` list (every fabric it is commissioned onto).
1007 ///
1008 /// # Errors
1009 ///
1010 /// An interaction error if the read fails.
1011 pub async fn list_fabrics(&self) -> Result<Vec<crate::opcreds::FabricDescriptor>, Error> {
1012 let paths = [ReadPath::concrete(
1013 0,
1014 crate::opcreds::OPERATIONAL_CREDENTIALS_CLUSTER,
1015 crate::opcreds::ATTR_FABRICS,
1016 )];
1017 let reports = self.read(&paths).await?;
1018 Ok(crate::opcreds::parse_fabrics(&reports))
1019 }
1020
1021 /// Write the device's `AccessControl.Acl` list to exactly `entries`.
1022 ///
1023 /// Refuses (before sending) any list that would strip our own administrative
1024 /// access ([`Error::AclWouldLockOut`]). Small lists go in one
1025 /// `WriteRequestMessage` (byte-identical to a normal write); larger lists are
1026 /// chunked (`ReplaceAll`+`AppendItem`) without ever sending an empty `ReplaceAll`.
1027 ///
1028 /// ACL writes are NOT timed (the spec does not require `TimedRequest` for
1029 /// `AccessControl.Acl`); however, if the device unexpectedly rejects the write
1030 /// with `NEEDS_TIMED_INTERACTION` the controller's timed-auto-upgrade will
1031 /// transparently retry on the single-chunk path (the same bytes are safe to
1032 /// re-send because the whole list is idempotent). The multi-chunk path fails
1033 /// cleanly on a `0xc6` rejection (the `ChunkedWrite` pending does not carry
1034 /// a `timed_payload`).
1035 ///
1036 /// # Errors
1037 ///
1038 /// [`Error::AclWouldLockOut`] if `entries` contains no Administer/CASE entry
1039 /// covering our commissioner node id; no bytes are sent to the device in that
1040 /// case. Otherwise returns an interaction error or a per-path device status.
1041 pub async fn write_acl(
1042 &self,
1043 entries: &[crate::acl::AclEntry],
1044 ) -> Result<Vec<(AttributePath, ImStatus)>, Error> {
1045 self.write_acl_with_budget(entries, WRITE_CHUNK_BUDGET)
1046 .await
1047 }
1048
1049 /// Inner implementation of [`write_acl`](Node::write_acl) with an injectable
1050 /// per-chunk byte budget.
1051 ///
1052 /// The lockout guard runs before any bytes are sent to the device, regardless
1053 /// of the budget. `budget` controls the `build_list_write_chunks` split point;
1054 /// the production verb always passes [`WRITE_CHUNK_BUDGET`] (800 bytes).
1055 ///
1056 /// Exposed as `pub(crate)` so tests can force a small budget (e.g. 40 bytes)
1057 /// to exercise the multi-chunk dispatch branch through `write_acl` itself
1058 /// rather than calling `chunked_write` directly.
1059 ///
1060 /// # Errors
1061 ///
1062 /// [`Error::AclWouldLockOut`] if `entries` contains no Administer/CASE entry
1063 /// covering our commissioner node id; no bytes are sent to the device in that
1064 /// case. Otherwise returns an interaction error or a per-path device status.
1065 pub(crate) async fn write_acl_with_budget(
1066 &self,
1067 entries: &[crate::acl::AclEntry],
1068 budget: usize,
1069 ) -> Result<Vec<(AttributePath, ImStatus)>, Error> {
1070 // Lockout guard MUST run before any network I/O.
1071 let our = self.commissioner_node_id().await?;
1072 if !crate::acl::acl_retains_admin(entries, our) {
1073 return Err(Error::AclWouldLockOut);
1074 }
1075 let path = AttributePath {
1076 endpoint: 0,
1077 cluster: crate::acl::ACCESS_CONTROL_CLUSTER,
1078 attribute: crate::acl::ATTR_ACL,
1079 };
1080 let element_tlvs: Vec<Vec<u8>> = entries
1081 .iter()
1082 .map(|e| value_to_tlv(&crate::acl::acl_entry_value(e)))
1083 .collect::<Result<_, _>>()?;
1084 let mut chunks = build_list_write_chunks(path, &element_tlvs, budget, false);
1085 if chunks.len() == 1 {
1086 // Single message: reuse the plain Action path (byte-identical to a
1087 // normal write, 0xc6 auto-upgrade intact). The timed builder hands
1088 // back the same chunk, so the retry — if the device demands timed —
1089 // re-sends identical bytes (safe for a full-list replace).
1090 let chunk = chunks.swap_remove(0);
1091 let plain = chunk.clone();
1092 let resp = self
1093 .action(
1094 OP_WRITE_REQUEST,
1095 plain,
1096 Box::new(move || chunk),
1097 vec![(path.cluster, path.attribute)],
1098 )
1099 .await?;
1100 Ok(parse_write_response(&resp)?)
1101 } else {
1102 self.chunked_write(chunks).await
1103 }
1104 }
1105
1106 /// Read the device's `AccessControl.Acl` list (the ACL entries on this fabric).
1107 ///
1108 /// # Errors
1109 ///
1110 /// An interaction error if the read fails.
1111 pub async fn read_acl(&self) -> Result<Vec<crate::acl::AclEntry>, Error> {
1112 let paths = [ReadPath::concrete(
1113 0,
1114 crate::acl::ACCESS_CONTROL_CLUSTER,
1115 crate::acl::ATTR_ACL,
1116 )];
1117 let reports = self.read(&paths).await?;
1118 Ok(crate::acl::parse_acl(&reports))
1119 }
1120
1121 /// Open an enhanced commissioning window using **caller-supplied** secrets.
1122 ///
1123 /// Internal seam behind the public [`Node::open_commissioning_window`], which
1124 /// generates the secrets. Kept `pub(crate)` rather than exposing a 7-argument
1125 /// public signature at 1.0; if a consumer ever needs caller-supplied secrets,
1126 /// re-expose it through an options struct (extend
1127 /// [`OpenWindowOpts`](crate::admin::OpenWindowOpts)).
1128 ///
1129 /// Computes the PAKE passcode verifier from `passcode`/`salt`/`iterations`,
1130 /// invokes `OpenCommissioningWindow` (a **timed** invoke — `AdminComm` requires
1131 /// it), and returns the onboarding payload.
1132 ///
1133 /// # Errors
1134 ///
1135 /// Returns [`Error::CommissioningWindowRejected`] if the device rejects the
1136 /// command, or a crypto/interaction error.
1137 #[allow(clippy::too_many_arguments)]
1138 pub(crate) async fn open_commissioning_window_with(
1139 &self,
1140 timeout_s: u16,
1141 passcode: u32,
1142 salt: &[u8],
1143 discriminator: u16,
1144 iterations: u32,
1145 vendor_id: Option<u16>,
1146 product_id: Option<u16>,
1147 ) -> Result<crate::admin::CommissioningWindow, Error> {
1148 let verifier = matter_crypto::pake_passcode_verifier(passcode, salt, iterations)
1149 .map_err(|e| Error::Operational(format!("verifier: {e}")))?;
1150 let fields =
1151 crate::admin::open_window_fields(timeout_s, &verifier, discriminator, iterations, salt);
1152 let path = CommandPath {
1153 endpoint: 0,
1154 cluster: crate::admin::ADMIN_COMMISSIONING_CLUSTER,
1155 command: crate::admin::CMD_OPEN_COMMISSIONING_WINDOW,
1156 };
1157 self.admin_timed_command(path, fields).await?;
1158 let (manual_code, qr_code) =
1159 crate::admin::onboarding_payload(passcode, discriminator, vendor_id, product_id)?;
1160 Ok(crate::admin::CommissioningWindow {
1161 passcode,
1162 discriminator,
1163 iterations,
1164 salt: salt.to_vec(),
1165 manual_code,
1166 qr_code,
1167 })
1168 }
1169
1170 /// Open an enhanced commissioning window so a second admin can commission
1171 /// this device onto its own fabric. Generates a fresh passcode/salt/
1172 /// discriminator, computes the PAKE verifier, and returns the onboarding
1173 /// payload (manual pairing code, plus QR when `opts.vendor_id`/`product_id`
1174 /// are set). The `AdminComm` command is sent as a timed invoke.
1175 ///
1176 /// # Errors
1177 /// Returns [`Error::CommissioningWindowRejected`] if the device rejects it,
1178 /// or a crypto/RNG/interaction error.
1179 pub async fn open_commissioning_window(
1180 &self,
1181 opts: crate::admin::OpenWindowOpts,
1182 ) -> Result<crate::admin::CommissioningWindow, Error> {
1183 let (passcode, salt, discriminator) = crate::admin::random_window_secrets()?;
1184 self.open_commissioning_window_with(
1185 opts.timeout_s,
1186 passcode,
1187 &salt,
1188 discriminator,
1189 opts.iterations,
1190 opts.vendor_id,
1191 opts.product_id,
1192 )
1193 .await
1194 }
1195
1196 /// Open a *basic* commissioning window (reuses the device's original
1197 /// passcode — no new onboarding payload). Timed invoke.
1198 ///
1199 /// # Errors
1200 /// [`Error::CommissioningWindowRejected`] on device rejection, else an
1201 /// interaction error.
1202 pub async fn open_basic_commissioning_window(&self, timeout_s: u16) -> Result<(), Error> {
1203 let fields = matter_codec::Value::Structure(vec![(
1204 matter_codec::Tag::Context(0),
1205 matter_codec::Value::Uint(u64::from(timeout_s)),
1206 )]);
1207 let path = CommandPath {
1208 endpoint: 0,
1209 cluster: crate::admin::ADMIN_COMMISSIONING_CLUSTER,
1210 command: crate::admin::CMD_OPEN_BASIC_COMMISSIONING_WINDOW,
1211 };
1212 self.admin_timed_command(path, fields).await
1213 }
1214
1215 /// Revoke any open commissioning window. Timed invoke. Returns `Ok(())`
1216 /// even if no window was open (the device reports `WindowNotOpen`, which is
1217 /// surfaced as [`Error::CommissioningWindowRejected`] only on a hard IM
1218 /// failure).
1219 ///
1220 /// # Errors
1221 /// [`Error::CommissioningWindowRejected`] on device rejection.
1222 pub async fn revoke_commissioning(&self) -> Result<(), Error> {
1223 let fields = matter_codec::Value::Structure(vec![]);
1224 let path = CommandPath {
1225 endpoint: 0,
1226 cluster: crate::admin::ADMIN_COMMISSIONING_CLUSTER,
1227 command: crate::admin::CMD_REVOKE_COMMISSIONING,
1228 };
1229 self.admin_timed_command(path, fields).await
1230 }
1231
1232 /// Shared helper: timed-invoke an `AdminComm` command expecting a bare
1233 /// success status. Maps `Success` to `Ok(())`, `Failure(code)` to
1234 /// [`Error::CommissioningWindowRejected`], any other `Status(_)` variant
1235 /// (catch-all for `#[non_exhaustive]` future codes) to an operational
1236 /// error, and any response command to an operational error.
1237 async fn admin_timed_command(
1238 &self,
1239 path: CommandPath,
1240 fields: matter_codec::Value,
1241 ) -> Result<(), Error> {
1242 match self.invoke_timed(path, fields, None).await? {
1243 InvokeResult::Status(ImStatus::Success) => Ok(()),
1244 InvokeResult::Status(ImStatus::Failure(code)) => {
1245 Err(Error::CommissioningWindowRejected(code))
1246 }
1247 InvokeResult::Status(_) => Err(Error::Operational(
1248 "unrecognised IM status for admin command".into(),
1249 )),
1250 InvokeResult::Data { .. } => {
1251 Err(Error::Operational("unexpected response command".into()))
1252 }
1253 }
1254 }
1255
1256 /// Remove a fabric from the device by its `fabric_index`.
1257 ///
1258 /// Reads `CurrentFabricIndex` first and refuses to remove our OWN fabric
1259 /// (that would sever this CASE session and orphan persisted state) with
1260 /// [`Error::WouldRemoveSelf`]. There is intentionally no `force` override.
1261 ///
1262 /// # Errors
1263 /// [`Error::WouldRemoveSelf`] if `fabric_index` is our own;
1264 /// [`Error::Operational`] if the device does not return a readable
1265 /// `CurrentFabricIndex` — the call fails without invoking `RemoveFabric` in
1266 /// that case (fail-closed on a destructive operation);
1267 /// [`Error::OperationalCredentialsRejected`] if the device rejects it (e.g.
1268 /// 7 `InvalidFabricIndex`); else an interaction error.
1269 pub async fn remove_fabric(&self, fabric_index: u8) -> Result<(), Error> {
1270 // Self-protection: CurrentFabricIndex over our session is OUR fabric's
1271 // index here. Must check BEFORE invoking — this is a destructive op.
1272 let cur = self
1273 .read(&[ReadPath::concrete(
1274 0,
1275 crate::opcreds::OPERATIONAL_CREDENTIALS_CLUSTER,
1276 crate::opcreds::ATTR_CURRENT_FABRIC_INDEX,
1277 )])
1278 .await?;
1279 // Fail CLOSED: if we cannot read CurrentFabricIndex we refuse to
1280 // proceed. The equality guard `== Some(fabric_index)` would silently
1281 // fall through when `parse_current_fabric_index` returns `None`,
1282 // allowing RemoveFabric on an unverified index.
1283 let cur_idx = crate::opcreds::parse_current_fabric_index(&cur).ok_or_else(|| {
1284 Error::Operational(
1285 "could not read CurrentFabricIndex; refusing remove_fabric for safety".into(),
1286 )
1287 })?;
1288 if cur_idx == fabric_index {
1289 return Err(Error::WouldRemoveSelf);
1290 }
1291 let fields = Value::Structure(vec![(
1292 matter_codec::Tag::Context(0),
1293 Value::Uint(u64::from(fabric_index)),
1294 )]);
1295 let path = CommandPath {
1296 endpoint: 0,
1297 cluster: crate::opcreds::OPERATIONAL_CREDENTIALS_CLUSTER,
1298 command: crate::opcreds::CMD_REMOVE_FABRIC,
1299 };
1300 match self.invoke(path, fields).await? {
1301 InvokeResult::Data { fields, .. } => {
1302 let status = crate::opcreds::parse_noc_response(&fields);
1303 crate::opcreds::noc_status_to_result(&status)
1304 }
1305 InvokeResult::Status(ImStatus::Success) => Ok(()),
1306 InvokeResult::Status(ImStatus::Failure(code)) => {
1307 Err(Error::OperationalCredentialsRejected(code))
1308 }
1309 InvokeResult::Status(_) => Err(Error::Operational(
1310 "unexpected status for RemoveFabric".into(),
1311 )),
1312 }
1313 }
1314
1315 /// Update the label of OUR fabric on the device (`UpdateFabricLabel` acts on
1316 /// the accessing fabric; there is no index argument).
1317 ///
1318 /// # Errors
1319 /// [`Error::OperationalCredentialsRejected`] if the device rejects it
1320 /// (e.g. 9 `LabelConflict`); else an interaction error.
1321 pub async fn update_fabric_label(&self, label: &str) -> Result<(), Error> {
1322 let fields = Value::Structure(vec![(
1323 matter_codec::Tag::Context(0),
1324 Value::Utf8(label.to_string()),
1325 )]);
1326 let path = CommandPath {
1327 endpoint: 0,
1328 cluster: crate::opcreds::OPERATIONAL_CREDENTIALS_CLUSTER,
1329 command: crate::opcreds::CMD_UPDATE_FABRIC_LABEL,
1330 };
1331 match self.invoke(path, fields).await? {
1332 InvokeResult::Data { fields, .. } => {
1333 let status = crate::opcreds::parse_noc_response(&fields);
1334 crate::opcreds::noc_status_to_result(&status)
1335 }
1336 InvokeResult::Status(ImStatus::Success) => Ok(()),
1337 InvokeResult::Status(ImStatus::Failure(code)) => {
1338 Err(Error::OperationalCredentialsRejected(code))
1339 }
1340 InvokeResult::Status(_) => Err(Error::Operational(
1341 "unexpected status for UpdateFabricLabel".into(),
1342 )),
1343 }
1344 }
1345
1346 /// Add the device endpoint to a group (`Groups.AddGroup`). The endpoint then
1347 /// joins the group's multicast address and accepts group commands.
1348 ///
1349 /// # Errors
1350 ///
1351 /// [`Error::GroupCommandRejected`] on a non-success status; else interaction error.
1352 pub async fn add_group(&self, endpoint: u16, group_id: u16, name: &str) -> Result<(), Error> {
1353 self.group_command(
1354 endpoint,
1355 crate::group::CMD_ADD_GROUP,
1356 crate::group::add_group_fields(group_id, name),
1357 )
1358 .await
1359 }
1360
1361 /// Remove the device endpoint from a group (`Groups.RemoveGroup`).
1362 ///
1363 /// # Errors
1364 ///
1365 /// [`Error::GroupCommandRejected`] on a non-success status; else interaction error.
1366 pub async fn remove_group(&self, endpoint: u16, group_id: u16) -> Result<(), Error> {
1367 self.group_command(
1368 endpoint,
1369 crate::group::CMD_REMOVE_GROUP,
1370 crate::group::remove_group_fields(group_id),
1371 )
1372 .await
1373 }
1374
1375 /// Shared: invoke a `Groups` command and map its response-status to `()`/error.
1376 ///
1377 /// `AddGroup`/`RemoveGroup` both return a response command whose `status` field
1378 /// (context tag 0) is 0 on success or a non-zero `GroupClusterStatus` code on
1379 /// failure. A bare `Success` IM status is also accepted (some devices skip the
1380 /// response command on success); bare `Failure` codes become
1381 /// [`Error::GroupCommandRejected`].
1382 async fn group_command(&self, endpoint: u16, command: u32, fields: Value) -> Result<(), Error> {
1383 let path = CommandPath {
1384 endpoint,
1385 cluster: crate::group::GROUPS_CLUSTER,
1386 command,
1387 };
1388 match self.invoke(path, fields).await? {
1389 InvokeResult::Data { fields, .. } => {
1390 let status = crate::group::parse_group_status(&fields);
1391 if status == 0 {
1392 Ok(())
1393 } else {
1394 Err(Error::GroupCommandRejected(status))
1395 }
1396 }
1397 InvokeResult::Status(ImStatus::Success) => Ok(()),
1398 InvokeResult::Status(ImStatus::Failure(code)) => Err(Error::GroupCommandRejected(code)),
1399 InvokeResult::Status(_) => Err(Error::Operational(
1400 "unexpected status for Groups command".into(),
1401 )),
1402 }
1403 }
1404
1405 /// Provision a group key set on the device via `KeySetWrite`
1406 /// (`GroupKeyManagement` cluster, endpoint 0). The epoch key is the
1407 /// group's symmetric key material. Returns `Ok(())` on a bare
1408 /// `Success` status from the device.
1409 ///
1410 /// `KeySetWrite` is NOT a timed command — the plain `invoke` path is used.
1411 ///
1412 /// # Errors
1413 ///
1414 /// [`Error::GroupCommandRejected`] if the device returns a non-success IM
1415 /// status (e.g. `ResourceExhausted`). An interaction or transport error is
1416 /// surfaced as its corresponding [`Error`] variant.
1417 pub async fn write_group_key_set(&self, set: &crate::group::GroupKeySet) -> Result<(), Error> {
1418 let path = CommandPath {
1419 endpoint: 0,
1420 cluster: crate::group::GROUP_KEY_MANAGEMENT_CLUSTER,
1421 command: crate::group::CMD_KEY_SET_WRITE,
1422 };
1423 match self
1424 .invoke(path, crate::group::key_set_write_fields(set))
1425 .await?
1426 {
1427 InvokeResult::Status(ImStatus::Success) => Ok(()),
1428 InvokeResult::Status(ImStatus::Failure(code)) => Err(Error::GroupCommandRejected(code)),
1429 InvokeResult::Status(_) => Err(Error::Operational(
1430 "unexpected status for KeySetWrite".into(),
1431 )),
1432 InvokeResult::Data { .. } => Err(Error::Operational(
1433 "unexpected response command for KeySetWrite".into(),
1434 )),
1435 }
1436 }
1437
1438 /// Write the device's `GroupKeyMap` list (binds group ids to key sets).
1439 ///
1440 /// Small lists go in one `WriteRequestMessage` (byte-identical to a normal
1441 /// write); larger lists are chunked (`ReplaceAll`+`AppendItem`) without ever
1442 /// sending an empty `ReplaceAll`. There is no lockout guard — `GroupKeyMap`
1443 /// has no self-lock concern unlike `AccessControl.Acl`.
1444 ///
1445 /// `GroupKeyMap` writes are NOT timed (the spec does not require
1446 /// `TimedRequest`); however, if the device unexpectedly rejects the write with
1447 /// `NEEDS_TIMED_INTERACTION` the controller's timed-auto-upgrade will
1448 /// transparently retry on the single-chunk path.
1449 ///
1450 /// # Errors
1451 ///
1452 /// Returns an interaction error or a per-path device status from the device.
1453 pub async fn write_group_key_map(
1454 &self,
1455 entries: &[crate::group::GroupKeyMapEntry],
1456 ) -> Result<Vec<(AttributePath, ImStatus)>, Error> {
1457 let path = AttributePath {
1458 endpoint: 0,
1459 cluster: crate::group::GROUP_KEY_MANAGEMENT_CLUSTER,
1460 attribute: crate::group::ATTR_GROUP_KEY_MAP,
1461 };
1462 let element_tlvs: Vec<Vec<u8>> = entries
1463 .iter()
1464 .map(|e| value_to_tlv(&crate::group::group_key_map_entry_value(*e)))
1465 .collect::<Result<_, _>>()?;
1466 let mut chunks = build_list_write_chunks(path, &element_tlvs, WRITE_CHUNK_BUDGET, false);
1467 if chunks.len() == 1 {
1468 // Single message: reuse the plain Action path (byte-identical to a
1469 // normal write, 0xc6 auto-upgrade intact). The timed builder hands
1470 // back the same chunk, so the retry — if the device demands timed —
1471 // re-sends identical bytes (safe for a full-list replace).
1472 let chunk = chunks.swap_remove(0);
1473 let plain = chunk.clone();
1474 let resp = self
1475 .action(
1476 OP_WRITE_REQUEST,
1477 plain,
1478 Box::new(move || chunk),
1479 vec![(path.cluster, path.attribute)],
1480 )
1481 .await?;
1482 Ok(parse_write_response(&resp)?)
1483 } else {
1484 self.chunked_write(chunks).await
1485 }
1486 }
1487
1488 /// Subscribe to attribute reports for `attrs` and/or event reports for
1489 /// `events` (concrete or wildcard paths) on a **single** subscription. The
1490 /// device sends the priming values/events, then steady-state changes within
1491 /// `[min_interval, max_interval]` seconds. Await
1492 /// [`SubscriptionEvent`](crate::subscription::SubscriptionEvent)s — both
1493 /// `Report` (attributes) and `Event` (events) — via
1494 /// [`Subscription::next`](crate::subscription::Subscription::next).
1495 ///
1496 /// Pass an empty slice for either to subscribe to only the other. The
1497 /// subscription auto-resubscribes transparently on staleness/session loss,
1498 /// re-requesting the same attribute and event paths.
1499 ///
1500 /// # Errors
1501 ///
1502 /// [`Error::ControllerStopped`] if the owning task stopped, or any
1503 /// connect / transport / interaction-model error while establishing the
1504 /// subscription.
1505 pub async fn subscribe(
1506 &self,
1507 attrs: &[ReadPath],
1508 events: &[EventPath],
1509 min_interval: u16,
1510 max_interval: u16,
1511 ) -> Result<crate::subscription::Subscription, Error> {
1512 let (reply, rx) = oneshot::channel();
1513 self.tx
1514 .send(Command::Subscribe {
1515 node_id: self.node_id,
1516 paths: attrs.to_vec(),
1517 event_paths: events.to_vec(),
1518 event_filters: Vec::new(),
1519 min_interval,
1520 max_interval,
1521 reply,
1522 })
1523 .await
1524 .map_err(|_| Error::ControllerStopped)?;
1525 let (receivers, key) = rx.await.map_err(|_| Error::ControllerStopped)??;
1526 Ok(crate::subscription::Subscription {
1527 rx: receivers.report_rx,
1528 ctrl_rx: receivers.ctrl_rx,
1529 tx: self.tx.clone(),
1530 key,
1531 cancelled: false,
1532 })
1533 }
1534}
1535
1536#[cfg(test)]
1537#[allow(clippy::unwrap_used, clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
1538mod tests {
1539 use matter_codec::Value;
1540
1541 use super::{build_invoke_request, value_to_tlv, CommandPath};
1542
1543 /// The whole point of [`Node::invoke_tlv`]: a generated
1544 /// `matter_clusters::gen::*::encode_*()` output can be fed straight in, and
1545 /// the resulting wire `InvokeRequest` is byte-identical to encoding the
1546 /// corresponding [`Value`] and calling [`Node::invoke`]. This proves the
1547 /// triple-hop (encode → decode → re-encode) the raw-TLV path removes is a
1548 /// no-op transform, so both entry points send the same bytes.
1549 #[test]
1550 fn invoke_tlv_matches_invoke_value_wire_payload() {
1551 let path = CommandPath {
1552 endpoint: 1,
1553 cluster: 0x0006,
1554 command: 0x01,
1555 };
1556 // Generated encoder output for OnOff.On (an empty fields struct) — the
1557 // exact `Vec<u8>` a caller would pass to `invoke_tlv`.
1558 let gen_tlv = matter_clusters::gen::on_off::encode_on();
1559 // The equivalent `Value` a caller would otherwise pass to `invoke`.
1560 let via_value = value_to_tlv(&Value::Structure(vec![])).unwrap();
1561 assert_eq!(
1562 gen_tlv, via_value,
1563 "gen encode_on() must equal value_to_tlv(empty struct) — the raw-TLV \
1564 and Value paths carry identical field bytes"
1565 );
1566 // Therefore the built wire requests are identical: invoke_tlv(gen) and
1567 // invoke(Value) transmit byte-identical frames.
1568 assert_eq!(
1569 build_invoke_request(path, &gen_tlv),
1570 build_invoke_request(path, &via_value),
1571 "invoke_tlv and invoke build the same on-wire InvokeRequest"
1572 );
1573 }
1574}