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