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