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