Skip to main content

matter_interaction/
write.rs

1//! `WriteRequestMessage` / `WriteResponseMessage` framing — Matter §10.6.
2
3#![forbid(unsafe_code)]
4
5use crate::error::ImError;
6use crate::path::{attribute_path_from_value, AttributePath};
7use crate::status::ImStatus;
8use crate::{expect_message_struct, read_container_members, skip_container, IM_REVISION};
9use matter_codec::{ContainerKind, Element, Tag, TlvReader, TlvWriter, Value};
10
11/// One attribute write: a concrete path plus the pre-encoded data value.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct AttributeWriteRequest {
14    /// Concrete attribute path to write.
15    pub path: AttributePath,
16    /// The attribute value as a standalone anonymous-tagged TLV element
17    /// (e.g. the output of a `matter-clusters` attribute encoder).
18    pub value_tlv: Vec<u8>,
19}
20
21/// Build a `WriteRequestMessage` for one or more concrete attribute writes.
22///
23/// `SuppressResponse` and `TimedRequest` are both `false`; `DataVersion`
24/// and `MoreChunkedMessages` are omitted (no chunking — single-MTU writes
25/// only, per the M7 scope).
26///
27/// # Panics
28///
29/// Panics if a `value_tlv` is not a valid anonymous-tagged TLV element
30/// (i.e. not the output of a codec encode call). The function is
31/// otherwise infallible; `Vec`-backed `TlvWriter` never fails.
32#[must_use]
33pub fn build_write_request(writes: &[AttributeWriteRequest]) -> Vec<u8> {
34    build_write_request_inner(writes, false)
35}
36
37/// Like [`build_write_request`] but sets `TimedRequest = true` — the action half
38/// of a timed interaction, sent on the same exchange after a `TimedRequest`
39/// message (see [`crate::build_timed_request`]).
40#[must_use]
41pub fn build_write_request_timed(writes: &[AttributeWriteRequest]) -> Vec<u8> {
42    build_write_request_inner(writes, true)
43}
44
45#[allow(clippy::expect_used)] // Vec-backed TlvWriter is infallible.
46fn build_write_request_inner(writes: &[AttributeWriteRequest], timed: bool) -> Vec<u8> {
47    let mut buf = Vec::new();
48    let mut w = TlvWriter::new(&mut buf);
49    w.start_structure(Tag::Anonymous)
50        .expect("infallible: vec writer");
51    w.put_bool(Tag::Context(0), false)
52        .expect("infallible: vec writer"); // SuppressResponse
53    w.put_bool(Tag::Context(1), timed)
54        .expect("infallible: vec writer"); // TimedRequest
55    w.start_array(Tag::Context(2))
56        .expect("infallible: vec writer"); // WriteRequests
57    for wr in writes {
58        w.start_structure(Tag::Anonymous)
59            .expect("infallible: vec writer"); // AttributeDataIB
60        w.start_list(Tag::Context(1))
61            .expect("infallible: vec writer"); // Path (AttributePathIB)
62        w.put_uint(Tag::Context(2), u64::from(wr.path.endpoint))
63            .expect("infallible: vec writer");
64        w.put_uint(Tag::Context(3), u64::from(wr.path.cluster))
65            .expect("infallible: vec writer");
66        w.put_uint(Tag::Context(4), u64::from(wr.path.attribute))
67            .expect("infallible: vec writer");
68        w.end_container().expect("infallible: vec writer"); // Path
69        w.put_preencoded(Tag::Context(2), &wr.value_tlv)
70            .expect("infallible: caller passes a valid anonymous-tagged element"); // Data
71        w.end_container().expect("infallible: vec writer"); // AttributeDataIB
72    }
73    w.end_container().expect("infallible: vec writer"); // WriteRequests array
74    w.put_uint(Tag::Context(0xFF), u64::from(IM_REVISION))
75        .expect("infallible: vec writer");
76    w.end_container().expect("infallible: vec writer"); // message struct
77    buf
78}
79
80/// Parse a `WriteResponseMessage` into per-path statuses.
81///
82/// The write response carries one `AttributeStatusIB` per written path —
83/// **including the success case** — so the result is a status per path,
84/// not a single message-level status. A message with no `WriteResponses`
85/// member yields an empty result.
86///
87/// # Errors
88///
89/// Returns [`ImError`] if the message is not a struct, an
90/// `AttributeStatusIB` is missing its path or status, or a path value is
91/// out of range.
92pub fn parse_write_response(bytes: &[u8]) -> Result<Vec<(AttributePath, ImStatus)>, ImError> {
93    let mut r = TlvReader::new(bytes);
94    expect_message_struct(&mut r)?;
95
96    let mut out = Vec::new();
97
98    // Find WriteResponses [0] (array). Absent → empty result.
99    loop {
100        match r.next()? {
101            None | Some(Element::ContainerEnd) => return Ok(out),
102            Some(Element::ContainerStart {
103                tag: Tag::Context(0),
104                kind: ContainerKind::Array,
105            }) => break,
106            Some(Element::ContainerStart { .. }) => skip_container(&mut r)?,
107            Some(_) => {}
108        }
109    }
110
111    // Iterate AttributeStatusIB structs in the array.
112    loop {
113        match r.next()? {
114            None => return Err(ImError::Codec(matter_codec::Error::UnclosedContainer)),
115            Some(Element::ContainerEnd) => break, // end of array
116            Some(Element::ContainerStart {
117                kind: ContainerKind::Structure,
118                ..
119            }) => out.push(parse_attribute_status_ib(&mut r)?),
120            Some(Element::ContainerStart { .. }) => skip_container(&mut r)?,
121            Some(_) => {}
122        }
123    }
124
125    Ok(out)
126}
127
128/// Parse one `AttributeStatusIB` body (reader positioned just after the
129/// struct start): `{ 0: Path(list), 1: StatusIB struct { 0: Status } }`.
130///
131/// Shared with the read path (IM-1): a `ReportData`'s `AttributeStatus [0]` IB
132/// has the identical body, so both the write response and the report path
133/// decode per-path status through here.
134pub(crate) fn parse_attribute_status_ib(
135    r: &mut TlvReader<'_>,
136) -> Result<(AttributePath, ImStatus), ImError> {
137    let mut path = None;
138    let mut status = None;
139    loop {
140        match r.next()? {
141            None => return Err(ImError::Codec(matter_codec::Error::UnclosedContainer)),
142            Some(Element::ContainerEnd) => break,
143            Some(Element::ContainerStart {
144                tag: Tag::Context(0),
145                kind: ContainerKind::List,
146            }) => {
147                let members = read_container_members(r)?;
148                path = Some(attribute_path_from_value(&members)?);
149            }
150            Some(Element::ContainerStart {
151                tag: Tag::Context(1),
152                kind: ContainerKind::Structure,
153            }) => {
154                // StatusIB = { 0: Status (uint), 1: ClusterStatus (ignored) }
155                let members = read_container_members(r)?;
156                // Last value wins for duplicate tags (lenient parsing); real devices never duplicate Status.
157                for (tag, v) in &members {
158                    if let (Tag::Context(0), Value::Uint(n)) = (tag, v) {
159                        let code = u8::try_from(*n)
160                            .map_err(|_| ImError::InvalidStatusCode { code: *n })?;
161                        status = Some(ImStatus::from_u8(code));
162                    }
163                }
164            }
165            Some(Element::ContainerStart { .. }) => skip_container(r)?,
166            Some(_) => {}
167        }
168    }
169    Ok((
170        path.ok_or(ImError::MissingField("AttributeStatusIB.Path"))?,
171        status.ok_or(ImError::MissingField("AttributeStatusIB.Status"))?,
172    ))
173}
174
175/// Reserve for the `MoreChunkedMessages`(ctx3) bool we may add after packing.
176const CHUNK_FLAG_RESERVE: usize = 4;
177
178/// Build one or more `WriteRequestMessage`s that write `element_tlvs` (each a
179/// pre-encoded anonymous-tagged list element) to `path` as a list, splitting
180/// across messages so each stays within `budget` unencrypted bytes.
181///
182/// Chunk 0 is a `ReplaceAll` (path without `ListIndex`; `Data` = an array of
183/// the elements that fit). Remaining elements are emitted as `AppendItem` IBs
184/// (path with `ListIndex`=null). `MoreChunkedMessages` (ctx3) is set on every
185/// message except the last.
186///
187/// When everything fits one message the result is a single `ReplaceAll`
188/// byte-identical to `build_write_request(&[AttributeWriteRequest{path,
189/// value_tlv: <the full array encoded>}])`.
190///
191/// An empty `element_tlvs` yields a single empty-array `ReplaceAll`.
192#[must_use]
193pub fn build_list_write_chunks(
194    path: AttributePath,
195    element_tlvs: &[Vec<u8>],
196    budget: usize,
197    timed: bool,
198) -> Vec<Vec<u8>> {
199    // 1) Greedily fill chunk 0's ReplaceAll array.
200    let mut idx = 0usize;
201    let mut first_batch: Vec<&[u8]> = Vec::new();
202    while idx < element_tlvs.len() {
203        let candidate: Vec<&[u8]> = first_batch
204            .iter()
205            .copied()
206            .chain(std::iter::once(element_tlvs[idx].as_slice()))
207            .collect();
208        if encoded_replace_all_len(path, &candidate, timed) + CHUNK_FLAG_RESERVE > budget
209            && !first_batch.is_empty()
210        {
211            break;
212        }
213        first_batch.push(element_tlvs[idx].as_slice());
214        idx += 1;
215    }
216
217    // Collect remaining elements as AppendItem batches.
218    let mut append_batches: Vec<Vec<&[u8]>> = Vec::new();
219    while idx < element_tlvs.len() {
220        let mut batch: Vec<&[u8]> = Vec::new();
221        while idx < element_tlvs.len() {
222            let candidate: Vec<&[u8]> = batch
223                .iter()
224                .copied()
225                .chain(std::iter::once(element_tlvs[idx].as_slice()))
226                .collect();
227            if encoded_append_len(path, &candidate, timed) + CHUNK_FLAG_RESERVE > budget
228                && !batch.is_empty()
229            {
230                break;
231            }
232            batch.push(element_tlvs[idx].as_slice());
233            idx += 1;
234        }
235        append_batches.push(batch);
236    }
237
238    // 2) Encode each chunk, setting MoreChunkedMessages on all but the last.
239    let total = 1 + append_batches.len();
240    let mut messages: Vec<Vec<u8>> = Vec::with_capacity(total);
241    let first_more = total > 1;
242    messages.push(encode_replace_all(path, &first_batch, timed, first_more));
243    for (i, batch) in append_batches.iter().enumerate() {
244        let more = i + 1 < append_batches.len();
245        messages.push(encode_append_items(path, batch, timed, more));
246    }
247    messages
248}
249
250/// Encode a `ReplaceAll` `WriteRequestMessage` containing one `AttributeDataIB`
251/// whose `Data` is an anonymous array of `elems`.
252#[allow(clippy::expect_used)] // Vec-backed TlvWriter is infallible.
253fn encode_replace_all(
254    path: AttributePath,
255    elems: &[&[u8]],
256    timed: bool,
257    more_chunked: bool,
258) -> Vec<u8> {
259    let mut buf = Vec::new();
260    let mut w = TlvWriter::new(&mut buf);
261    w.start_structure(Tag::Anonymous)
262        .expect("infallible: vec writer");
263    w.put_bool(Tag::Context(0), false)
264        .expect("infallible: vec writer"); // SuppressResponse
265    w.put_bool(Tag::Context(1), timed)
266        .expect("infallible: vec writer"); // TimedRequest
267    w.start_array(Tag::Context(2))
268        .expect("infallible: vec writer"); // WriteRequests
269
270    // One AttributeDataIB — ReplaceAll (no ListIndex in path).
271    w.start_structure(Tag::Anonymous)
272        .expect("infallible: vec writer");
273    w.start_list(Tag::Context(1))
274        .expect("infallible: vec writer"); // Path (AttributePathIB)
275    w.put_uint(Tag::Context(2), u64::from(path.endpoint))
276        .expect("infallible: vec writer");
277    w.put_uint(Tag::Context(3), u64::from(path.cluster))
278        .expect("infallible: vec writer");
279    w.put_uint(Tag::Context(4), u64::from(path.attribute))
280        .expect("infallible: vec writer");
281    w.end_container().expect("infallible: vec writer"); // Path
282                                                        // Data = anonymous array containing the pre-encoded elements.
283    w.start_array(Tag::Context(2))
284        .expect("infallible: vec writer");
285    for e in elems {
286        w.put_preencoded(Tag::Anonymous, e)
287            .expect("infallible: caller passes valid anonymous-tagged elements");
288    }
289    w.end_container().expect("infallible: vec writer"); // Data array
290    w.end_container().expect("infallible: vec writer"); // AttributeDataIB
291
292    w.end_container().expect("infallible: vec writer"); // WriteRequests array
293    if more_chunked {
294        w.put_bool(Tag::Context(3), true)
295            .expect("infallible: vec writer"); // MoreChunkedMessages
296    }
297    w.put_uint(Tag::Context(0xFF), u64::from(IM_REVISION))
298        .expect("infallible: vec writer");
299    w.end_container().expect("infallible: vec writer"); // message struct
300    buf
301}
302
303/// Encode an `AppendItem` `WriteRequestMessage` containing one
304/// `AttributeDataIB` per element — each IB has `ListIndex`=null in its path.
305#[allow(clippy::expect_used)] // Vec-backed TlvWriter is infallible.
306fn encode_append_items(
307    path: AttributePath,
308    elems: &[&[u8]],
309    timed: bool,
310    more_chunked: bool,
311) -> Vec<u8> {
312    let mut buf = Vec::new();
313    let mut w = TlvWriter::new(&mut buf);
314    w.start_structure(Tag::Anonymous)
315        .expect("infallible: vec writer");
316    w.put_bool(Tag::Context(0), false)
317        .expect("infallible: vec writer"); // SuppressResponse
318    w.put_bool(Tag::Context(1), timed)
319        .expect("infallible: vec writer"); // TimedRequest
320    w.start_array(Tag::Context(2))
321        .expect("infallible: vec writer"); // WriteRequests
322
323    for e in elems {
324        w.start_structure(Tag::Anonymous)
325            .expect("infallible: vec writer"); // AttributeDataIB
326        w.start_list(Tag::Context(1))
327            .expect("infallible: vec writer"); // Path (AttributePathIB)
328        w.put_uint(Tag::Context(2), u64::from(path.endpoint))
329            .expect("infallible: vec writer");
330        w.put_uint(Tag::Context(3), u64::from(path.cluster))
331            .expect("infallible: vec writer");
332        w.put_uint(Tag::Context(4), u64::from(path.attribute))
333            .expect("infallible: vec writer");
334        w.put_null(Tag::Context(5)).expect("infallible: vec writer"); // ListIndex=null → AppendItem
335        w.end_container().expect("infallible: vec writer"); // Path
336        w.put_preencoded(Tag::Context(2), e)
337            .expect("infallible: caller passes valid anonymous-tagged elements"); // Data
338        w.end_container().expect("infallible: vec writer"); // AttributeDataIB
339    }
340
341    w.end_container().expect("infallible: vec writer"); // WriteRequests array
342    if more_chunked {
343        w.put_bool(Tag::Context(3), true)
344            .expect("infallible: vec writer"); // MoreChunkedMessages
345    }
346    w.put_uint(Tag::Context(0xFF), u64::from(IM_REVISION))
347        .expect("infallible: vec writer");
348    w.end_container().expect("infallible: vec writer"); // message struct
349    buf
350}
351
352fn encoded_replace_all_len(path: AttributePath, elems: &[&[u8]], timed: bool) -> usize {
353    encode_replace_all(path, elems, timed, false).len()
354}
355
356fn encoded_append_len(path: AttributePath, elems: &[&[u8]], timed: bool) -> usize {
357    encode_append_items(path, elems, timed, false).len()
358}
359
360/// Parse the element TLVs out of a sequence of `WriteRequestMessage`s produced
361/// by [`build_list_write_chunks`], returning them in order.
362///
363/// For a `ReplaceAll` IB (no `ListIndex` in path) the `Data` is an array;
364/// each anonymous array element is re-encoded and pushed. For `AppendItem`
365/// IBs (`ListIndex`=null) the `Data` element (ctx2-tagged) is re-encoded as
366/// anonymous and pushed.
367///
368/// This function is provided for test validation only.
369#[cfg(test)]
370pub(crate) fn reassemble_list_write(chunks: &[Vec<u8>]) -> Vec<Vec<u8>> {
371    let mut out = Vec::new();
372    for chunk in chunks {
373        collect_elements_from_chunk(chunk, &mut out);
374    }
375    out
376}
377
378/// Extract element TLVs from one `WriteRequestMessage` chunk into `out`.
379///
380/// Uses `read_value` to decode each `AttributeDataIB` as a typed `Value`,
381/// then walks the structure to extract elements without needing raw-byte seeks.
382#[cfg(test)]
383#[allow(clippy::expect_used)]
384fn collect_elements_from_chunk(chunk: &[u8], out: &mut Vec<Vec<u8>>) {
385    let mut r = TlvReader::new(chunk);
386    // Enter anonymous message struct.
387    let Ok(Some(Element::ContainerStart {
388        tag: Tag::Anonymous,
389        kind: ContainerKind::Structure,
390    })) = r.next()
391    else {
392        return;
393    };
394
395    // Find WriteRequests (ctx2 array).
396    loop {
397        match r.next() {
398            Ok(Some(Element::ContainerStart {
399                tag: Tag::Context(2),
400                kind: ContainerKind::Array,
401            })) => break,
402            Ok(Some(Element::ContainerStart { .. })) => {
403                let _ = skip_container(&mut r);
404            }
405            Ok(Some(Element::ContainerEnd) | None) | Err(_) => return,
406            Ok(Some(_)) => {}
407        }
408    }
409
410    // Iterate AttributeDataIBs — read each as a full Value so we can inspect
411    // the path and data without a forward-only byte-position API.
412    loop {
413        match r.next() {
414            Ok(Some(Element::ContainerStart {
415                kind: ContainerKind::Structure,
416                ..
417            })) => {
418                if let Ok(members) = read_container_members(&mut r) {
419                    collect_elements_from_ib_members(&members, out);
420                }
421            }
422            Ok(Some(Element::ContainerEnd) | None) => break,
423            Ok(Some(Element::ContainerStart { .. })) => {
424                let _ = skip_container(&mut r);
425            }
426            Ok(Some(_)) | Err(_) => {}
427        }
428    }
429}
430
431/// Walk the decoded `AttributeDataIB` members `[(Tag, Value)]` and push
432/// re-encoded anonymous element TLVs into `out`.
433///
434/// An `AttributeDataIB` has:
435/// - `ctx1` → Path (list): may include `ctx5 Null` (`ListIndex`=null) for `AppendItem`
436/// - `ctx2` → Data
437#[cfg(test)]
438#[allow(clippy::expect_used)]
439fn collect_elements_from_ib_members(members: &[(Tag, Value)], out: &mut Vec<Vec<u8>>) {
440    // Determine whether this is ReplaceAll or AppendItem by inspecting path (ctx1).
441    let mut is_append = false;
442    let mut data_value: Option<&Value> = None;
443
444    for (tag, value) in members {
445        match tag {
446            Tag::Context(1) => {
447                // Path is a list: check for ListIndex=null (ctx5).
448                if let Value::List(path_members) = value {
449                    for (pt, pv) in path_members {
450                        if *pt == Tag::Context(5) && *pv == Value::Null {
451                            is_append = true;
452                        }
453                    }
454                }
455            }
456            Tag::Context(2) => {
457                data_value = Some(value);
458            }
459            _ => {}
460        }
461    }
462
463    let Some(data) = data_value else { return };
464
465    if is_append {
466        // AppendItem: Data is the element itself (re-tagged ctx2 by put_preencoded).
467        // Re-encode it as anonymous-tagged.
468        let mut elem_bytes = Vec::new();
469        let mut w = TlvWriter::new(&mut elem_bytes);
470        w.write_value(Tag::Anonymous, data)
471            .expect("infallible: vec writer");
472        out.push(elem_bytes);
473    } else {
474        // ReplaceAll: Data is an Array; each element is a list element.
475        if let Value::Array(elems) = data {
476            for elem in elems {
477                let mut elem_bytes = Vec::new();
478                let mut w = TlvWriter::new(&mut elem_bytes);
479                w.write_value(Tag::Anonymous, elem)
480                    .expect("infallible: vec writer");
481                out.push(elem_bytes);
482            }
483        }
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    #![allow(clippy::unwrap_used, clippy::expect_used)]
490    use super::*;
491    use matter_codec::{ContainerKind, Element, Tag, TlvReader, TlvWriter, Value};
492
493    /// Encode a string as a standalone anonymous TLV element (stand-in for
494    /// a matter-clusters attribute encoder).
495    fn anon_string(s: &str) -> Vec<u8> {
496        let mut buf = Vec::new();
497        let mut w = TlvWriter::new(&mut buf);
498        w.put_utf8(Tag::Anonymous, s).unwrap();
499        buf
500    }
501
502    #[test]
503    fn write_request_has_expected_structure() {
504        let bytes = build_write_request(&[AttributeWriteRequest {
505            path: AttributePath {
506                endpoint: 0,
507                cluster: 0x28,
508                attribute: 0x05, // NodeLabel
509            },
510            value_tlv: anon_string("matter-rust"),
511        }]);
512        let mut r = TlvReader::new(&bytes);
513        // message struct
514        assert!(matches!(
515            r.next().unwrap(),
516            Some(Element::ContainerStart {
517                tag: Tag::Anonymous,
518                kind: ContainerKind::Structure
519            })
520        ));
521        // SuppressResponse [0] = false
522        assert!(matches!(
523            r.next().unwrap(),
524            Some(Element::Scalar {
525                tag: Tag::Context(0),
526                value: Value::Bool(false)
527            })
528        ));
529        // TimedRequest [1] = false
530        assert!(matches!(
531            r.next().unwrap(),
532            Some(Element::Scalar {
533                tag: Tag::Context(1),
534                value: Value::Bool(false)
535            })
536        ));
537        // WriteRequests [2] array
538        assert!(matches!(
539            r.next().unwrap(),
540            Some(Element::ContainerStart {
541                tag: Tag::Context(2),
542                kind: ContainerKind::Array
543            })
544        ));
545        // AttributeDataIB struct
546        assert!(matches!(
547            r.next().unwrap(),
548            Some(Element::ContainerStart {
549                tag: Tag::Anonymous,
550                kind: ContainerKind::Structure
551            })
552        ));
553        // Path [1] list with 2/3/4
554        assert!(matches!(
555            r.next().unwrap(),
556            Some(Element::ContainerStart {
557                tag: Tag::Context(1),
558                kind: ContainerKind::List
559            })
560        ));
561        assert!(matches!(
562            r.next().unwrap(),
563            Some(Element::Scalar {
564                tag: Tag::Context(2),
565                value: Value::Uint(0)
566            })
567        ));
568        assert!(matches!(
569            r.next().unwrap(),
570            Some(Element::Scalar {
571                tag: Tag::Context(3),
572                value: Value::Uint(0x28)
573            })
574        ));
575        assert!(matches!(
576            r.next().unwrap(),
577            Some(Element::Scalar {
578                tag: Tag::Context(4),
579                value: Value::Uint(0x05)
580            })
581        ));
582    }
583
584    /// Build a `WriteResponseMessage` by hand and parse it back.
585    fn echo_write_response(entries: &[(AttributePath, u8)]) -> Vec<u8> {
586        let mut buf = Vec::new();
587        let mut w = TlvWriter::new(&mut buf);
588        w.start_structure(Tag::Anonymous).unwrap();
589        w.start_array(Tag::Context(0)).unwrap(); // WriteResponses
590        for (p, code) in entries {
591            w.start_structure(Tag::Anonymous).unwrap(); // AttributeStatusIB
592            w.start_list(Tag::Context(0)).unwrap(); // Path
593            w.put_uint(Tag::Context(2), u64::from(p.endpoint)).unwrap();
594            w.put_uint(Tag::Context(3), u64::from(p.cluster)).unwrap();
595            w.put_uint(Tag::Context(4), u64::from(p.attribute)).unwrap();
596            w.end_container().unwrap();
597            w.start_structure(Tag::Context(1)).unwrap(); // StatusIB
598            w.put_uint(Tag::Context(0), u64::from(*code)).unwrap();
599            w.end_container().unwrap();
600            w.end_container().unwrap(); // AttributeStatusIB
601        }
602        w.end_container().unwrap(); // array
603        w.put_uint(Tag::Context(0xFF), 11).unwrap();
604        w.end_container().unwrap();
605        buf
606    }
607
608    #[test]
609    fn parses_success_and_failure_statuses() {
610        let p1 = AttributePath {
611            endpoint: 0,
612            cluster: 0x28,
613            attribute: 0x05,
614        };
615        let p2 = AttributePath {
616            endpoint: 0,
617            cluster: 0x28,
618            attribute: 0x06,
619        };
620        let msg = echo_write_response(&[(p1, 0x00), (p2, 0x01)]);
621        let statuses = parse_write_response(&msg).unwrap();
622        assert_eq!(statuses.len(), 2);
623        assert_eq!(statuses[0], (p1, ImStatus::Success));
624        assert_eq!(statuses[1], (p2, ImStatus::Failure(0x01)));
625    }
626
627    #[test]
628    fn missing_status_is_an_error() {
629        // AttributeStatusIB with a path but no StatusIB.
630        let mut buf = Vec::new();
631        let mut w = TlvWriter::new(&mut buf);
632        w.start_structure(Tag::Anonymous).unwrap();
633        w.start_array(Tag::Context(0)).unwrap();
634        w.start_structure(Tag::Anonymous).unwrap();
635        w.start_list(Tag::Context(0)).unwrap();
636        w.put_uint(Tag::Context(2), 0).unwrap();
637        w.put_uint(Tag::Context(3), 0x28).unwrap();
638        w.put_uint(Tag::Context(4), 0x05).unwrap();
639        w.end_container().unwrap();
640        w.end_container().unwrap();
641        w.end_container().unwrap();
642        w.put_uint(Tag::Context(0xFF), 11).unwrap();
643        w.end_container().unwrap();
644
645        let result = parse_write_response(&buf);
646        assert!(
647            matches!(
648                result,
649                Err(ImError::MissingField("AttributeStatusIB.Status"))
650            ),
651            "expected MissingField, got {result:?}"
652        );
653    }
654
655    #[test]
656    fn empty_message_yields_empty_statuses() {
657        let mut buf = Vec::new();
658        let mut w = TlvWriter::new(&mut buf);
659        w.start_structure(Tag::Anonymous).unwrap();
660        w.put_uint(Tag::Context(0xFF), 11).unwrap();
661        w.end_container().unwrap();
662        let statuses = parse_write_response(&buf).unwrap();
663        assert!(statuses.is_empty());
664    }
665}
666
667#[cfg(test)]
668mod chunk_tests {
669    #![allow(clippy::unwrap_used, clippy::expect_used)]
670    use super::*;
671    use matter_codec::{Tag, TlvWriter, Value};
672    use proptest::prelude::*;
673
674    fn entry_tlv(n: u64) -> Vec<u8> {
675        // a small anonymous-tagged struct standing in for an ACL entry
676        let mut b = Vec::new();
677        let mut w = TlvWriter::new(&mut b);
678        w.write_value(
679            Tag::Anonymous,
680            &Value::Structure(vec![(Tag::Context(1), Value::Uint(n))]),
681        )
682        .unwrap();
683        b
684    }
685
686    fn p() -> AttributePath {
687        AttributePath {
688            endpoint: 0,
689            cluster: 0x001F,
690            attribute: 0x0000,
691        }
692    }
693
694    #[test]
695    fn single_chunk_equals_replace_all_build_write_request() {
696        let elems = vec![entry_tlv(1), entry_tlv(2)];
697        let chunks = build_list_write_chunks(p(), &elems, 4096, false);
698        assert_eq!(chunks.len(), 1);
699        // Byte-identical to a single ReplaceAll write of the full array.
700        let mut arr = Vec::new();
701        let mut w = TlvWriter::new(&mut arr);
702        w.write_value(
703            Tag::Anonymous,
704            &Value::Array(vec![
705                Value::Structure(vec![(Tag::Context(1), Value::Uint(1))]),
706                Value::Structure(vec![(Tag::Context(1), Value::Uint(2))]),
707            ]),
708        )
709        .unwrap();
710        let expected = build_write_request(&[AttributeWriteRequest {
711            path: p(),
712            value_tlv: arr,
713        }]);
714        assert_eq!(
715            chunks[0], expected,
716            "single-chunk output must be byte-identical to build_write_request"
717        );
718    }
719
720    #[test]
721    fn overflow_splits_and_sets_more_chunked() {
722        // tiny budget forces one element per message
723        let elems = vec![entry_tlv(1), entry_tlv(2), entry_tlv(3)];
724        let chunks = build_list_write_chunks(p(), &elems, 40, false);
725        assert!(
726            chunks.len() >= 2,
727            "expected multiple chunks, got {}",
728            chunks.len()
729        );
730        // all but last carry MoreChunkedMessages (ctx3 == true); last does not
731        for (i, c) in chunks.iter().enumerate() {
732            assert_eq!(has_more_chunked(c), i + 1 != chunks.len(), "chunk {i}");
733        }
734    }
735
736    #[test]
737    fn reassemble_roundtrips() {
738        let elems: Vec<Vec<u8>> = (0..7).map(entry_tlv).collect();
739        let chunks = build_list_write_chunks(p(), &elems, 48, false);
740        assert_eq!(reassemble_list_write(&chunks), elems);
741    }
742
743    // test helper: does this WriteRequestMessage carry MoreChunkedMessages(ctx3)=true?
744    fn has_more_chunked(msg: &[u8]) -> bool {
745        use matter_codec::{Element, TlvReader};
746        let mut r = TlvReader::new(msg);
747        // enter the anonymous message struct
748        let _ = r.next();
749        loop {
750            match r.next() {
751                Ok(Some(Element::Scalar {
752                    tag: Tag::Context(3),
753                    value: Value::Bool(b),
754                })) => return b,
755                Ok(Some(Element::ContainerStart { .. })) => {
756                    let _ = super::skip_container(&mut r);
757                }
758                Ok(Some(Element::ContainerEnd) | None) | Err(_) => return false,
759                Ok(Some(_)) => {}
760            }
761        }
762    }
763
764    proptest! {
765        #[test]
766        fn split_reassemble_identity(count in 0usize..30, budget in 30usize..200) {
767            let elems: Vec<Vec<u8>> = (0..count as u64).map(entry_tlv).collect();
768            let chunks = build_list_write_chunks(p(), &elems, budget, false);
769            prop_assert_eq!(reassemble_list_write(&chunks), elems.clone());
770            // MoreChunked invariant
771            for (i, c) in chunks.iter().enumerate() {
772                prop_assert_eq!(has_more_chunked(c), i + 1 != chunks.len());
773            }
774        }
775    }
776}