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