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 let fields_tlv = value_to_tlv(&fields)?;
467 let resp = self
468 .action(
469 OP_INVOKE_REQUEST,
470 build_invoke_request(path, &fields_tlv),
471 build_invoke_request_timed(path, &fields_tlv),
472 vec![(path.cluster, path.command)],
473 )
474 .await?;
475 match parse_invoke_response(&resp)? {
476 InvokeResponse::Status(s) => Ok(InvokeResult::Status(s)),
477 InvokeResponse::Command { path, fields_tlv } => Ok(InvokeResult::Data {
478 path,
479 fields: tlv_to_value(&fields_tlv)?,
480 }),
481 }
482 }
483
484 /// Like [`invoke`](Self::invoke) but always performs a **timed** interaction
485 /// (a `TimedRequest` precedes the command — required by some commands, e.g.
486 /// `DoorLock` lock/unlock). `timeout_ms` defaults to [`TIMED_DEFAULT_MS`].
487 ///
488 /// Plain [`invoke`](Self::invoke) already auto-upgrades to timed on a
489 /// `NEEDS_TIMED_INTERACTION` rejection; use this to force the timed path.
490 ///
491 /// # Errors
492 ///
493 /// As [`Self::invoke`].
494 pub async fn invoke_timed(
495 &self,
496 path: CommandPath,
497 fields: Value,
498 timeout_ms: Option<u16>,
499 ) -> Result<InvokeResult, Error> {
500 let fields_tlv = value_to_tlv(&fields)?;
501 let payload = build_invoke_request_timed(path, &fields_tlv);
502 let resp = self
503 .round_trip_timed(
504 timeout_ms.unwrap_or(TIMED_DEFAULT_MS),
505 OP_INVOKE_REQUEST,
506 payload,
507 )
508 .await?;
509 match parse_invoke_response(&resp)? {
510 InvokeResponse::Status(s) => Ok(InvokeResult::Status(s)),
511 InvokeResponse::Command { path, fields_tlv } => Ok(InvokeResult::Data {
512 path,
513 fields: tlv_to_value(&fields_tlv)?,
514 }),
515 }
516 }
517
518 /// Trigger `AnnounceOTAProvider` on this device's
519 /// `OtaSoftwareUpdateRequestor` (0x002A) cluster — telling the device that
520 /// *we* (`provider_node_id`) are an OTA Provider it may query for firmware.
521 /// Sent as a `SimpleAnnouncement` (the device decides when to act): it
522 /// resolves us via operational mDNS, opens a CASE session to us, and invokes
523 /// `QueryImage`.
524 ///
525 /// `provider_node_id` is our own operational node id; `vendor_id` is our
526 /// vendor id; `endpoint` is the endpoint **on us** that hosts the
527 /// `OtaSoftwareUpdateProvider` (0x0029) cluster. The command itself is
528 /// invoked on the device's endpoint 0.
529 ///
530 /// This only fires the announcement — the provider-server half (serving the
531 /// image over BDX) lands in a later M9-F phase.
532 ///
533 /// # Errors
534 ///
535 /// Returns [`Error::InteractionModel`] if the invoke fails to build or parse,
536 /// or [`Error::Operational`] if the device rejects the command with a
537 /// non-success IM status or answers with an unexpected response command.
538 pub async fn announce_ota_provider(
539 &self,
540 provider_node_id: u64,
541 vendor_id: u16,
542 endpoint: u16,
543 ) -> Result<(), Error> {
544 use matter_clusters::gen::ota_software_update_requestor::{
545 command_id::ANNOUNCE_OTA_PROVIDER, encode_announce_ota_provider,
546 AnnouncementReasonEnum, CLUSTER_ID,
547 };
548 let fields_tlv = encode_announce_ota_provider(
549 provider_node_id,
550 vendor_id,
551 AnnouncementReasonEnum::SimpleAnnouncement,
552 None,
553 endpoint,
554 );
555 let fields = tlv_to_value(&fields_tlv)?;
556 let path = CommandPath {
557 endpoint: 0,
558 cluster: CLUSTER_ID,
559 command: ANNOUNCE_OTA_PROVIDER,
560 };
561 match self.invoke(path, fields).await? {
562 InvokeResult::Status(ImStatus::Success) => Ok(()),
563 InvokeResult::Status(ImStatus::Failure(code)) => Err(Error::Operational(format!(
564 "AnnounceOTAProvider rejected (IM status {code:#04x})"
565 ))),
566 InvokeResult::Status(_) => Err(Error::Operational(
567 "unrecognised IM status for AnnounceOTAProvider".into(),
568 )),
569 InvokeResult::Data { .. } => Err(Error::Operational(
570 "unexpected response command for AnnounceOTAProvider".into(),
571 )),
572 }
573 }
574
575 /// Set the device's wall-clock via `TimeSynchronization.SetUTCTime`
576 /// (0x0038 cmd 0x00). `utc_us` is microseconds since the Matter epoch
577 /// (2000-01-01 UTC); `granularity` describes its precision.
578 ///
579 /// # Errors
580 ///
581 /// [`Error::Operational`] if the device rejects it (e.g. it already has a
582 /// finer-granularity time), else an interaction error.
583 pub async fn set_utc_time(
584 &self,
585 utc_us: u64,
586 granularity: TimeGranularity,
587 ) -> Result<(), Error> {
588 let fields = Value::Structure(vec![
589 (Tag::Context(0), Value::Uint(utc_us)),
590 (Tag::Context(1), Value::Uint(u64::from(granularity.to_u8()))),
591 ]);
592 let path = CommandPath {
593 endpoint: 0,
594 cluster: 0x0038,
595 command: 0x00,
596 };
597 match self.invoke(path, fields).await? {
598 InvokeResult::Status(ImStatus::Success) => Ok(()),
599 InvokeResult::Status(ImStatus::Failure(code)) => Err(Error::Operational(format!(
600 "SetUTCTime rejected (IM status {code:#04x})"
601 ))),
602 InvokeResult::Status(_) => Err(Error::Operational(
603 "unrecognised IM status for SetUTCTime".into(),
604 )),
605 InvokeResult::Data { .. } => Err(Error::Operational(
606 "unexpected response command for SetUTCTime".into(),
607 )),
608 }
609 }
610
611 /// Set the device's time zone via `SetTimeZone` (0x0038 cmd 0x02). Returns
612 /// the device's `DSTOffsetRequired` flag (whether you must also call
613 /// [`Self::set_dst_offset`]).
614 ///
615 /// # Errors
616 ///
617 /// [`Error::Operational`] on device rejection or a malformed response, else
618 /// an interaction error.
619 pub async fn set_time_zone(&self, entries: &[TimeZoneEntry]) -> Result<bool, Error> {
620 let list = entries
621 .iter()
622 .map(|e| {
623 let mut members = vec![
624 (Tag::Context(0), Value::Int(i64::from(e.offset_seconds))),
625 (Tag::Context(1), Value::Uint(e.valid_at_us)),
626 ];
627 if let Some(name) = &e.name {
628 members.push((Tag::Context(2), Value::Utf8(name.clone())));
629 }
630 Value::Structure(members)
631 })
632 .collect();
633 let fields = Value::Structure(vec![(Tag::Context(0), Value::Array(list))]);
634 let path = CommandPath {
635 endpoint: 0,
636 cluster: 0x0038,
637 command: 0x02,
638 };
639 match self.invoke(path, fields).await? {
640 InvokeResult::Data { fields, .. } => dst_required_from_response(&fields),
641 InvokeResult::Status(ImStatus::Failure(code)) => Err(Error::Operational(format!(
642 "SetTimeZone rejected (IM status {code:#04x})"
643 ))),
644 InvokeResult::Status(_) => Err(Error::Operational(
645 "SetTimeZone returned status, expected response".into(),
646 )),
647 }
648 }
649
650 /// Set the device's DST offsets via `SetDSTOffset` (0x0038 cmd 0x04).
651 ///
652 /// # Errors
653 ///
654 /// [`Error::Operational`] on device rejection, else an interaction error.
655 pub async fn set_dst_offset(&self, entries: &[DstOffsetEntry]) -> Result<(), Error> {
656 let list = entries
657 .iter()
658 .map(|e| {
659 Value::Structure(vec![
660 (Tag::Context(0), Value::Int(i64::from(e.offset_seconds))),
661 (Tag::Context(1), Value::Uint(e.valid_starting_us)),
662 (
663 Tag::Context(2),
664 e.valid_until_us.map_or(Value::Null, Value::Uint),
665 ),
666 ])
667 })
668 .collect();
669 let fields = Value::Structure(vec![(Tag::Context(0), Value::Array(list))]);
670 let path = CommandPath {
671 endpoint: 0,
672 cluster: 0x0038,
673 command: 0x04,
674 };
675 match self.invoke(path, fields).await? {
676 InvokeResult::Status(ImStatus::Success) => Ok(()),
677 InvokeResult::Status(ImStatus::Failure(code)) => Err(Error::Operational(format!(
678 "SetDSTOffset rejected (IM status {code:#04x})"
679 ))),
680 InvokeResult::Status(_) => Err(Error::Operational(
681 "unrecognised IM status for SetDSTOffset".into(),
682 )),
683 InvokeResult::Data { .. } => Err(Error::Operational(
684 "unexpected response command for SetDSTOffset".into(),
685 )),
686 }
687 }
688
689 /// Read the device's current `UTCTime` (0x0038 attr 0x00). `None` if the
690 /// device reports a null time (clock not set).
691 ///
692 /// # Errors
693 ///
694 /// An interaction error if the read fails.
695 pub async fn read_utc_time(&self) -> Result<Option<u64>, Error> {
696 let reports = self.read(&[ReadPath::concrete(0, 0x0038, 0x0000)]).await?;
697 Ok(reports.iter().find_map(|(p, v)| {
698 if p.attribute == 0x0000 {
699 if let Value::Uint(u) = v {
700 return Some(*u);
701 }
702 }
703 None
704 }))
705 }
706
707 /// Read this device's `Binding` list on `endpoint` (0x001E attr 0x0000) —
708 /// the targets it is wired to send to.
709 ///
710 /// # Errors
711 ///
712 /// An interaction error if the read fails.
713 pub async fn read_binding(
714 &self,
715 endpoint: u16,
716 ) -> Result<Vec<crate::binding::BindingTarget>, Error> {
717 let reports = self
718 .read(&[ReadPath::concrete(
719 endpoint,
720 crate::binding::BINDING_CLUSTER,
721 crate::binding::ATTR_BINDING,
722 )])
723 .await?;
724 Ok(crate::binding::parse_bindings(&reports))
725 }
726
727 /// Replace this device's `Binding` list on `endpoint` with `targets` (a
728 /// full-list, fabric-scoped write). Returns the per-path device status.
729 ///
730 /// # Errors
731 ///
732 /// An interaction error, or a per-path device status.
733 pub async fn write_binding(
734 &self,
735 endpoint: u16,
736 targets: &[crate::binding::BindingTarget],
737 ) -> Result<Vec<(AttributePath, ImStatus)>, Error> {
738 let path = AttributePath {
739 endpoint,
740 cluster: crate::binding::BINDING_CLUSTER,
741 attribute: crate::binding::ATTR_BINDING,
742 };
743 let element_tlvs: Vec<Vec<u8>> = targets
744 .iter()
745 .map(|t| value_to_tlv(&crate::binding::binding_target_value(t)))
746 .collect::<Result<_, _>>()?;
747 let chunks = build_list_write_chunks(path, &element_tlvs, WRITE_CHUNK_BUDGET, false);
748 let resp = if chunks.len() == 1 {
749 self.action(
750 OP_WRITE_REQUEST,
751 chunks[0].clone(),
752 chunks[0].clone(),
753 vec![(path.cluster, path.attribute)],
754 )
755 .await?
756 } else {
757 self.chunked_write(chunks).await?
758 };
759 Ok(parse_write_response(&resp)?)
760 }
761
762 /// Register the controller as a check-in client with this ICD
763 /// (`IcdManagement.RegisterClient`, 0x0046 cmd 0x00). Generates a fresh
764 /// 16-byte symmetric key, registers our commissioner node id as the
765 /// `CheckInNodeID`, persists an [`IcdRegistration`](crate::IcdRegistration)
766 /// (so the check-in listener can later verify this device's Check-Ins), and
767 /// returns it. `monitored_subject` is the subject the ICD watches for us
768 /// (usually our node id).
769 ///
770 /// # Errors
771 ///
772 /// [`Error::Operational`] on RNG failure or device rejection; an interaction
773 /// error; or a persistence error.
774 pub async fn register_icd_client(
775 &self,
776 monitored_subject: u64,
777 client_type: crate::icd::IcdClientType,
778 ) -> Result<crate::icd::IcdRegistration, Error> {
779 let check_in_node_id = self.commissioner_node_id().await?;
780 let mut key = [0u8; 16];
781 matter_crypto::random_bytes(&mut key)
782 .map_err(|e| Error::Operational(format!("ICD key generation failed: {e}")))?;
783 let fields = crate::icd::register_client_fields(
784 check_in_node_id,
785 monitored_subject,
786 &key,
787 client_type,
788 );
789 let path = CommandPath {
790 endpoint: 0,
791 cluster: crate::icd::ICD_MANAGEMENT_CLUSTER,
792 command: 0x00,
793 };
794 let icd_counter = match self.invoke(path, fields).await? {
795 InvokeResult::Data { fields, .. } => icd_counter_from_response(&fields)?,
796 InvokeResult::Status(ImStatus::Failure(code)) => {
797 return Err(Error::Operational(format!(
798 "RegisterClient rejected (IM status {code:#04x})"
799 )))
800 }
801 InvokeResult::Status(_) => {
802 return Err(Error::Operational(
803 "RegisterClient returned status, expected RegisterClientResponse".into(),
804 ))
805 }
806 };
807 let registration = crate::icd::IcdRegistration::new(
808 self.node_id,
809 check_in_node_id,
810 monitored_subject,
811 key,
812 icd_counter,
813 );
814 let (reply, rx) = oneshot::channel();
815 self.tx
816 .send(Command::PersistIcdRegistration {
817 registration: registration.clone(),
818 reply,
819 })
820 .await
821 .map_err(|_| Error::ControllerStopped)?;
822 rx.await.map_err(|_| Error::ControllerStopped)??;
823 Ok(registration)
824 }
825
826 /// Unregister the controller from this ICD (`UnregisterClient`, cmd 0x02),
827 /// using our commissioner node id as the `CheckInNodeID`.
828 ///
829 /// # Errors
830 ///
831 /// [`Error::Operational`] on device rejection, else an interaction error.
832 pub async fn unregister_icd_client(&self) -> Result<(), Error> {
833 let check_in_node_id = self.commissioner_node_id().await?;
834 let fields = Value::Structure(vec![(Tag::Context(0), Value::Uint(check_in_node_id))]);
835 let path = CommandPath {
836 endpoint: 0,
837 cluster: crate::icd::ICD_MANAGEMENT_CLUSTER,
838 command: 0x02,
839 };
840 match self.invoke(path, fields).await? {
841 InvokeResult::Status(ImStatus::Success) => Ok(()),
842 InvokeResult::Status(ImStatus::Failure(code)) => Err(Error::Operational(format!(
843 "UnregisterClient rejected (IM status {code:#04x})"
844 ))),
845 InvokeResult::Status(_) => Err(Error::Operational(
846 "unrecognised IM status for UnregisterClient".into(),
847 )),
848 InvokeResult::Data { .. } => Err(Error::Operational(
849 "unexpected response command for UnregisterClient".into(),
850 )),
851 }
852 }
853
854 /// Ask this ICD to stay in active mode for at least `stay_active_ms`
855 /// (`StayActiveRequest`, cmd 0x03). Returns the device's promised active
856 /// duration (ms).
857 ///
858 /// # Errors
859 ///
860 /// [`Error::Operational`] on device rejection or a malformed response, else
861 /// an interaction error.
862 pub async fn stay_active_request(&self, stay_active_ms: u32) -> Result<u32, Error> {
863 let fields = Value::Structure(vec![(
864 Tag::Context(0),
865 Value::Uint(u64::from(stay_active_ms)),
866 )]);
867 let path = CommandPath {
868 endpoint: 0,
869 cluster: crate::icd::ICD_MANAGEMENT_CLUSTER,
870 command: 0x03,
871 };
872 match self.invoke(path, fields).await? {
873 InvokeResult::Data { fields, .. } => promised_duration_from_response(&fields),
874 InvokeResult::Status(ImStatus::Failure(code)) => Err(Error::Operational(format!(
875 "StayActiveRequest rejected (IM status {code:#04x})"
876 ))),
877 InvokeResult::Status(_) => Err(Error::Operational(
878 "StayActiveRequest returned status, expected response".into(),
879 )),
880 }
881 }
882
883 /// Read `AdministratorCommissioning` `WindowStatus`, `AdminFabricIndex`, and
884 /// `AdminVendorId` from endpoint 0. Returns a snapshot of the current
885 /// commissioning-window state.
886 ///
887 /// # Errors
888 ///
889 /// An interaction error if the read fails.
890 pub async fn commissioning_window_status(&self) -> Result<crate::admin::WindowStatus, Error> {
891 use crate::admin::{
892 ADMIN_COMMISSIONING_CLUSTER, ATTR_ADMIN_FABRIC_INDEX, ATTR_ADMIN_VENDOR_ID,
893 ATTR_WINDOW_STATUS,
894 };
895 let paths = [
896 ReadPath::concrete(0, ADMIN_COMMISSIONING_CLUSTER, ATTR_WINDOW_STATUS),
897 ReadPath::concrete(0, ADMIN_COMMISSIONING_CLUSTER, ATTR_ADMIN_FABRIC_INDEX),
898 ReadPath::concrete(0, ADMIN_COMMISSIONING_CLUSTER, ATTR_ADMIN_VENDOR_ID),
899 ];
900 let reports = self.read(&paths).await?;
901 Ok(crate::admin::parse_window_status(&reports))
902 }
903
904 /// Read the device's `Fabrics` list (every fabric it is commissioned onto).
905 ///
906 /// # Errors
907 ///
908 /// An interaction error if the read fails.
909 pub async fn list_fabrics(&self) -> Result<Vec<crate::opcreds::FabricDescriptor>, Error> {
910 let paths = [ReadPath::concrete(
911 0,
912 crate::opcreds::OPERATIONAL_CREDENTIALS_CLUSTER,
913 crate::opcreds::ATTR_FABRICS,
914 )];
915 let reports = self.read(&paths).await?;
916 Ok(crate::opcreds::parse_fabrics(&reports))
917 }
918
919 /// Write the device's `AccessControl.Acl` list to exactly `entries`.
920 ///
921 /// Refuses (before sending) any list that would strip our own administrative
922 /// access ([`Error::AclWouldLockOut`]). Small lists go in one
923 /// `WriteRequestMessage` (byte-identical to a normal write); larger lists are
924 /// chunked (`ReplaceAll`+`AppendItem`) without ever sending an empty `ReplaceAll`.
925 ///
926 /// ACL writes are NOT timed (the spec does not require `TimedRequest` for
927 /// `AccessControl.Acl`); however, if the device unexpectedly rejects the write
928 /// with `NEEDS_TIMED_INTERACTION` the controller's timed-auto-upgrade will
929 /// transparently retry on the single-chunk path (the same bytes are safe to
930 /// re-send because the whole list is idempotent). The multi-chunk path fails
931 /// cleanly on a `0xc6` rejection (the `ChunkedWrite` pending does not carry
932 /// a `timed_payload`).
933 ///
934 /// # Errors
935 ///
936 /// [`Error::AclWouldLockOut`] if `entries` contains no Administer/CASE entry
937 /// covering our commissioner node id; no bytes are sent to the device in that
938 /// case. Otherwise returns an interaction error or a per-path device status.
939 pub async fn write_acl(
940 &self,
941 entries: &[crate::acl::AclEntry],
942 ) -> Result<Vec<(AttributePath, ImStatus)>, Error> {
943 self.write_acl_with_budget(entries, WRITE_CHUNK_BUDGET)
944 .await
945 }
946
947 /// Inner implementation of [`write_acl`](Node::write_acl) with an injectable
948 /// per-chunk byte budget.
949 ///
950 /// The lockout guard runs before any bytes are sent to the device, regardless
951 /// of the budget. `budget` controls the `build_list_write_chunks` split point;
952 /// the production verb always passes [`WRITE_CHUNK_BUDGET`] (800 bytes).
953 ///
954 /// Exposed as `pub(crate)` so tests can force a small budget (e.g. 40 bytes)
955 /// to exercise the multi-chunk dispatch branch through `write_acl` itself
956 /// rather than calling `chunked_write` directly.
957 ///
958 /// # Errors
959 ///
960 /// [`Error::AclWouldLockOut`] if `entries` contains no Administer/CASE entry
961 /// covering our commissioner node id; no bytes are sent to the device in that
962 /// case. Otherwise returns an interaction error or a per-path device status.
963 pub(crate) async fn write_acl_with_budget(
964 &self,
965 entries: &[crate::acl::AclEntry],
966 budget: usize,
967 ) -> Result<Vec<(AttributePath, ImStatus)>, Error> {
968 // Lockout guard MUST run before any network I/O.
969 let our = self.commissioner_node_id().await?;
970 if !crate::acl::acl_retains_admin(entries, our) {
971 return Err(Error::AclWouldLockOut);
972 }
973 let path = AttributePath {
974 endpoint: 0,
975 cluster: crate::acl::ACCESS_CONTROL_CLUSTER,
976 attribute: crate::acl::ATTR_ACL,
977 };
978 let element_tlvs: Vec<Vec<u8>> = entries
979 .iter()
980 .map(|e| value_to_tlv(&crate::acl::acl_entry_value(e)))
981 .collect::<Result<_, _>>()?;
982 let chunks = build_list_write_chunks(path, &element_tlvs, budget, false);
983 let resp = if chunks.len() == 1 {
984 // Single message: reuse the plain Action path (byte-identical to a
985 // normal write, 0xc6 auto-upgrade intact). Pass `chunks[0]` as both
986 // plain and timed payload so the retry — if the device demands timed —
987 // re-sends identical bytes (safe for a full-list replace).
988 self.action(
989 OP_WRITE_REQUEST,
990 chunks[0].clone(),
991 chunks[0].clone(),
992 vec![(path.cluster, path.attribute)],
993 )
994 .await?
995 } else {
996 self.chunked_write(chunks).await?
997 };
998 Ok(parse_write_response(&resp)?)
999 }
1000
1001 /// Read the device's `AccessControl.Acl` list (the ACL entries on this fabric).
1002 ///
1003 /// # Errors
1004 ///
1005 /// An interaction error if the read fails.
1006 pub async fn read_acl(&self) -> Result<Vec<crate::acl::AclEntry>, Error> {
1007 let paths = [ReadPath::concrete(
1008 0,
1009 crate::acl::ACCESS_CONTROL_CLUSTER,
1010 crate::acl::ATTR_ACL,
1011 )];
1012 let reports = self.read(&paths).await?;
1013 Ok(crate::acl::parse_acl(&reports))
1014 }
1015
1016 /// Open an enhanced commissioning window using **caller-supplied** secrets
1017 /// (test/power-user seam). Most callers want
1018 /// `Node::open_commissioning_window` (Task 3), which generates the secrets.
1019 ///
1020 /// Computes the PAKE passcode verifier from `passcode`/`salt`/`iterations`,
1021 /// invokes `OpenCommissioningWindow` (a **timed** invoke — `AdminComm` requires
1022 /// it), and returns the onboarding payload.
1023 ///
1024 /// # Errors
1025 ///
1026 /// Returns [`Error::CommissioningWindowRejected`] if the device rejects the
1027 /// command, or a crypto/interaction error.
1028 #[allow(clippy::too_many_arguments)]
1029 pub async fn open_commissioning_window_with(
1030 &self,
1031 timeout_s: u16,
1032 passcode: u32,
1033 salt: &[u8],
1034 discriminator: u16,
1035 iterations: u32,
1036 vendor_id: Option<u16>,
1037 product_id: Option<u16>,
1038 ) -> Result<crate::admin::CommissioningWindow, Error> {
1039 let verifier = matter_crypto::pake_passcode_verifier(passcode, salt, iterations)
1040 .map_err(|e| Error::Operational(format!("verifier: {e}")))?;
1041 let fields =
1042 crate::admin::open_window_fields(timeout_s, &verifier, discriminator, iterations, salt);
1043 let path = CommandPath {
1044 endpoint: 0,
1045 cluster: crate::admin::ADMIN_COMMISSIONING_CLUSTER,
1046 command: crate::admin::CMD_OPEN_COMMISSIONING_WINDOW,
1047 };
1048 self.admin_timed_command(path, fields).await?;
1049 let (manual_code, qr_code) =
1050 crate::admin::onboarding_payload(passcode, discriminator, vendor_id, product_id)?;
1051 Ok(crate::admin::CommissioningWindow {
1052 passcode,
1053 discriminator,
1054 iterations,
1055 salt: salt.to_vec(),
1056 manual_code,
1057 qr_code,
1058 })
1059 }
1060
1061 /// Open an enhanced commissioning window so a second admin can commission
1062 /// this device onto its own fabric. Generates a fresh passcode/salt/
1063 /// discriminator, computes the PAKE verifier, and returns the onboarding
1064 /// payload (manual pairing code, plus QR when `opts.vendor_id`/`product_id`
1065 /// are set). The `AdminComm` command is sent as a timed invoke.
1066 ///
1067 /// # Errors
1068 /// Returns [`Error::CommissioningWindowRejected`] if the device rejects it,
1069 /// or a crypto/RNG/interaction error.
1070 pub async fn open_commissioning_window(
1071 &self,
1072 opts: crate::admin::OpenWindowOpts,
1073 ) -> Result<crate::admin::CommissioningWindow, Error> {
1074 let (passcode, salt, discriminator) = crate::admin::random_window_secrets()?;
1075 self.open_commissioning_window_with(
1076 opts.timeout_s,
1077 passcode,
1078 &salt,
1079 discriminator,
1080 opts.iterations,
1081 opts.vendor_id,
1082 opts.product_id,
1083 )
1084 .await
1085 }
1086
1087 /// Open a *basic* commissioning window (reuses the device's original
1088 /// passcode — no new onboarding payload). Timed invoke.
1089 ///
1090 /// # Errors
1091 /// [`Error::CommissioningWindowRejected`] on device rejection, else an
1092 /// interaction error.
1093 pub async fn open_basic_commissioning_window(&self, timeout_s: u16) -> Result<(), Error> {
1094 let fields = matter_codec::Value::Structure(vec![(
1095 matter_codec::Tag::Context(0),
1096 matter_codec::Value::Uint(u64::from(timeout_s)),
1097 )]);
1098 let path = CommandPath {
1099 endpoint: 0,
1100 cluster: crate::admin::ADMIN_COMMISSIONING_CLUSTER,
1101 command: crate::admin::CMD_OPEN_BASIC_COMMISSIONING_WINDOW,
1102 };
1103 self.admin_timed_command(path, fields).await
1104 }
1105
1106 /// Revoke any open commissioning window. Timed invoke. Returns `Ok(())`
1107 /// even if no window was open (the device reports `WindowNotOpen`, which is
1108 /// surfaced as [`Error::CommissioningWindowRejected`] only on a hard IM
1109 /// failure).
1110 ///
1111 /// # Errors
1112 /// [`Error::CommissioningWindowRejected`] on device rejection.
1113 pub async fn revoke_commissioning(&self) -> Result<(), Error> {
1114 let fields = matter_codec::Value::Structure(vec![]);
1115 let path = CommandPath {
1116 endpoint: 0,
1117 cluster: crate::admin::ADMIN_COMMISSIONING_CLUSTER,
1118 command: crate::admin::CMD_REVOKE_COMMISSIONING,
1119 };
1120 self.admin_timed_command(path, fields).await
1121 }
1122
1123 /// Shared helper: timed-invoke an `AdminComm` command expecting a bare
1124 /// success status. Maps `Success` to `Ok(())`, `Failure(code)` to
1125 /// [`Error::CommissioningWindowRejected`], any other `Status(_)` variant
1126 /// (catch-all for `#[non_exhaustive]` future codes) to an operational
1127 /// error, and any response command to an operational error.
1128 async fn admin_timed_command(
1129 &self,
1130 path: CommandPath,
1131 fields: matter_codec::Value,
1132 ) -> Result<(), Error> {
1133 match self.invoke_timed(path, fields, None).await? {
1134 InvokeResult::Status(ImStatus::Success) => Ok(()),
1135 InvokeResult::Status(ImStatus::Failure(code)) => {
1136 Err(Error::CommissioningWindowRejected(code))
1137 }
1138 InvokeResult::Status(_) => Err(Error::Operational(
1139 "unrecognised IM status for admin command".into(),
1140 )),
1141 InvokeResult::Data { .. } => {
1142 Err(Error::Operational("unexpected response command".into()))
1143 }
1144 }
1145 }
1146
1147 /// Remove a fabric from the device by its `fabric_index`.
1148 ///
1149 /// Reads `CurrentFabricIndex` first and refuses to remove our OWN fabric
1150 /// (that would sever this CASE session and orphan persisted state) with
1151 /// [`Error::WouldRemoveSelf`]. There is intentionally no `force` override.
1152 ///
1153 /// # Errors
1154 /// [`Error::WouldRemoveSelf`] if `fabric_index` is our own;
1155 /// [`Error::Operational`] if the device does not return a readable
1156 /// `CurrentFabricIndex` — the call fails without invoking `RemoveFabric` in
1157 /// that case (fail-closed on a destructive operation);
1158 /// [`Error::OperationalCredentialsRejected`] if the device rejects it (e.g.
1159 /// 7 `InvalidFabricIndex`); else an interaction error.
1160 pub async fn remove_fabric(&self, fabric_index: u8) -> Result<(), Error> {
1161 // Self-protection: CurrentFabricIndex over our session is OUR fabric's
1162 // index here. Must check BEFORE invoking — this is a destructive op.
1163 let cur = self
1164 .read(&[ReadPath::concrete(
1165 0,
1166 crate::opcreds::OPERATIONAL_CREDENTIALS_CLUSTER,
1167 crate::opcreds::ATTR_CURRENT_FABRIC_INDEX,
1168 )])
1169 .await?;
1170 // Fail CLOSED: if we cannot read CurrentFabricIndex we refuse to
1171 // proceed. The equality guard `== Some(fabric_index)` would silently
1172 // fall through when `parse_current_fabric_index` returns `None`,
1173 // allowing RemoveFabric on an unverified index.
1174 let cur_idx = crate::opcreds::parse_current_fabric_index(&cur).ok_or_else(|| {
1175 Error::Operational(
1176 "could not read CurrentFabricIndex; refusing remove_fabric for safety".into(),
1177 )
1178 })?;
1179 if cur_idx == fabric_index {
1180 return Err(Error::WouldRemoveSelf);
1181 }
1182 let fields = Value::Structure(vec![(
1183 matter_codec::Tag::Context(0),
1184 Value::Uint(u64::from(fabric_index)),
1185 )]);
1186 let path = CommandPath {
1187 endpoint: 0,
1188 cluster: crate::opcreds::OPERATIONAL_CREDENTIALS_CLUSTER,
1189 command: crate::opcreds::CMD_REMOVE_FABRIC,
1190 };
1191 match self.invoke(path, fields).await? {
1192 InvokeResult::Data { fields, .. } => {
1193 let status = crate::opcreds::parse_noc_response(&fields);
1194 crate::opcreds::noc_status_to_result(&status)
1195 }
1196 InvokeResult::Status(ImStatus::Success) => Ok(()),
1197 InvokeResult::Status(ImStatus::Failure(code)) => {
1198 Err(Error::OperationalCredentialsRejected(code))
1199 }
1200 InvokeResult::Status(_) => Err(Error::Operational(
1201 "unexpected status for RemoveFabric".into(),
1202 )),
1203 }
1204 }
1205
1206 /// Update the label of OUR fabric on the device (`UpdateFabricLabel` acts on
1207 /// the accessing fabric; there is no index argument).
1208 ///
1209 /// # Errors
1210 /// [`Error::OperationalCredentialsRejected`] if the device rejects it
1211 /// (e.g. 9 `LabelConflict`); else an interaction error.
1212 pub async fn update_fabric_label(&self, label: &str) -> Result<(), Error> {
1213 let fields = Value::Structure(vec![(
1214 matter_codec::Tag::Context(0),
1215 Value::Utf8(label.to_string()),
1216 )]);
1217 let path = CommandPath {
1218 endpoint: 0,
1219 cluster: crate::opcreds::OPERATIONAL_CREDENTIALS_CLUSTER,
1220 command: crate::opcreds::CMD_UPDATE_FABRIC_LABEL,
1221 };
1222 match self.invoke(path, fields).await? {
1223 InvokeResult::Data { fields, .. } => {
1224 let status = crate::opcreds::parse_noc_response(&fields);
1225 crate::opcreds::noc_status_to_result(&status)
1226 }
1227 InvokeResult::Status(ImStatus::Success) => Ok(()),
1228 InvokeResult::Status(ImStatus::Failure(code)) => {
1229 Err(Error::OperationalCredentialsRejected(code))
1230 }
1231 InvokeResult::Status(_) => Err(Error::Operational(
1232 "unexpected status for UpdateFabricLabel".into(),
1233 )),
1234 }
1235 }
1236
1237 /// Add the device endpoint to a group (`Groups.AddGroup`). The endpoint then
1238 /// joins the group's multicast address and accepts group commands.
1239 ///
1240 /// # Errors
1241 ///
1242 /// [`Error::GroupCommandRejected`] on a non-success status; else interaction error.
1243 pub async fn add_group(&self, endpoint: u16, group_id: u16, name: &str) -> Result<(), Error> {
1244 self.group_command(
1245 endpoint,
1246 crate::group::CMD_ADD_GROUP,
1247 crate::group::add_group_fields(group_id, name),
1248 )
1249 .await
1250 }
1251
1252 /// Remove the device endpoint from a group (`Groups.RemoveGroup`).
1253 ///
1254 /// # Errors
1255 ///
1256 /// [`Error::GroupCommandRejected`] on a non-success status; else interaction error.
1257 pub async fn remove_group(&self, endpoint: u16, group_id: u16) -> Result<(), Error> {
1258 self.group_command(
1259 endpoint,
1260 crate::group::CMD_REMOVE_GROUP,
1261 crate::group::remove_group_fields(group_id),
1262 )
1263 .await
1264 }
1265
1266 /// Shared: invoke a `Groups` command and map its response-status to `()`/error.
1267 ///
1268 /// `AddGroup`/`RemoveGroup` both return a response command whose `status` field
1269 /// (context tag 0) is 0 on success or a non-zero `GroupClusterStatus` code on
1270 /// failure. A bare `Success` IM status is also accepted (some devices skip the
1271 /// response command on success); bare `Failure` codes become
1272 /// [`Error::GroupCommandRejected`].
1273 async fn group_command(&self, endpoint: u16, command: u32, fields: Value) -> Result<(), Error> {
1274 let path = CommandPath {
1275 endpoint,
1276 cluster: crate::group::GROUPS_CLUSTER,
1277 command,
1278 };
1279 match self.invoke(path, fields).await? {
1280 InvokeResult::Data { fields, .. } => {
1281 let status = crate::group::parse_group_status(&fields);
1282 if status == 0 {
1283 Ok(())
1284 } else {
1285 Err(Error::GroupCommandRejected(status))
1286 }
1287 }
1288 InvokeResult::Status(ImStatus::Success) => Ok(()),
1289 InvokeResult::Status(ImStatus::Failure(code)) => Err(Error::GroupCommandRejected(code)),
1290 InvokeResult::Status(_) => Err(Error::Operational(
1291 "unexpected status for Groups command".into(),
1292 )),
1293 }
1294 }
1295
1296 /// Provision a group key set on the device via `KeySetWrite`
1297 /// (`GroupKeyManagement` cluster, endpoint 0). The epoch key is the
1298 /// group's symmetric key material. Returns `Ok(())` on a bare
1299 /// `Success` status from the device.
1300 ///
1301 /// `KeySetWrite` is NOT a timed command — the plain `invoke` path is used.
1302 ///
1303 /// # Errors
1304 ///
1305 /// [`Error::GroupCommandRejected`] if the device returns a non-success IM
1306 /// status (e.g. `ResourceExhausted`). An interaction or transport error is
1307 /// surfaced as its corresponding [`Error`] variant.
1308 pub async fn write_group_key_set(&self, set: &crate::group::GroupKeySet) -> Result<(), Error> {
1309 let path = CommandPath {
1310 endpoint: 0,
1311 cluster: crate::group::GROUP_KEY_MANAGEMENT_CLUSTER,
1312 command: crate::group::CMD_KEY_SET_WRITE,
1313 };
1314 match self
1315 .invoke(path, crate::group::key_set_write_fields(set))
1316 .await?
1317 {
1318 InvokeResult::Status(ImStatus::Success) => Ok(()),
1319 InvokeResult::Status(ImStatus::Failure(code)) => Err(Error::GroupCommandRejected(code)),
1320 InvokeResult::Status(_) => Err(Error::Operational(
1321 "unexpected status for KeySetWrite".into(),
1322 )),
1323 InvokeResult::Data { .. } => Err(Error::Operational(
1324 "unexpected response command for KeySetWrite".into(),
1325 )),
1326 }
1327 }
1328
1329 /// Write the device's `GroupKeyMap` list (binds group ids to key sets).
1330 ///
1331 /// Small lists go in one `WriteRequestMessage` (byte-identical to a normal
1332 /// write); larger lists are chunked (`ReplaceAll`+`AppendItem`) without ever
1333 /// sending an empty `ReplaceAll`. There is no lockout guard — `GroupKeyMap`
1334 /// has no self-lock concern unlike `AccessControl.Acl`.
1335 ///
1336 /// `GroupKeyMap` writes are NOT timed (the spec does not require
1337 /// `TimedRequest`); however, if the device unexpectedly rejects the write with
1338 /// `NEEDS_TIMED_INTERACTION` the controller's timed-auto-upgrade will
1339 /// transparently retry on the single-chunk path.
1340 ///
1341 /// # Errors
1342 ///
1343 /// Returns an interaction error or a per-path device status from the device.
1344 pub async fn write_group_key_map(
1345 &self,
1346 entries: &[crate::group::GroupKeyMapEntry],
1347 ) -> Result<Vec<(AttributePath, ImStatus)>, Error> {
1348 let path = AttributePath {
1349 endpoint: 0,
1350 cluster: crate::group::GROUP_KEY_MANAGEMENT_CLUSTER,
1351 attribute: crate::group::ATTR_GROUP_KEY_MAP,
1352 };
1353 let element_tlvs: Vec<Vec<u8>> = entries
1354 .iter()
1355 .map(|e| value_to_tlv(&crate::group::group_key_map_entry_value(*e)))
1356 .collect::<Result<_, _>>()?;
1357 let chunks = build_list_write_chunks(path, &element_tlvs, WRITE_CHUNK_BUDGET, false);
1358 let resp = if chunks.len() == 1 {
1359 // Single message: reuse the plain Action path (byte-identical to a
1360 // normal write, 0xc6 auto-upgrade intact). Pass `chunks[0]` as both
1361 // plain and timed payload so the retry — if the device demands timed —
1362 // re-sends identical bytes (safe for a full-list replace).
1363 self.action(
1364 OP_WRITE_REQUEST,
1365 chunks[0].clone(),
1366 chunks[0].clone(),
1367 vec![(path.cluster, path.attribute)],
1368 )
1369 .await?
1370 } else {
1371 self.chunked_write(chunks).await?
1372 };
1373 Ok(parse_write_response(&resp)?)
1374 }
1375
1376 /// Subscribe to attribute reports for `attrs` and/or event reports for
1377 /// `events` (concrete or wildcard paths) on a **single** subscription. The
1378 /// device sends the priming values/events, then steady-state changes within
1379 /// `[min_interval, max_interval]` seconds. Await
1380 /// [`SubscriptionEvent`](crate::subscription::SubscriptionEvent)s — both
1381 /// `Report` (attributes) and `Event` (events) — via
1382 /// [`Subscription::next`](crate::subscription::Subscription::next).
1383 ///
1384 /// Pass an empty slice for either to subscribe to only the other. The
1385 /// subscription auto-resubscribes transparently on staleness/session loss,
1386 /// re-requesting the same attribute and event paths.
1387 ///
1388 /// # Errors
1389 ///
1390 /// [`Error::ControllerStopped`] if the owning task stopped, or any
1391 /// connect / transport / interaction-model error while establishing the
1392 /// subscription.
1393 pub async fn subscribe(
1394 &self,
1395 attrs: &[ReadPath],
1396 events: &[EventPath],
1397 min_interval: u16,
1398 max_interval: u16,
1399 ) -> Result<crate::subscription::Subscription, Error> {
1400 let (reply, rx) = oneshot::channel();
1401 self.tx
1402 .send(Command::Subscribe {
1403 node_id: self.node_id,
1404 paths: attrs.to_vec(),
1405 event_paths: events.to_vec(),
1406 event_filters: Vec::new(),
1407 min_interval,
1408 max_interval,
1409 reply,
1410 })
1411 .await
1412 .map_err(|_| Error::ControllerStopped)?;
1413 let (receivers, key) = rx.await.map_err(|_| Error::ControllerStopped)??;
1414 Ok(crate::subscription::Subscription {
1415 rx: receivers.report_rx,
1416 ctrl_rx: receivers.ctrl_rx,
1417 tx: self.tx.clone(),
1418 key,
1419 cancelled: false,
1420 })
1421 }
1422}