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;