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