Skip to main content

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}