Skip to main content

matter_interaction/
path.rs

1//! Concrete IM paths: `CommandPathIB` and `AttributePathIB` — Matter
2//! Appendix A.6.
3
4#![forbid(unsafe_code)]
5
6use crate::error::ImError;
7use matter_codec::{Element, Tag, TlvReader, Value};
8
9/// A concrete command path: `(endpoint, cluster, command)`.
10///
11/// Encoded as a `CommandPathIB` TLV **list** (Matter Appendix A.6):
12/// context tag 0 = endpoint, 1 = cluster, 2 = command.
13#[derive(Copy, Clone, Debug, PartialEq, Eq)]
14pub struct CommandPath {
15    /// Matter endpoint (always 0 for commissioning).
16    pub endpoint: u16,
17    /// Cluster ID.
18    pub cluster: u32,
19    /// Command ID.
20    pub command: u32,
21}
22
23/// A concrete attribute path: `(endpoint, cluster, attribute)`.
24///
25/// Encoded as an `AttributePathIB` TLV **list** (Matter Appendix A.6):
26/// context tag 2 = endpoint, 3 = cluster, 4 = attribute. Commissioning
27/// reads only concrete attributes, so no wildcard/list-index fields are
28/// emitted.
29#[derive(Copy, Clone, Debug, PartialEq, Eq)]
30pub struct AttributePath {
31    /// Matter endpoint.
32    pub endpoint: u16,
33    /// Cluster ID.
34    pub cluster: u32,
35    /// Attribute ID.
36    pub attribute: u32,
37}
38
39/// A read-request attribute path with optional (wildcard) components. A `None`
40/// field is **omitted** from the encoded `AttributePathIB`, which the Matter IM
41/// interprets as a wildcard (Appendix A.6): omit `attribute` → all attributes of
42/// the cluster; omit `endpoint` → all endpoints; etc. Responses are always keyed
43/// by a concrete [`AttributePath`].
44///
45/// `#[non_exhaustive]`: a read/subscribe path may gain optional spec components
46/// (e.g. a data-version filter); marking it keeps such additions non-breaking.
47/// Build via [`ReadPath::concrete`] / [`ReadPath::cluster`] / [`ReadPath::all`]
48/// / [`ReadPath::new`].
49#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
50#[non_exhaustive]
51pub struct ReadPath {
52    /// Endpoint, or `None` for all endpoints.
53    pub endpoint: Option<u16>,
54    /// Cluster, or `None` for all clusters.
55    pub cluster: Option<u32>,
56    /// Attribute, or `None` for all attributes.
57    pub attribute: Option<u32>,
58}
59
60impl ReadPath {
61    /// A read path from raw optional components (a `None` component is a
62    /// wildcard). Prefer [`Self::concrete`] / [`Self::cluster`] / [`Self::all`]
63    /// for the common shapes.
64    #[must_use]
65    pub fn new(endpoint: Option<u16>, cluster: Option<u32>, attribute: Option<u32>) -> Self {
66        Self {
67            endpoint,
68            cluster,
69            attribute,
70        }
71    }
72
73    /// A concrete `(endpoint, cluster, attribute)` path (no wildcards).
74    #[must_use]
75    pub fn concrete(endpoint: u16, cluster: u32, attribute: u32) -> Self {
76        Self {
77            endpoint: Some(endpoint),
78            cluster: Some(cluster),
79            attribute: Some(attribute),
80        }
81    }
82
83    /// All attributes of `cluster` on `endpoint`.
84    #[must_use]
85    pub fn cluster(endpoint: u16, cluster: u32) -> Self {
86        Self {
87            endpoint: Some(endpoint),
88            cluster: Some(cluster),
89            attribute: None,
90        }
91    }
92
93    /// Every attribute on every endpoint/cluster (full wildcard).
94    #[must_use]
95    pub fn all() -> Self {
96        Self {
97            endpoint: None,
98            cluster: None,
99            attribute: None,
100        }
101    }
102}
103
104impl From<AttributePath> for ReadPath {
105    fn from(p: AttributePath) -> Self {
106        Self {
107            endpoint: Some(p.endpoint),
108            cluster: Some(p.cluster),
109            attribute: Some(p.attribute),
110        }
111    }
112}
113
114/// Consume an `AttributePathIB` list body (reader positioned just after the
115/// list's `ContainerStart`) into an [`AttributePath`], without materialising
116/// the members. The `bool` reports a `ListIndex` (context tag 5) equal to
117/// `null`, which in a `ReportData` signals a list **append** (Matter
118/// §10.6.4). Out-of-range values surface as [`ImError::UnexpectedValue`].
119pub(crate) fn attribute_path_from_reader(
120    r: &mut TlvReader<'_>,
121) -> Result<(AttributePath, bool), ImError> {
122    let mut endpoint = None;
123    let mut cluster = None;
124    let mut attribute = None;
125    let mut append = false;
126    loop {
127        match r.next()? {
128            None => {
129                return Err(ImError::Codec(matter_codec::Error::UnclosedContainer));
130            }
131            Some(Element::ContainerEnd) => break,
132            Some(Element::Scalar {
133                tag: Tag::Context(2),
134                value: Value::Uint(n),
135            }) => {
136                endpoint =
137                    Some(u16::try_from(n).map_err(|_| {
138                        ImError::UnexpectedValue("AttributePath.endpoint exceeds u16")
139                    })?);
140            }
141            Some(Element::Scalar {
142                tag: Tag::Context(3),
143                value: Value::Uint(n),
144            }) => {
145                cluster =
146                    Some(u32::try_from(n).map_err(|_| {
147                        ImError::UnexpectedValue("AttributePath.cluster exceeds u32")
148                    })?);
149            }
150            Some(Element::Scalar {
151                tag: Tag::Context(4),
152                value: Value::Uint(n),
153            }) => {
154                attribute = Some(u32::try_from(n).map_err(|_| {
155                    ImError::UnexpectedValue("AttributePath.attribute exceeds u32")
156                })?);
157            }
158            Some(Element::Scalar {
159                tag: Tag::Context(5),
160                value: Value::Null,
161            }) => append = true,
162            Some(Element::ContainerStart { .. }) => crate::skip_container(r)?,
163            Some(_) => {}
164        }
165    }
166    Ok((
167        AttributePath {
168            endpoint: endpoint.ok_or(ImError::MissingField("AttributePath.endpoint"))?,
169            cluster: cluster.ok_or(ImError::MissingField("AttributePath.cluster"))?,
170            attribute: attribute.ok_or(ImError::MissingField("AttributePath.attribute"))?,
171        },
172        append,
173    ))
174}
175
176#[cfg(test)]
177mod tests {
178    #![allow(clippy::unwrap_used)] // Test code: CLAUDE.md carve-out.
179    use super::*;
180    // `Element` / `TlvReader` / `Tag` arrive via `use super::*` (the module's
181    // own matter_codec import); only the writer is extra here.
182    use matter_codec::TlvWriter;
183
184    /// Drive `attribute_path_from_reader` over a writer-built `AttributePathIB`.
185    fn parse(build: impl FnOnce(&mut TlvWriter<'_>)) -> Result<(AttributePath, bool), ImError> {
186        let mut buf = Vec::new();
187        let mut w = TlvWriter::new(&mut buf);
188        w.start_list(Tag::Anonymous).unwrap();
189        build(&mut w);
190        w.end_container().unwrap();
191        let mut r = TlvReader::new(&buf);
192        assert!(matches!(
193            r.next().unwrap(),
194            Some(Element::ContainerStart { .. })
195        ));
196        attribute_path_from_reader(&mut r)
197    }
198
199    #[test]
200    fn streaming_path_parse_matches_member_semantics() {
201        // Normal path + ListIndex null append marker.
202        let (p, append) = parse(|w| {
203            w.put_uint(Tag::Context(2), 1).unwrap();
204            w.put_uint(Tag::Context(3), 0x0006).unwrap();
205            w.put_uint(Tag::Context(4), 0xFFFC).unwrap();
206            w.put_null(Tag::Context(5)).unwrap();
207        })
208        .unwrap();
209        assert_eq!((p.endpoint, p.cluster, p.attribute), (1, 0x0006, 0xFFFC));
210        assert!(append);
211
212        // Duplicate tag: last wins (parity with the member-vec iteration).
213        let (p, _) = parse(|w| {
214            w.put_uint(Tag::Context(2), 1).unwrap();
215            w.put_uint(Tag::Context(2), 2).unwrap();
216            w.put_uint(Tag::Context(3), 6).unwrap();
217            w.put_uint(Tag::Context(4), 0).unwrap();
218        })
219        .unwrap();
220        assert_eq!(p.endpoint, 2);
221
222        // Unknown nested container inside the path list is skipped.
223        let (p, append) = parse(|w| {
224            w.put_uint(Tag::Context(2), 1).unwrap();
225            w.start_structure(Tag::Context(9)).unwrap();
226            w.put_uint(Tag::Context(0), 7).unwrap();
227            w.end_container().unwrap();
228            w.put_uint(Tag::Context(3), 6).unwrap();
229            w.put_uint(Tag::Context(4), 0).unwrap();
230        })
231        .unwrap();
232        assert_eq!(p.cluster, 6);
233        assert!(!append);
234    }
235
236    #[test]
237    fn streaming_path_parse_range_and_missing_errors() {
238        // endpoint exceeding u16 → UnexpectedValue.
239        assert!(matches!(
240            parse(|w| {
241                w.put_uint(Tag::Context(2), 0x0001_0000).unwrap();
242                w.put_uint(Tag::Context(3), 6).unwrap();
243                w.put_uint(Tag::Context(4), 0).unwrap();
244            }),
245            Err(ImError::UnexpectedValue(_))
246        ));
247        // missing attribute → MissingField.
248        assert!(matches!(
249            parse(|w| {
250                w.put_uint(Tag::Context(2), 0).unwrap();
251                w.put_uint(Tag::Context(3), 6).unwrap();
252            }),
253            Err(ImError::MissingField("AttributePath.attribute"))
254        ));
255    }
256
257    #[test]
258    fn truncated_path_body_errors_unclosed_container() {
259        // Build a full valid path, then chop the trailing end-of-container.
260        let mut buf = Vec::new();
261        let mut w = TlvWriter::new(&mut buf);
262        w.start_list(Tag::Anonymous).unwrap();
263        w.put_uint(Tag::Context(2), 1).unwrap();
264        w.put_uint(Tag::Context(3), 6).unwrap();
265        w.put_uint(Tag::Context(4), 0).unwrap();
266        w.end_container().unwrap();
267        buf.pop(); // remove the list's 0x18
268        let mut r = TlvReader::new(&buf);
269        assert!(matches!(
270            r.next().unwrap(),
271            Some(Element::ContainerStart { .. })
272        ));
273        assert!(matches!(
274            attribute_path_from_reader(&mut r),
275            Err(ImError::Codec(matter_codec::Error::UnclosedContainer))
276        ));
277    }
278
279    #[test]
280    fn non_null_list_index_leaves_append_false() {
281        let (path, append) = parse(|w| {
282            w.put_uint(Tag::Context(2), 1).unwrap();
283            w.put_uint(Tag::Context(3), 6).unwrap();
284            w.put_uint(Tag::Context(4), 0).unwrap();
285            w.put_uint(Tag::Context(5), 3).unwrap(); // concrete index, not null
286        })
287        .unwrap();
288        assert_eq!((path.endpoint, path.cluster, path.attribute), (1, 6, 0));
289        assert!(!append, "only ListIndex=null signals append");
290    }
291
292    #[test]
293    fn wrong_typed_member_is_ignored() {
294        // A wrong-typed duplicate AFTER the valid member must not clobber it.
295        let (path, append) = parse(|w| {
296            w.put_uint(Tag::Context(2), 1).unwrap();
297            w.put_uint(Tag::Context(3), 6).unwrap();
298            w.put_uint(Tag::Context(4), 0).unwrap();
299            w.put_utf8(Tag::Context(2), "nope").unwrap(); // ignored
300        })
301        .unwrap();
302        assert_eq!((path.endpoint, path.cluster, path.attribute), (1, 6, 0));
303        assert!(!append);
304    }
305}