Skip to main content

matter_interaction/
lib.rs

1//! Matter Interaction Model (IM) message framing — Matter Core Spec §10.
2//!
3//! Builders for the IM message envelopes the wire carries
4//! (`InvokeRequestMessage`, `ReadRequestMessage`, `WriteRequestMessage`,
5//! `SubscribeRequestMessage`, `StatusResponseMessage`)
6//! and parsers for the responses (`InvokeResponseMessage`,
7//! `ReportDataMessage`, `WriteResponseMessage`, `SubscribeResponseMessage`).
8//! Callers supply already-encoded cluster TLV payloads (e.g. from
9//! `matter-clusters` codecs) and compose them with the concrete paths in
10//! [`path`].
11//!
12//! Scope:
13//!
14//! - **Invoke** — single command, group (broadcast) invoke, and batched
15//!   multi-command invoke via [`build_invoke_request_batch`] /
16//!   [`parse_invoke_response_batch`]. A controller-side batching verb and
17//!   `MaxPathsPerInvoke` gating are not here; the batch builders are.
18//! - **Read and subscribe** — concrete and wildcard paths ([`ReadPath`]),
19//!   [`SubscribeRequest`] with its response and `StatusResponse` handshake,
20//!   and [`ReportAccumulator`] for reassembling chunked reports across
21//!   messages and across list boundaries.
22//! - **Write** — single-message writes, plus [`build_list_write_chunks`] for
23//!   a list attribute too large for one message.
24//! - **Events** — event paths and filters in `ReadRequest` and
25//!   `SubscribeRequest`, and `EventReportIB` parsing.
26//! - **Timed interactions** — the `TimedRequest` message and the
27//!   `TimedRequest` flag on writes and invokes.
28//! - **Server side** — [`parse_invoke_request`] and the
29//!   `build_invoke_response_*` builders, for responding to an invoke rather
30//!   than issuing one (used by this workspace's OTA provider).
31//!
32//! This crate depends only on `matter-codec`: it frames and parses IM
33//! messages and does no IO. Byte-parity with matter.js is enforced by
34//! `tests/im_byte_parity.rs`
35//! against fixtures captured via `cargo xtask capture-im`.
36
37#![forbid(unsafe_code)]
38
39mod accumulator;
40pub mod error;
41pub mod event;
42pub mod invoke;
43pub mod invoke_server;
44pub mod path;
45pub mod read;
46pub mod status;
47pub mod subscription;
48pub mod timed;
49pub mod write;
50
51pub use accumulator::{ReportAccumulator, DEFAULT_MAX_BYTES, DEFAULT_MAX_ELEMENTS};
52pub use error::ImError;
53pub use event::{
54    EventFilter, EventPath, EventPriority, EventReport, EventReportItem, EventTimestamp,
55};
56pub use invoke::{
57    build_invoke_request, build_invoke_request_batch, build_invoke_request_group,
58    build_invoke_request_timed, parse_invoke_response, parse_invoke_response_batch, InvokeResponse,
59    InvokeResponseEntry,
60};
61pub use invoke_server::{
62    build_invoke_response_command, build_invoke_response_status, parse_invoke_request,
63    InvokedCommand, ParsedInvokeRequest,
64};
65pub use path::{AttributePath, CommandPath, ReadPath};
66pub use read::{
67    build_read_request, build_read_request_full, build_read_request_paths, parse_report_data,
68    AttributeReportItem, ReportData, ReportOp,
69};
70pub use status::{parse_status_response, ImStatus};
71pub use subscription::{
72    build_status_response, build_subscribe_request, parse_subscribe_response, SubscribeRequest,
73    SubscribeResponse,
74};
75pub use timed::build_timed_request;
76pub use write::{
77    build_list_write_chunks, build_write_request, build_write_request_timed, parse_write_response,
78    AttributeWriteRequest,
79};
80
81/// Interaction Model protocol revision emitted at context tag `0xFF` in
82/// every top-level IM message. Confirmed against the matter.js byte-parity
83/// fixture (see `tests/im_byte_parity.rs`); bump only when a captured
84/// fixture proves matter.js changed it.
85pub const IM_REVISION: u8 = 11;
86
87use matter_codec::{ContainerKind, Element, Tag, TlvReader, Value};
88
89/// Assert the reader's first element is an anonymous message struct and
90/// consume its start.
91///
92/// # Errors
93///
94/// Returns [`error::ImError::NotAStruct`] if the first element is not an
95/// anonymous structure start, or propagates any [`error::ImError::Codec`]
96/// error from the reader.
97pub fn expect_message_struct(r: &mut TlvReader<'_>) -> Result<(), error::ImError> {
98    match r.next()? {
99        Some(Element::ContainerStart {
100            tag: Tag::Anonymous,
101            kind: ContainerKind::Structure,
102        }) => Ok(()),
103        Some(_) | None => Err(error::ImError::NotAStruct),
104    }
105}
106
107/// Reader positioned just after a container start: consume to its matching
108/// end, returning the members as `(tag, value)` pairs (for List/Structure).
109/// Calling this with the reader in any other position yields an
110/// [`error::ImError`] or misattributed members — never a panic or UB.
111///
112/// # Errors
113///
114/// Returns [`error::ImError::Codec`] wrapping
115/// [`matter_codec::Error::UnclosedContainer`] if the input ends before a
116/// matching end-of-container, or propagates any other codec error.
117pub fn read_container_members(r: &mut TlvReader<'_>) -> Result<Vec<(Tag, Value)>, error::ImError> {
118    let mut out = Vec::new();
119    loop {
120        match r.next()? {
121            None => {
122                return Err(error::ImError::Codec(
123                    matter_codec::Error::UnclosedContainer,
124                ))
125            }
126            Some(Element::ContainerEnd) => return Ok(out),
127            Some(Element::Scalar { tag, value }) => out.push((tag, value)),
128            Some(Element::ContainerStart { tag, kind }) => {
129                let v = read_container_value(r, kind)?;
130                out.push((tag, v));
131            }
132            Some(_) => {}
133        }
134    }
135}
136
137/// Reader positioned just after a container start (of `kind`): read the
138/// whole sub-tree into a [`Value`]. Calling this with the reader in any other
139/// position yields an [`error::ImError`] or misattributed members — never a
140/// panic or UB.
141///
142/// # Errors
143///
144/// Propagates any error from [`read_container_members`].
145pub fn read_container_value(
146    r: &mut TlvReader<'_>,
147    kind: ContainerKind,
148) -> Result<Value, error::ImError> {
149    if matches!(kind, ContainerKind::Array) {
150        // Arrays: build the Vec<Value> directly instead of collecting
151        // (Tag, Value) members and re-collecting into a second Vec. Tags on
152        // array children are discarded, as before (lenient; the codec's
153        // tree-builder path is the strict one).
154        let mut elements = Vec::new();
155        loop {
156            match r.next()? {
157                None => {
158                    return Err(error::ImError::Codec(
159                        matter_codec::Error::UnclosedContainer,
160                    ))
161                }
162                Some(Element::ContainerEnd) => return Ok(Value::Array(elements)),
163                Some(Element::Scalar { value, .. }) => elements.push(value),
164                Some(Element::ContainerStart {
165                    kind: inner_kind, ..
166                }) => elements.push(read_container_value(r, inner_kind)?),
167                Some(_) => {}
168            }
169        }
170    }
171    let members = read_container_members(r)?;
172    Ok(match kind {
173        ContainerKind::Structure => Value::Structure(members),
174        // ContainerKind::List and any future non-exhaustive variants: preserve as List.
175        _ => Value::List(members),
176    })
177}
178
179/// Reader positioned just after a container start: discard the whole
180/// sub-tree (used to skip fields we do not consume). Calling this with the
181/// reader in any other position yields an [`error::ImError`] or misattributed
182/// members — never a panic or UB.
183///
184/// Streaming: the skipped bytes are walked structurally (tags, lengths,
185/// depth) but never decoded — string payloads in skipped data are not
186/// UTF-8 validated — and it does not charge the codec's tree-builder
187/// element budget; a discarded payload is bounded by its input size only.
188/// (Deliberate: see the 2026-08-09 performance-remediation spec §3.1.)
189///
190/// # Errors
191///
192/// Propagates any [`matter_codec::Error`] from the underlying streaming
193/// walk (e.g. `UnclosedContainer` on truncated input) as
194/// [`error::ImError::Codec`].
195pub fn skip_container(r: &mut TlvReader<'_>) -> Result<(), error::ImError> {
196    r.skip_container().map_err(error::ImError::Codec)
197}
198
199#[cfg(test)]
200mod tests {
201    #![allow(clippy::unwrap_used)] // Test code: CLAUDE.md carve-out.
202    use super::*;
203    use matter_codec::{Tag, TlvWriter};
204
205    #[test]
206    fn skip_container_leaves_reader_at_next_sibling() {
207        // { deep nested container with mixed scalars } followed by a sentinel uint.
208        let mut buf = Vec::new();
209        let mut w = TlvWriter::new(&mut buf);
210        w.start_structure(Tag::Anonymous).unwrap();
211        w.start_structure(Tag::Context(1)).unwrap(); // the container we skip
212        w.put_uint(Tag::Context(0), 7).unwrap();
213        w.start_array(Tag::Context(1)).unwrap();
214        w.put_bytes(Tag::Anonymous, &[0xAA; 40]).unwrap();
215        w.end_container().unwrap();
216        w.end_container().unwrap(); // ctx1 struct
217        w.put_uint(Tag::Context(2), 42).unwrap(); // sentinel sibling
218        w.end_container().unwrap();
219
220        let mut r = TlvReader::new(&buf);
221        assert!(matches!(
222            r.next().unwrap(),
223            Some(Element::ContainerStart { .. })
224        )); // outer
225        assert!(matches!(
226            r.next().unwrap(),
227            Some(Element::ContainerStart { .. })
228        )); // ctx1
229        skip_container(&mut r).unwrap();
230        // Reader must now be positioned at the sentinel.
231        match r.next().unwrap() {
232            Some(Element::Scalar {
233                tag: Tag::Context(2),
234                value: Value::Uint(42),
235            }) => {}
236            other => panic!("expected sentinel after skip, got {other:?}"),
237        }
238    }
239
240    #[test]
241    fn read_container_value_array_matches_codec_tree_builder_shape() {
242        // Array of mixed scalars + a nested array; tags on array children are
243        // discarded (lenient, unchanged behavior).
244        let mut buf = Vec::new();
245        let mut w = TlvWriter::new(&mut buf);
246        w.start_array(Tag::Anonymous).unwrap();
247        w.put_uint(Tag::Anonymous, 1).unwrap();
248        w.start_array(Tag::Anonymous).unwrap();
249        w.put_uint(Tag::Anonymous, 2).unwrap();
250        w.end_container().unwrap();
251        w.put_utf8(Tag::Anonymous, "x").unwrap();
252        w.end_container().unwrap();
253
254        let mut r = TlvReader::new(&buf);
255        let Some(Element::ContainerStart { kind, .. }) = r.next().unwrap() else {
256            panic!("expected array start");
257        };
258        let v = read_container_value(&mut r, kind).unwrap();
259        assert_eq!(
260            v,
261            Value::Array(vec![
262                Value::Uint(1),
263                Value::Array(vec![Value::Uint(2)]),
264                Value::Utf8(String::from("x")),
265            ])
266        );
267    }
268}
269
270/// Compile-checks the Rust examples in this crate's `README.md`.
271///
272/// `#[cfg(doctest)]` means the item exists only while rustdoc is collecting
273/// doctests, so the README is compiled by `cargo test --doc` without being
274/// duplicated into the rendered crate docs.
275#[cfg(doctest)]
276#[doc = include_str!("../README.md")]
277struct ReadmeDoctests;