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::AttributePath;
7#[cfg(test)]
8use crate::read_container_members;
9use crate::status::ImStatus;
10use crate::{expect_message_struct, skip_container, IM_REVISION};
11use matter_codec::{ContainerKind, Element, Tag, TlvReader, TlvWriter, Value};
12
13/// One attribute write: a concrete path plus the pre-encoded data value.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct AttributeWriteRequest {
16    /// Concrete attribute path to write.
17    pub path: AttributePath,
18    /// The attribute value as a standalone anonymous-tagged TLV element
19    /// (e.g. the output of a `matter-clusters` attribute encoder).
20    pub value_tlv: Vec<u8>,
21}
22
23/// Build a `WriteRequestMessage` for one or more concrete attribute writes.
24///
25/// `SuppressResponse` and `TimedRequest` are both `false`; `DataVersion`
26/// and `MoreChunkedMessages` are omitted (no chunking — single-MTU writes
27/// only, per the M7 scope).
28///
29/// # Panics
30///
31/// Panics if a `value_tlv` is not a valid anonymous-tagged TLV element
32/// (i.e. not the output of a codec encode call). The function is
33/// otherwise infallible; `Vec`-backed `TlvWriter` never fails.
34#[must_use]
35pub fn build_write_request(writes: &[AttributeWriteRequest]) -> Vec<u8> {
36    build_write_request_inner(writes, false)
37}
38
39/// Like [`build_write_request`] but sets `TimedRequest = true` — the action half
40/// of a timed interaction, sent on the same exchange after a `TimedRequest`
41/// message (see [`crate::build_timed_request`]).
42#[must_use]
43pub fn build_write_request_timed(writes: &[AttributeWriteRequest]) -> Vec<u8> {
44    build_write_request_inner(writes, true)
45}
46
47#[allow(clippy::expect_used)] // Vec-backed TlvWriter is infallible.
48fn build_write_request_inner(writes: &[AttributeWriteRequest], timed: bool) -> Vec<u8> {
49    let mut buf = Vec::with_capacity(
50        48 + writes
51            .iter()
52            .map(|wr| 24 + wr.value_tlv.len())
53            .sum::<usize>(),
54    );
55    let mut w = TlvWriter::new(&mut buf);
56    w.start_structure(Tag::Anonymous)
57        .expect("infallible: vec writer");
58    w.put_bool(Tag::Context(0), false)
59        .expect("infallible: vec writer"); // SuppressResponse
60    w.put_bool(Tag::Context(1), timed)
61        .expect("infallible: vec writer"); // TimedRequest
62    w.start_array(Tag::Context(2))
63        .expect("infallible: vec writer"); // WriteRequests
64    for wr in writes {
65        w.start_structure(Tag::Anonymous)
66            .expect("infallible: vec writer"); // AttributeDataIB
67        w.start_list(Tag::Context(1))
68            .expect("infallible: vec writer"); // Path (AttributePathIB)
69        w.put_uint(Tag::Context(2), u64::from(wr.path.endpoint))
70            .expect("infallible: vec writer");
71        w.put_uint(Tag::Context(3), u64::from(wr.path.cluster))
72            .expect("infallible: vec writer");
73        w.put_uint(Tag::Context(4), u64::from(wr.path.attribute))
74            .expect("infallible: vec writer");
75        w.end_container().expect("infallible: vec writer"); // Path
76        w.put_preencoded(Tag::Context(2), &wr.value_tlv)
77            .expect("infallible: caller passes a valid anonymous-tagged element"); // Data
78        w.end_container().expect("infallible: vec writer"); // AttributeDataIB
79    }
80    w.end_container().expect("infallible: vec writer"); // WriteRequests array
81    w.put_uint(Tag::Context(0xFF), u64::from(IM_REVISION))
82        .expect("infallible: vec writer");
83    w.end_container().expect("infallible: vec writer"); // message struct
84    buf
85}
86
87/// Parse a `WriteResponseMessage` into per-path statuses.
88///
89/// The write response carries one `AttributeStatusIB` per written path —
90/// **including the success case** — so the result is a status per path,
91/// not a single message-level status. A message with no `WriteResponses`
92/// member yields an empty result.
93///
94/// # Errors
95///
96/// Returns [`ImError`] if the message is not a struct, an
97/// `AttributeStatusIB` is missing its path or status, or a path value is
98/// out of range.
99pub fn parse_write_response(bytes: &[u8]) -> Result<Vec<(AttributePath, ImStatus)>, ImError> {
100    let mut r = TlvReader::new(bytes);
101    expect_message_struct(&mut r)?;
102
103    let mut out = Vec::new();
104
105    // Find WriteResponses [0] (array). Absent → empty result.
106    loop {
107        match r.next()? {
108            None | Some(Element::ContainerEnd) => return Ok(out),
109            Some(Element::ContainerStart {
110                tag: Tag::Context(0),
111                kind: ContainerKind::Array,
112            }) => break,
113            Some(Element::ContainerStart { .. }) => skip_container(&mut r)?,
114            Some(_) => {}
115        }
116    }
117
118    // Iterate AttributeStatusIB structs in the array.
119    loop {
120        match r.next()? {
121            None => return Err(ImError::Codec(matter_codec::Error::UnclosedContainer)),
122            Some(Element::ContainerEnd) => break, // end of array
123            Some(Element::ContainerStart {
124                kind: ContainerKind::Structure,
125                ..
126            }) => out.push(parse_attribute_status_ib(&mut r)?),
127            Some(Element::ContainerStart { .. }) => skip_container(&mut r)?,
128            Some(_) => {}
129        }
130    }
131
132    Ok(out)
133}
134
135/// Parse one `AttributeStatusIB` body (reader positioned just after the
136/// struct start): `{ 0: Path(list), 1: StatusIB struct { 0: Status } }`.
137///
138/// Shared with the read path (IM-1): a `ReportData`'s `AttributeStatus [0]` IB
139/// has the identical body, so both the write response and the report path
140/// decode per-path status through here.
141pub(crate) fn parse_attribute_status_ib(
142    r: &mut TlvReader<'_>,
143) -> Result<(AttributePath, ImStatus), ImError> {
144    let mut path = None;
145    let mut status = None;
146    loop {
147        match r.next()? {
148            None => return Err(ImError::Codec(matter_codec::Error::UnclosedContainer)),
149            Some(Element::ContainerEnd) => break,
150            Some(Element::ContainerStart {
151                tag: Tag::Context(0),
152                kind: ContainerKind::List,
153            }) => {
154                let (p, _) = crate::path::attribute_path_from_reader(r)?;
155                path = Some(p);
156            }
157            Some(Element::ContainerStart {
158                tag: Tag::Context(1),
159                kind: ContainerKind::Structure,
160            }) => {
161                if let Some(s) = parse_status_ib_body(r)? {
162                    status = Some(s);
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/// Consume a `StatusIB` struct body (reader just after its start),
176/// returning the last `Status` (context tag 0) seen, mapped to
177/// [`ImStatus`]. Out-of-range codes error as `InvalidStatusCode`.
178fn parse_status_ib_body(r: &mut TlvReader<'_>) -> Result<Option<ImStatus>, ImError> {
179    let mut status = None;
180    loop {
181        match r.next()? {
182            None => return Err(ImError::Codec(matter_codec::Error::UnclosedContainer)),
183            Some(Element::ContainerEnd) => return Ok(status),
184            Some(Element::Scalar {
185                tag: Tag::Context(0),
186                value: Value::Uint(n),
187            }) => {
188                let code = u8::try_from(n).map_err(|_| ImError::InvalidStatusCode { code: n })?;
189                status = Some(ImStatus::from_u8(code));
190            }
191            Some(Element::ContainerStart { .. }) => skip_container(r)?,
192            Some(_) => {}
193        }
194    }
195}
196
197/// Reserve for the `MoreChunkedMessages`(ctx3) bool we may add after packing.
198/// Covers the 2-byte explicit flag element (control byte + tag byte for a
199/// boolean) whether it ends up encoding `Some(true)` or `Some(false)` — both
200/// cost the same 2 bytes.
201const CHUNK_FLAG_RESERVE: usize = 4;
202
203/// Build one or more `WriteRequestMessage`s that write `element_tlvs` (each a
204/// pre-encoded anonymous-tagged list element) to `path` as a list, splitting
205/// across messages so each stays within `budget` unencrypted bytes.
206///
207/// Chunk 0 is a `ReplaceAll` (path without `ListIndex`; `Data` = an array of
208/// the elements that fit). Remaining elements are emitted as `AppendItem` IBs
209/// (path with `ListIndex`=null).
210///
211/// When the write fits a single message, the result is one `ReplaceAll` with
212/// `MoreChunkedMessages` (ctx3) omitted entirely — byte-identical to
213/// `build_write_request(&[AttributeWriteRequest{path, value_tlv: <the full
214/// array encoded>}])`.
215///
216/// When the write spans multiple messages, `MoreChunkedMessages` is encoded
217/// **explicitly on every chunk**: `true` on all but the last, and an explicit
218/// `false` on the last. This is required for chip interop: chip's
219/// `WriteHandler::ProcessWriteRequest` (connectedhomeip
220/// `src/app/WriteHandler.cpp:649-656`) initializes its parsed
221/// `MoreChunkedMessages` from the *previous* chunk's stored state before
222/// attempting to read the field, so an absent field on the final chunk
223/// silently inherits `true` from chunk N-1 and the device never considers the
224/// write transaction finished. Only the single-message (no-chunking) shape
225/// omits the field, matching `build_write_request`.
226///
227/// An empty `element_tlvs` yields a single empty-array `ReplaceAll`.
228#[must_use]
229pub fn build_list_write_chunks(
230    path: AttributePath,
231    element_tlvs: &[Vec<u8>],
232    budget: usize,
233    timed: bool,
234) -> Vec<Vec<u8>> {
235    // Probe element: a 1-byte anonymous null (0x14). cost = base + wrapper
236    // + probe.len() + 1, so wrapper+1 falls out by subtraction.
237    const PROBE: &[u8] = &[0x14];
238
239    // Incremental size accounting: containers are delimited by end markers
240    // (never length prefixes), so a chunk with elements e_1..e_k encodes to
241    // exactly base + Σ cost(e_i). ReplaceAll cost(e) = e.len() (anonymous
242    // re-tag rewrites the control byte, same size); AppendItem cost(e) =
243    // wrapper + e.len() + 1 (fixed per-path AttributeDataIB wrapper, and the
244    // context re-tag adds one byte). Probe both constants from real encodes
245    // rather than hand-deriving byte counts; the equivalence proptest and
246    // the single-chunk byte-identity test pin correctness.
247    let replace_base = encoded_replace_all_len(path, &[], timed);
248    let append_base = encoded_append_len(path, &[], timed);
249    let append_per_elem_overhead =
250        encoded_append_len(path, &[PROBE], timed) - append_base - PROBE.len();
251
252    // 1) Greedily fill chunk 0's ReplaceAll array.
253    let mut idx = 0usize;
254    let mut first_batch: Vec<&[u8]> = Vec::new();
255    let mut size = replace_base;
256    while idx < element_tlvs.len() {
257        let cost = element_tlvs[idx].len();
258        if size + cost + CHUNK_FLAG_RESERVE > budget && !first_batch.is_empty() {
259            break;
260        }
261        size += cost;
262        first_batch.push(element_tlvs[idx].as_slice());
263        idx += 1;
264    }
265
266    // Collect remaining elements as AppendItem batches.
267    let mut append_batches: Vec<Vec<&[u8]>> = Vec::new();
268    while idx < element_tlvs.len() {
269        let mut batch: Vec<&[u8]> = Vec::new();
270        let mut size = append_base;
271        while idx < element_tlvs.len() {
272            let cost = append_per_elem_overhead + element_tlvs[idx].len();
273            if size + cost + CHUNK_FLAG_RESERVE > budget && !batch.is_empty() {
274                break;
275            }
276            size += cost;
277            batch.push(element_tlvs[idx].as_slice());
278            idx += 1;
279        }
280        append_batches.push(batch);
281    }
282
283    // 2) Encode each chunk. Single-message output omits MoreChunkedMessages
284    // entirely (None); multi-chunk output sets it explicitly on every chunk —
285    // Some(true) on all but the last, Some(false) on the last (chip parity:
286    // see the rustdoc above for why the final chunk cannot merely omit it).
287    // Note: when chunked is true, append_batches is always non-empty (total =
288    // 1 + append_batches.len() > 1), so the first (ReplaceAll) chunk is never
289    // the last chunk — it always carries Some(true).
290    let total = 1 + append_batches.len();
291    let mut messages: Vec<Vec<u8>> = Vec::with_capacity(total);
292    let chunked = total > 1;
293    let first_more = if chunked { Some(true) } else { None };
294    messages.push(encode_replace_all(path, &first_batch, timed, first_more));
295    for (i, batch) in append_batches.iter().enumerate() {
296        let more = Some(i + 1 < append_batches.len());
297        messages.push(encode_append_items(path, batch, timed, more));
298    }
299    messages
300}
301
302/// Encode a `ReplaceAll` `WriteRequestMessage` containing one `AttributeDataIB`
303/// whose `Data` is an anonymous array of `elems`.
304///
305/// `more_chunked`: `None` omits `MoreChunkedMessages` (ctx3) entirely —
306/// the single-chunk shape. `Some(v)` encodes it explicitly, `true` or
307/// `false` alike — required on every chunk of a multi-chunk sequence (see
308/// [`build_list_write_chunks`] rustdoc for why the final chunk cannot omit
309/// an explicit `false`).
310#[allow(clippy::expect_used)] // Vec-backed TlvWriter is infallible.
311fn encode_replace_all(
312    path: AttributePath,
313    elems: &[&[u8]],
314    timed: bool,
315    more_chunked: Option<bool>,
316) -> Vec<u8> {
317    let mut buf = Vec::new();
318    let mut w = TlvWriter::new(&mut buf);
319    w.start_structure(Tag::Anonymous)
320        .expect("infallible: vec writer");
321    w.put_bool(Tag::Context(0), false)
322        .expect("infallible: vec writer"); // SuppressResponse
323    w.put_bool(Tag::Context(1), timed)
324        .expect("infallible: vec writer"); // TimedRequest
325    w.start_array(Tag::Context(2))
326        .expect("infallible: vec writer"); // WriteRequests
327
328    // One AttributeDataIB — ReplaceAll (no ListIndex in path).
329    w.start_structure(Tag::Anonymous)
330        .expect("infallible: vec writer");
331    w.start_list(Tag::Context(1))
332        .expect("infallible: vec writer"); // Path (AttributePathIB)
333    w.put_uint(Tag::Context(2), u64::from(path.endpoint))
334        .expect("infallible: vec writer");
335    w.put_uint(Tag::Context(3), u64::from(path.cluster))
336        .expect("infallible: vec writer");
337    w.put_uint(Tag::Context(4), u64::from(path.attribute))
338        .expect("infallible: vec writer");
339    w.end_container().expect("infallible: vec writer"); // Path
340                                                        // Data = anonymous array containing the pre-encoded elements.
341    w.start_array(Tag::Context(2))
342        .expect("infallible: vec writer");
343    for e in elems {
344        w.put_preencoded(Tag::Anonymous, e)
345            .expect("infallible: caller passes valid anonymous-tagged elements");
346    }
347    w.end_container().expect("infallible: vec writer"); // Data array
348    w.end_container().expect("infallible: vec writer"); // AttributeDataIB
349
350    w.end_container().expect("infallible: vec writer"); // WriteRequests array
351    if let Some(v) = more_chunked {
352        w.put_bool(Tag::Context(3), v)
353            .expect("infallible: vec writer"); // MoreChunkedMessages
354    }
355    w.put_uint(Tag::Context(0xFF), u64::from(IM_REVISION))
356        .expect("infallible: vec writer");
357    w.end_container().expect("infallible: vec writer"); // message struct
358    buf
359}
360
361/// Encode an `AppendItem` `WriteRequestMessage` containing one
362/// `AttributeDataIB` per element — each IB has `ListIndex`=null in its path.
363///
364/// `more_chunked`: see [`encode_replace_all`] — `None` omits
365/// `MoreChunkedMessages`, `Some(v)` encodes it explicitly.
366#[allow(clippy::expect_used)] // Vec-backed TlvWriter is infallible.
367fn encode_append_items(
368    path: AttributePath,
369    elems: &[&[u8]],
370    timed: bool,
371    more_chunked: Option<bool>,
372) -> Vec<u8> {
373    let mut buf = Vec::new();
374    let mut w = TlvWriter::new(&mut buf);
375    w.start_structure(Tag::Anonymous)
376        .expect("infallible: vec writer");
377    w.put_bool(Tag::Context(0), false)
378        .expect("infallible: vec writer"); // SuppressResponse
379    w.put_bool(Tag::Context(1), timed)
380        .expect("infallible: vec writer"); // TimedRequest
381    w.start_array(Tag::Context(2))
382        .expect("infallible: vec writer"); // WriteRequests
383
384    for e in elems {
385        w.start_structure(Tag::Anonymous)
386            .expect("infallible: vec writer"); // AttributeDataIB
387        w.start_list(Tag::Context(1))
388            .expect("infallible: vec writer"); // Path (AttributePathIB)
389        w.put_uint(Tag::Context(2), u64::from(path.endpoint))
390            .expect("infallible: vec writer");
391        w.put_uint(Tag::Context(3), u64::from(path.cluster))
392            .expect("infallible: vec writer");
393        w.put_uint(Tag::Context(4), u64::from(path.attribute))
394            .expect("infallible: vec writer");
395        w.put_null(Tag::Context(5)).expect("infallible: vec writer"); // ListIndex=null → AppendItem
396        w.end_container().expect("infallible: vec writer"); // Path
397        w.put_preencoded(Tag::Context(2), e)
398            .expect("infallible: caller passes valid anonymous-tagged elements"); // Data
399        w.end_container().expect("infallible: vec writer"); // AttributeDataIB
400    }
401
402    w.end_container().expect("infallible: vec writer"); // WriteRequests array
403    if let Some(v) = more_chunked {
404        w.put_bool(Tag::Context(3), v)
405            .expect("infallible: vec writer"); // MoreChunkedMessages
406    }
407    w.put_uint(Tag::Context(0xFF), u64::from(IM_REVISION))
408        .expect("infallible: vec writer");
409    w.end_container().expect("infallible: vec writer"); // message struct
410    buf
411}
412
413/// Size-accounting base: always uses the flag-omitted (`None`) shape. The
414/// caller reserves [`CHUNK_FLAG_RESERVE`] bytes on top to cover the explicit
415/// flag that may be added afterward.
416fn encoded_replace_all_len(path: AttributePath, elems: &[&[u8]], timed: bool) -> usize {
417    encode_replace_all(path, elems, timed, None).len()
418}
419
420/// Size-accounting base: always uses the flag-omitted (`None`) shape. See
421/// [`encoded_replace_all_len`].
422fn encoded_append_len(path: AttributePath, elems: &[&[u8]], timed: bool) -> usize {
423    encode_append_items(path, elems, timed, None).len()
424}
425
426/// Parse the element TLVs out of a sequence of `WriteRequestMessage`s produced
427/// by [`build_list_write_chunks`], returning them in order.
428///
429/// For a `ReplaceAll` IB (no `ListIndex` in path) the `Data` is an array;
430/// each anonymous array element is re-encoded and pushed. For `AppendItem`
431/// IBs (`ListIndex`=null) the `Data` element (ctx2-tagged) is re-encoded as
432/// anonymous and pushed.
433///
434/// This function is provided for test validation only.
435#[cfg(test)]
436pub(crate) fn reassemble_list_write(chunks: &[Vec<u8>]) -> Vec<Vec<u8>> {
437    let mut out = Vec::new();
438    for chunk in chunks {
439        collect_elements_from_chunk(chunk, &mut out);
440    }
441    out
442}
443
444/// Extract element TLVs from one `WriteRequestMessage` chunk into `out`.
445///
446/// Uses `read_value` to decode each `AttributeDataIB` as a typed `Value`,
447/// then walks the structure to extract elements without needing raw-byte seeks.
448#[cfg(test)]
449#[allow(clippy::expect_used)]
450fn collect_elements_from_chunk(chunk: &[u8], out: &mut Vec<Vec<u8>>) {
451    let mut r = TlvReader::new(chunk);
452    // Enter anonymous message struct.
453    let Ok(Some(Element::ContainerStart {
454        tag: Tag::Anonymous,
455        kind: ContainerKind::Structure,
456    })) = r.next()
457    else {
458        return;
459    };
460
461    // Find WriteRequests (ctx2 array).
462    loop {
463        match r.next() {
464            Ok(Some(Element::ContainerStart {
465                tag: Tag::Context(2),
466                kind: ContainerKind::Array,
467            })) => break,
468            Ok(Some(Element::ContainerStart { .. })) => {
469                let _ = skip_container(&mut r);
470            }
471            Ok(Some(Element::ContainerEnd) | None) | Err(_) => return,
472            Ok(Some(_)) => {}
473        }
474    }
475
476    // Iterate AttributeDataIBs — read each as a full Value so we can inspect
477    // the path and data without a forward-only byte-position API.
478    loop {
479        match r.next() {
480            Ok(Some(Element::ContainerStart {
481                kind: ContainerKind::Structure,
482                ..
483            })) => {
484                if let Ok(members) = read_container_members(&mut r) {
485                    collect_elements_from_ib_members(&members, out);
486                }
487            }
488            Ok(Some(Element::ContainerEnd) | None) => break,
489            Ok(Some(Element::ContainerStart { .. })) => {
490                let _ = skip_container(&mut r);
491            }
492            Ok(Some(_)) | Err(_) => {}
493        }
494    }
495}
496
497/// Walk the decoded `AttributeDataIB` members `[(Tag, Value)]` and push
498/// re-encoded anonymous element TLVs into `out`.
499///
500/// An `AttributeDataIB` has:
501/// - `ctx1` → Path (list): may include `ctx5 Null` (`ListIndex`=null) for `AppendItem`
502/// - `ctx2` → Data
503#[cfg(test)]
504#[allow(clippy::expect_used)]
505fn collect_elements_from_ib_members(members: &[(Tag, Value)], out: &mut Vec<Vec<u8>>) {
506    // Determine whether this is ReplaceAll or AppendItem by inspecting path (ctx1).
507    let mut is_append = false;
508    let mut data_value: Option<&Value> = None;
509
510    for (tag, value) in members {
511        match tag {
512            Tag::Context(1) => {
513                // Path is a list: check for ListIndex=null (ctx5).
514                if let Value::List(path_members) = value {
515                    for (pt, pv) in path_members {
516                        if *pt == Tag::Context(5) && *pv == Value::Null {
517                            is_append = true;
518                        }
519                    }
520                }
521            }
522            Tag::Context(2) => {
523                data_value = Some(value);
524            }
525            _ => {}
526        }
527    }
528
529    let Some(data) = data_value else { return };
530
531    if is_append {
532        // AppendItem: Data is the element itself (re-tagged ctx2 by put_preencoded).
533        // Re-encode it as anonymous-tagged.
534        let mut elem_bytes = Vec::new();
535        let mut w = TlvWriter::new(&mut elem_bytes);
536        w.write_value(Tag::Anonymous, data)
537            .expect("infallible: vec writer");
538        out.push(elem_bytes);
539    } else {
540        // ReplaceAll: Data is an Array; each element is a list element.
541        if let Value::Array(elems) = data {
542            for elem in elems {
543                let mut elem_bytes = Vec::new();
544                let mut w = TlvWriter::new(&mut elem_bytes);
545                w.write_value(Tag::Anonymous, elem)
546                    .expect("infallible: vec writer");
547                out.push(elem_bytes);
548            }
549        }
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    #![allow(clippy::unwrap_used, clippy::expect_used)]
556    use super::*;
557    use matter_codec::{ContainerKind, Element, Tag, TlvReader, TlvWriter, Value};
558
559    /// Encode a string as a standalone anonymous TLV element (stand-in for
560    /// a matter-clusters attribute encoder).
561    fn anon_string(s: &str) -> Vec<u8> {
562        let mut buf = Vec::new();
563        let mut w = TlvWriter::new(&mut buf);
564        w.put_utf8(Tag::Anonymous, s).unwrap();
565        buf
566    }
567
568    #[test]
569    fn write_request_has_expected_structure() {
570        let bytes = build_write_request(&[AttributeWriteRequest {
571            path: AttributePath {
572                endpoint: 0,
573                cluster: 0x28,
574                attribute: 0x05, // NodeLabel
575            },
576            value_tlv: anon_string("matter-rust"),
577        }]);
578        let mut r = TlvReader::new(&bytes);
579        // message struct
580        assert!(matches!(
581            r.next().unwrap(),
582            Some(Element::ContainerStart {
583                tag: Tag::Anonymous,
584                kind: ContainerKind::Structure
585            })
586        ));
587        // SuppressResponse [0] = false
588        assert!(matches!(
589            r.next().unwrap(),
590            Some(Element::Scalar {
591                tag: Tag::Context(0),
592                value: Value::Bool(false)
593            })
594        ));
595        // TimedRequest [1] = false
596        assert!(matches!(
597            r.next().unwrap(),
598            Some(Element::Scalar {
599                tag: Tag::Context(1),
600                value: Value::Bool(false)
601            })
602        ));
603        // WriteRequests [2] array
604        assert!(matches!(
605            r.next().unwrap(),
606            Some(Element::ContainerStart {
607                tag: Tag::Context(2),
608                kind: ContainerKind::Array
609            })
610        ));
611        // AttributeDataIB struct
612        assert!(matches!(
613            r.next().unwrap(),
614            Some(Element::ContainerStart {
615                tag: Tag::Anonymous,
616                kind: ContainerKind::Structure
617            })
618        ));
619        // Path [1] list with 2/3/4
620        assert!(matches!(
621            r.next().unwrap(),
622            Some(Element::ContainerStart {
623                tag: Tag::Context(1),
624                kind: ContainerKind::List
625            })
626        ));
627        assert!(matches!(
628            r.next().unwrap(),
629            Some(Element::Scalar {
630                tag: Tag::Context(2),
631                value: Value::Uint(0)
632            })
633        ));
634        assert!(matches!(
635            r.next().unwrap(),
636            Some(Element::Scalar {
637                tag: Tag::Context(3),
638                value: Value::Uint(0x28)
639            })
640        ));
641        assert!(matches!(
642            r.next().unwrap(),
643            Some(Element::Scalar {
644                tag: Tag::Context(4),
645                value: Value::Uint(0x05)
646            })
647        ));
648    }
649
650    /// Build a `WriteResponseMessage` by hand and parse it back.
651    fn echo_write_response(entries: &[(AttributePath, u8)]) -> Vec<u8> {
652        let mut buf = Vec::new();
653        let mut w = TlvWriter::new(&mut buf);
654        w.start_structure(Tag::Anonymous).unwrap();
655        w.start_array(Tag::Context(0)).unwrap(); // WriteResponses
656        for (p, code) in entries {
657            w.start_structure(Tag::Anonymous).unwrap(); // AttributeStatusIB
658            w.start_list(Tag::Context(0)).unwrap(); // Path
659            w.put_uint(Tag::Context(2), u64::from(p.endpoint)).unwrap();
660            w.put_uint(Tag::Context(3), u64::from(p.cluster)).unwrap();
661            w.put_uint(Tag::Context(4), u64::from(p.attribute)).unwrap();
662            w.end_container().unwrap();
663            w.start_structure(Tag::Context(1)).unwrap(); // StatusIB
664            w.put_uint(Tag::Context(0), u64::from(*code)).unwrap();
665            w.end_container().unwrap();
666            w.end_container().unwrap(); // AttributeStatusIB
667        }
668        w.end_container().unwrap(); // array
669        w.put_uint(Tag::Context(0xFF), 11).unwrap();
670        w.end_container().unwrap();
671        buf
672    }
673
674    #[test]
675    fn parses_success_and_failure_statuses() {
676        let p1 = AttributePath {
677            endpoint: 0,
678            cluster: 0x28,
679            attribute: 0x05,
680        };
681        let p2 = AttributePath {
682            endpoint: 0,
683            cluster: 0x28,
684            attribute: 0x06,
685        };
686        let msg = echo_write_response(&[(p1, 0x00), (p2, 0x01)]);
687        let statuses = parse_write_response(&msg).unwrap();
688        assert_eq!(statuses.len(), 2);
689        assert_eq!(statuses[0], (p1, ImStatus::Success));
690        assert_eq!(statuses[1], (p2, ImStatus::Failure(0x01)));
691    }
692
693    #[test]
694    fn missing_status_is_an_error() {
695        // AttributeStatusIB with a path but no StatusIB.
696        let mut buf = Vec::new();
697        let mut w = TlvWriter::new(&mut buf);
698        w.start_structure(Tag::Anonymous).unwrap();
699        w.start_array(Tag::Context(0)).unwrap();
700        w.start_structure(Tag::Anonymous).unwrap();
701        w.start_list(Tag::Context(0)).unwrap();
702        w.put_uint(Tag::Context(2), 0).unwrap();
703        w.put_uint(Tag::Context(3), 0x28).unwrap();
704        w.put_uint(Tag::Context(4), 0x05).unwrap();
705        w.end_container().unwrap();
706        w.end_container().unwrap();
707        w.end_container().unwrap();
708        w.put_uint(Tag::Context(0xFF), 11).unwrap();
709        w.end_container().unwrap();
710
711        let result = parse_write_response(&buf);
712        assert!(
713            matches!(
714                result,
715                Err(ImError::MissingField("AttributeStatusIB.Status"))
716            ),
717            "expected MissingField, got {result:?}"
718        );
719    }
720
721    #[test]
722    fn empty_message_yields_empty_statuses() {
723        let mut buf = Vec::new();
724        let mut w = TlvWriter::new(&mut buf);
725        w.start_structure(Tag::Anonymous).unwrap();
726        w.put_uint(Tag::Context(0xFF), 11).unwrap();
727        w.end_container().unwrap();
728        let statuses = parse_write_response(&buf).unwrap();
729        assert!(statuses.is_empty());
730    }
731
732    /// Drive the private `parse_status_ib_body` over a writer-built `StatusIB`.
733    fn parse_status(build: impl FnOnce(&mut TlvWriter<'_>)) -> Result<Option<ImStatus>, ImError> {
734        let mut buf = Vec::new();
735        let mut w = TlvWriter::new(&mut buf);
736        w.start_structure(Tag::Anonymous).unwrap();
737        build(&mut w);
738        w.end_container().unwrap();
739        let mut r = TlvReader::new(&buf);
740        assert!(matches!(
741            r.next().unwrap(),
742            Some(Element::ContainerStart { .. })
743        ));
744        parse_status_ib_body(&mut r)
745    }
746
747    #[test]
748    fn status_ib_body_parses_status_none_and_range_error() {
749        assert!(matches!(
750            parse_status(|w| w.put_uint(Tag::Context(0), 0).unwrap()),
751            Ok(Some(ImStatus::Success))
752        ));
753        // No status member at all -> Ok(None).
754        assert!(matches!(parse_status(|_| {}), Ok(None)));
755        // Out-of-range code errors.
756        assert!(matches!(
757            parse_status(|w| w.put_uint(Tag::Context(0), 0x1_00).unwrap()),
758            Err(ImError::InvalidStatusCode { code: 0x100 })
759        ));
760        // Unknown nested container is skipped, status still found after it.
761        assert!(matches!(
762            parse_status(|w| {
763                w.start_structure(Tag::Context(7)).unwrap();
764                w.put_uint(Tag::Context(0), 9).unwrap();
765                w.end_container().unwrap();
766                w.put_uint(Tag::Context(0), 0).unwrap();
767            }),
768            Ok(Some(ImStatus::Success))
769        ));
770    }
771}
772
773#[cfg(test)]
774mod chunk_tests {
775    #![allow(clippy::unwrap_used, clippy::expect_used)]
776    use super::*;
777    use matter_codec::{Tag, TlvWriter, Value};
778    use proptest::prelude::*;
779
780    fn entry_tlv(n: u64) -> Vec<u8> {
781        // a small anonymous-tagged struct standing in for an ACL entry
782        let mut b = Vec::new();
783        let mut w = TlvWriter::new(&mut b);
784        w.write_value(
785            Tag::Anonymous,
786            &Value::Structure(vec![(Tag::Context(1), Value::Uint(n))]),
787        )
788        .unwrap();
789        b
790    }
791
792    fn p() -> AttributePath {
793        AttributePath {
794            endpoint: 0,
795            cluster: 0x001F,
796            attribute: 0x0000,
797        }
798    }
799
800    /// Pre-phase-3 packer: re-encodes the whole candidate chunk on every
801    /// element to decide whether it still fits `budget`, so it is O(n²) in
802    /// elements per chunk. Retained verbatim as a proptest oracle to pin
803    /// the incremental packer's output as byte-identical.
804    fn build_list_write_chunks_reference(
805        path: AttributePath,
806        element_tlvs: &[Vec<u8>],
807        budget: usize,
808        timed: bool,
809    ) -> Vec<Vec<u8>> {
810        // 1) Greedily fill chunk 0's ReplaceAll array.
811        let mut idx = 0usize;
812        let mut first_batch: Vec<&[u8]> = Vec::new();
813        while idx < element_tlvs.len() {
814            let candidate: Vec<&[u8]> = first_batch
815                .iter()
816                .copied()
817                .chain(std::iter::once(element_tlvs[idx].as_slice()))
818                .collect();
819            if encoded_replace_all_len(path, &candidate, timed) + CHUNK_FLAG_RESERVE > budget
820                && !first_batch.is_empty()
821            {
822                break;
823            }
824            first_batch.push(element_tlvs[idx].as_slice());
825            idx += 1;
826        }
827
828        // Collect remaining elements as AppendItem batches.
829        let mut append_batches: Vec<Vec<&[u8]>> = Vec::new();
830        while idx < element_tlvs.len() {
831            let mut batch: Vec<&[u8]> = Vec::new();
832            while idx < element_tlvs.len() {
833                let candidate: Vec<&[u8]> = batch
834                    .iter()
835                    .copied()
836                    .chain(std::iter::once(element_tlvs[idx].as_slice()))
837                    .collect();
838                if encoded_append_len(path, &candidate, timed) + CHUNK_FLAG_RESERVE > budget
839                    && !batch.is_empty()
840                {
841                    break;
842                }
843                batch.push(element_tlvs[idx].as_slice());
844                idx += 1;
845            }
846            append_batches.push(batch);
847        }
848
849        // 2) Encode each chunk. Same Option semantics as the incremental
850        // packer: None (omitted) for single-message output, Some(true)/
851        // Some(false) explicitly on every chunk of a multi-chunk sequence.
852        let total = 1 + append_batches.len();
853        let mut messages: Vec<Vec<u8>> = Vec::with_capacity(total);
854        let chunked = total > 1;
855        let first_more = if chunked { Some(true) } else { None };
856        messages.push(encode_replace_all(path, &first_batch, timed, first_more));
857        for (i, batch) in append_batches.iter().enumerate() {
858            let more = Some(i + 1 < append_batches.len());
859            messages.push(encode_append_items(path, batch, timed, more));
860        }
861        messages
862    }
863
864    proptest! {
865        /// The incremental packer must produce byte-identical chunks to the
866        /// pre-phase-3 full-re-encode packer for arbitrary element sets.
867        #[test]
868        fn incremental_packer_matches_reference(
869            lens in proptest::collection::vec(0usize..120, 0..30),
870            budget in 60usize..600,
871            timed: bool,
872        ) {
873            let elems: Vec<Vec<u8>> = lens.iter().map(|&n| {
874                let mut buf = Vec::new();
875                let mut w = TlvWriter::new(&mut buf);
876                w.put_bytes(Tag::Anonymous, &vec![0x5A; n]).unwrap();
877                buf
878            }).collect();
879            let p = p(); // the existing test-path helper at the top of the test module
880            prop_assert_eq!(
881                build_list_write_chunks(p, &elems, budget, timed),
882                build_list_write_chunks_reference(p, &elems, budget, timed)
883            );
884        }
885    }
886
887    #[test]
888    fn single_chunk_equals_replace_all_build_write_request() {
889        let elems = vec![entry_tlv(1), entry_tlv(2)];
890        let chunks = build_list_write_chunks(p(), &elems, 4096, false);
891        assert_eq!(chunks.len(), 1);
892        // Byte-identical to a single ReplaceAll write of the full array.
893        let mut arr = Vec::new();
894        let mut w = TlvWriter::new(&mut arr);
895        w.write_value(
896            Tag::Anonymous,
897            &Value::Array(vec![
898                Value::Structure(vec![(Tag::Context(1), Value::Uint(1))]),
899                Value::Structure(vec![(Tag::Context(1), Value::Uint(2))]),
900            ]),
901        )
902        .unwrap();
903        let expected = build_write_request(&[AttributeWriteRequest {
904            path: p(),
905            value_tlv: arr,
906        }]);
907        assert_eq!(
908            chunks[0], expected,
909            "single-chunk output must be byte-identical to build_write_request"
910        );
911    }
912
913    #[test]
914    fn overflow_splits_and_sets_more_chunked() {
915        // tiny budget forces one element per message
916        let elems = vec![entry_tlv(1), entry_tlv(2), entry_tlv(3)];
917        let chunks = build_list_write_chunks(p(), &elems, 40, false);
918        assert!(
919            chunks.len() >= 2,
920            "expected multiple chunks, got {}",
921            chunks.len()
922        );
923        // every chunk of a multi-chunk sequence carries MoreChunkedMessages
924        // explicitly: Some(true) on all but the last, Some(false) — present,
925        // not merely absent — on the last (chip parity: WriteHandler.cpp
926        // inherits the previous chunk's value when the field is absent).
927        for (i, c) in chunks.iter().enumerate() {
928            assert_eq!(
929                more_chunked_flag(c),
930                Some(i + 1 != chunks.len()),
931                "chunk {i}"
932            );
933        }
934    }
935
936    #[test]
937    fn reassemble_roundtrips() {
938        let elems: Vec<Vec<u8>> = (0..7).map(entry_tlv).collect();
939        let chunks = build_list_write_chunks(p(), &elems, 48, false);
940        assert_eq!(reassemble_list_write(&chunks), elems);
941    }
942
943    /// NEW: forces a 3-chunk output and asserts the MoreChunkedMessages(ctx3)
944    /// presence/value on each chunk, plus that a single-chunk output carries
945    /// no ctx3 element at all.
946    #[test]
947    fn multi_chunk_carries_explicit_flag_final_chunk_explicit_false() {
948        // Budget tight enough that 3 entries each land in their own message:
949        // chunk 0 = ReplaceAll[entry 1], chunk 1 = AppendItem[entry 2],
950        // chunk 2 = AppendItem[entry 3].
951        let elems = vec![entry_tlv(1), entry_tlv(2), entry_tlv(3)];
952        let chunks = build_list_write_chunks(p(), &elems, 40, false);
953        assert_eq!(
954            chunks.len(),
955            3,
956            "expected exactly 3 chunks, got {}",
957            chunks.len()
958        );
959        assert_eq!(more_chunked_flag(&chunks[0]), Some(true), "chunk 0");
960        assert_eq!(more_chunked_flag(&chunks[1]), Some(true), "chunk 1");
961        assert_eq!(
962            more_chunked_flag(&chunks[2]),
963            Some(false),
964            "final chunk must carry an EXPLICIT MoreChunkedMessages=false, not omit it"
965        );
966
967        // A single-chunk (unsplit) write carries no ctx3 element at all.
968        let single = build_list_write_chunks(p(), &[entry_tlv(1)], 4096, false);
969        assert_eq!(single.len(), 1);
970        assert_eq!(
971            more_chunked_flag(&single[0]),
972            None,
973            "single-chunk output must omit MoreChunkedMessages entirely"
974        );
975    }
976
977    /// test helper: does this `WriteRequestMessage` carry `MoreChunkedMessages`
978    /// (ctx3)? `None` = absent, `Some(v)` = explicitly present with value `v`.
979    fn more_chunked_flag(msg: &[u8]) -> Option<bool> {
980        use matter_codec::{Element, TlvReader};
981        let mut r = TlvReader::new(msg);
982        // enter the anonymous message struct
983        let _ = r.next();
984        loop {
985            match r.next() {
986                Ok(Some(Element::Scalar {
987                    tag: Tag::Context(3),
988                    value: Value::Bool(b),
989                })) => return Some(b),
990                Ok(Some(Element::ContainerStart { .. })) => {
991                    let _ = super::skip_container(&mut r);
992                }
993                Ok(Some(Element::ContainerEnd) | None) | Err(_) => return None,
994                Ok(Some(_)) => {}
995            }
996        }
997    }
998
999    proptest! {
1000        #[test]
1001        fn split_reassemble_identity(count in 0usize..30, budget in 30usize..200) {
1002            let elems: Vec<Vec<u8>> = (0..count as u64).map(entry_tlv).collect();
1003            let chunks = build_list_write_chunks(p(), &elems, budget, false);
1004            prop_assert_eq!(reassemble_list_write(&chunks), elems.clone());
1005            // MoreChunked invariant: explicit on every chunk when multi-chunk,
1006            // absent entirely when single-chunk.
1007            for (i, c) in chunks.iter().enumerate() {
1008                let expected = if chunks.len() > 1 {
1009                    Some(i + 1 != chunks.len())
1010                } else {
1011                    None
1012                };
1013                prop_assert_eq!(more_chunked_flag(c), expected);
1014            }
1015        }
1016    }
1017}