Skip to main content

matter_interaction/
invoke.rs

1//! `InvokeRequestMessage` / `InvokeResponseMessage` framing — Matter §10.7.
2
3#![forbid(unsafe_code)]
4
5use crate::error::ImError;
6use crate::path::CommandPath;
7use crate::status::ImStatus;
8use crate::{expect_message_struct, skip_container, IM_REVISION};
9use matter_codec::{ContainerKind, Element, Tag, TlvReader, TlvWriter, Value};
10
11/// Write a `CommandPathIB` (a TLV **list**: 0=endpoint, 1=cluster,
12/// 2=command) under `tag`.
13#[allow(clippy::expect_used, clippy::missing_panics_doc)] // Vec-backed TlvWriter is infallible.
14pub(crate) fn write_command_path(w: &mut TlvWriter<'_>, tag: Tag, path: CommandPath) {
15    w.start_list(tag).expect("infallible: vec writer");
16    w.put_uint(Tag::Context(0), u64::from(path.endpoint))
17        .expect("infallible: vec writer");
18    w.put_uint(Tag::Context(1), u64::from(path.cluster))
19        .expect("infallible: vec writer");
20    w.put_uint(Tag::Context(2), u64::from(path.command))
21        .expect("infallible: vec writer");
22    w.end_container().expect("infallible: vec writer");
23}
24
25/// Build an `InvokeRequestMessage` carrying a single command.
26///
27/// `command_fields_tlv` is the already-encoded command-fields struct
28/// (e.g. the output of `crate::noc::encode_csr_request`); it is embedded
29/// verbatim as the `CommandFields` member. `SuppressResponse` and
30/// `TimedRequest` are both `false`.
31///
32/// # Panics
33///
34/// Panics if `command_fields_tlv` is not a valid anonymous-tagged TLV
35/// element (i.e. not the output of a codec encode call). The function is
36/// otherwise infallible; `Vec`-backed `TlvWriter` never fails.
37#[must_use]
38pub fn build_invoke_request(path: CommandPath, command_fields_tlv: &[u8]) -> Vec<u8> {
39    build_invoke_request_inner(path, command_fields_tlv, false, false)
40}
41
42/// Like [`build_invoke_request`] but sets `TimedRequest = true` — the action half
43/// of a timed interaction, sent on the same exchange after a `TimedRequest`
44/// message (see [`crate::build_timed_request`]).
45///
46/// # Panics
47///
48/// As [`build_invoke_request`] (invalid `command_fields_tlv`).
49#[must_use]
50pub fn build_invoke_request_timed(path: CommandPath, command_fields_tlv: &[u8]) -> Vec<u8> {
51    build_invoke_request_inner(path, command_fields_tlv, true, false)
52}
53
54/// Like [`build_invoke_request`] but sets `SuppressResponse = true` — the form
55/// used for **group** (multicast) invokes. Group commands are unacknowledged at
56/// the IM layer: there is no return path for a multicast send, so the request
57/// must instruct the receiving devices to suppress any `InvokeResponse`
58/// (Matter Core Spec §8.9.2 / §10.7.2 — group commands carry `SuppressResponse`).
59/// `TimedRequest` is `false` (timed interactions are not available on group
60/// sends).
61///
62/// # Panics
63///
64/// As [`build_invoke_request`] (invalid `command_fields_tlv`).
65#[must_use]
66pub fn build_invoke_request_group(path: CommandPath, command_fields_tlv: &[u8]) -> Vec<u8> {
67    build_invoke_request_inner(path, command_fields_tlv, false, true)
68}
69
70#[allow(clippy::expect_used)] // Vec-backed TlvWriter is infallible.
71fn build_invoke_request_inner(
72    path: CommandPath,
73    command_fields_tlv: &[u8],
74    timed: bool,
75    suppress_response: bool,
76) -> Vec<u8> {
77    let mut buf = Vec::with_capacity(48 + command_fields_tlv.len());
78    let mut w = TlvWriter::new(&mut buf);
79    w.start_structure(Tag::Anonymous)
80        .expect("infallible: vec writer");
81    w.put_bool(Tag::Context(0), suppress_response)
82        .expect("infallible: vec writer"); // SuppressResponse
83    w.put_bool(Tag::Context(1), timed)
84        .expect("infallible: vec writer"); // TimedRequest
85    w.start_array(Tag::Context(2))
86        .expect("infallible: vec writer"); // InvokeRequests
87    {
88        w.start_structure(Tag::Anonymous)
89            .expect("infallible: vec writer"); // CommandDataIB
90        write_command_path(&mut w, Tag::Context(0), path);
91        w.put_preencoded(Tag::Context(1), command_fields_tlv)
92            .expect("infallible: caller passes a valid anonymous-tagged struct");
93        w.end_container().expect("infallible: vec writer"); // CommandDataIB
94    }
95    w.end_container().expect("infallible: vec writer"); // InvokeRequests array
96    w.put_uint(Tag::Context(0xFF), u64::from(IM_REVISION))
97        .expect("infallible: vec writer");
98    w.end_container().expect("infallible: vec writer"); // message struct
99    buf
100}
101
102/// Build an `InvokeRequestMessage` carrying **multiple** commands, each tagged
103/// with a sequential `CommandRef` (`CommandDataIB` tag 2) so the device's
104/// responses can be matched back. `SuppressResponse` and `TimedRequest` are
105/// `false`. Each tuple is `(path, command_fields_tlv)`, the fields an
106/// anonymous-tagged TLV blob (e.g. a `matter-clusters` command encoder output).
107///
108/// NB: the wire format permits a batch, but a device only accepts more than one
109/// command if it advertises `MaxPathsPerInvoke > 1` in its `SessionParameters`;
110/// the controller-side gating is deferred (M9-B5 scope) — callers must respect it.
111///
112/// # Panics
113///
114/// As [`build_invoke_request`] (a `command_fields_tlv` that is not a valid
115/// anonymous-tagged TLV element).
116#[must_use]
117#[allow(clippy::expect_used)] // Vec-backed TlvWriter is infallible.
118pub fn build_invoke_request_batch(commands: &[(CommandPath, &[u8])]) -> Vec<u8> {
119    let mut buf = Vec::with_capacity(32 + commands.iter().map(|c| 32 + c.1.len()).sum::<usize>());
120    let mut w = TlvWriter::new(&mut buf);
121    w.start_structure(Tag::Anonymous)
122        .expect("infallible: vec writer");
123    w.put_bool(Tag::Context(0), false)
124        .expect("infallible: vec writer"); // SuppressResponse
125    w.put_bool(Tag::Context(1), false)
126        .expect("infallible: vec writer"); // TimedRequest
127    w.start_array(Tag::Context(2))
128        .expect("infallible: vec writer"); // InvokeRequests
129    for (i, (path, fields)) in commands.iter().enumerate() {
130        w.start_structure(Tag::Anonymous)
131            .expect("infallible: vec writer"); // CommandDataIB
132        write_command_path(&mut w, Tag::Context(0), *path);
133        w.put_preencoded(Tag::Context(1), fields)
134            .expect("infallible: caller passes a valid anonymous-tagged struct");
135        // CommandRef (tag 2): the index. `try_from` is total for any realistic
136        // batch; cap defensively rather than panic on an absurd one.
137        let cref = u16::try_from(i).unwrap_or(u16::MAX);
138        w.put_uint(Tag::Context(2), u64::from(cref))
139            .expect("infallible: vec writer");
140        w.end_container().expect("infallible: vec writer"); // CommandDataIB
141    }
142    w.end_container().expect("infallible: vec writer"); // InvokeRequests array
143    w.put_uint(Tag::Context(0xFF), u64::from(IM_REVISION))
144        .expect("infallible: vec writer");
145    w.end_container().expect("infallible: vec writer"); // message struct
146    buf
147}
148
149/// Outcome of parsing a single-command `InvokeResponseMessage`.
150#[derive(Clone, Debug, PartialEq, Eq)]
151pub enum InvokeResponse {
152    /// The device returned a response command. `fields_tlv` is the
153    /// re-anonymised `CommandFields` struct, ready to hand to
154    /// `Commissioner::on_response`.
155    Command {
156        /// Path of the response command (`(endpoint, cluster, command)`).
157        path: CommandPath,
158        /// The device's original `CommandFields` bytes, **verbatim**, under
159        /// a fresh anonymous tag: only the container's own control/tag bytes
160        /// are replaced, the body is copied unexamined. Original integer
161        /// widths are preserved, and so is everything else the device sent —
162        /// which has three consequences for consumers:
163        ///
164        /// - A localized-string suffix (element type `0x1F`, IS1) survives in
165        ///   the blob rather than being dropped by a re-encode. Decoded
166        ///   `Value`s are unchanged: the downstream decoder still truncates at
167        ///   the IS1 separator.
168        /// - Invalid UTF-8 inside `CommandFields` is **not** rejected here —
169        ///   the copy never decodes it — so it surfaces from your own decoder
170        ///   instead of from IM parsing.
171        /// - An off-spec `Array` whose children carry non-anonymous tags is
172        ///   copied through as-is and fails in your decoder with
173        ///   `NonAnonymousArrayTag`; the older decode-then-re-encode path
174        ///   silently normalised those tags away.
175        fields_tlv: Vec<u8>,
176    },
177    /// The device returned a bare status (no response command payload).
178    Status(ImStatus),
179}
180
181/// One parsed `InvokeResponseIB` from a batched response, with its `CommandRef`
182/// (`CommandDataIB` / `CommandStatusIB` tag 2) for matching to the request command.
183#[derive(Clone, Debug, PartialEq, Eq)]
184pub struct InvokeResponseEntry {
185    /// The `CommandRef` echoed by the device, if present.
186    pub command_ref: Option<u16>,
187    /// The response: a command payload or a status.
188    pub response: InvokeResponse,
189}
190
191/// Copy the body of the container whose `ContainerStart` was just returned
192/// (reader positioned right after it) under a fresh **anonymous** header of
193/// the same kind. The copied span excludes the original element's control
194/// and tag bytes and includes its end-of-container marker, so the result is
195/// a standalone anonymous-tagged TLV blob with the device's original byte
196/// widths preserved verbatim.
197///
198/// The copied bytes are NOT UTF-8-revalidated here: string payloads inside
199/// the span pass through verbatim, and validation defers to whatever decoder
200/// eventually consumes the blob.
201///
202/// # Errors
203///
204/// Any error from [`TlvReader::skip_container_span`] — including its
205/// precondition: the immediately preceding `next()` must have returned the
206/// `ContainerStart` being retagged. After an error the reader state is
207/// unspecified; abandon the parse.
208#[allow(clippy::expect_used)] // Vec-backed TlvWriter is infallible (repo idiom).
209pub(crate) fn retag_container_anonymous(
210    r: &mut TlvReader<'_>,
211    kind: ContainerKind,
212) -> Result<Vec<u8>, ImError> {
213    let span = r.skip_container_span().map_err(ImError::Codec)?;
214    let body = r.span_bytes(span.body());
215    let mut out = Vec::with_capacity(1 + body.len());
216    {
217        let mut w = TlvWriter::new(&mut out);
218        match kind {
219            ContainerKind::Structure => w.start_structure(Tag::Anonymous),
220            ContainerKind::Array => w.start_array(Tag::Anonymous),
221            // List and any future non-exhaustive kinds re-emit as a list,
222            // mirroring read_container_value's fallback.
223            _ => w.start_list(Tag::Anonymous),
224        }
225        .expect("infallible: vec writer");
226    }
227    out.extend_from_slice(body);
228    Ok(out)
229}
230
231/// An anonymous empty structure (`0x15 0x18`) — the canonical stand-in when
232/// a `CommandDataIB` carries no `CommandFields` member, so callers always
233/// receive a valid TLV blob.
234#[allow(clippy::expect_used)] // Vec-backed TlvWriter is infallible (repo idiom).
235fn empty_anonymous_struct() -> Vec<u8> {
236    let mut out = Vec::with_capacity(2);
237    {
238        let mut w = TlvWriter::new(&mut out);
239        w.start_structure(Tag::Anonymous)
240            .expect("infallible: vec writer");
241        w.end_container().expect("infallible: vec writer");
242    }
243    out
244}
245
246/// Consume a `CommandPathIB` list body (reader positioned just after the
247/// list's `ContainerStart`) into a [`CommandPath`], without materialising
248/// the members.
249pub(crate) fn command_path_from_reader(r: &mut TlvReader<'_>) -> Result<CommandPath, ImError> {
250    let mut endpoint = None;
251    let mut cluster = None;
252    let mut command = None;
253    loop {
254        match r.next()? {
255            None => {
256                return Err(ImError::Codec(matter_codec::Error::UnclosedContainer));
257            }
258            Some(Element::ContainerEnd) => break,
259            Some(Element::Scalar {
260                tag: Tag::Context(0),
261                value: Value::Uint(n),
262            }) => {
263                endpoint =
264                    Some(u16::try_from(n).map_err(|_| {
265                        ImError::UnexpectedValue("CommandPath.endpoint exceeds u16")
266                    })?);
267            }
268            Some(Element::Scalar {
269                tag: Tag::Context(1),
270                value: Value::Uint(n),
271            }) => {
272                cluster =
273                    Some(u32::try_from(n).map_err(|_| {
274                        ImError::UnexpectedValue("CommandPath.cluster exceeds u32")
275                    })?);
276            }
277            Some(Element::Scalar {
278                tag: Tag::Context(2),
279                value: Value::Uint(n),
280            }) => {
281                command =
282                    Some(u32::try_from(n).map_err(|_| {
283                        ImError::UnexpectedValue("CommandPath.command exceeds u32")
284                    })?);
285            }
286            Some(Element::ContainerStart { .. }) => crate::skip_container(r)?,
287            Some(_) => {}
288        }
289    }
290    Ok(CommandPath {
291        endpoint: endpoint.ok_or(ImError::MissingField("CommandPath.endpoint"))?,
292        cluster: cluster.ok_or(ImError::MissingField("CommandPath.cluster"))?,
293        command: command.ok_or(ImError::MissingField("CommandPath.command"))?,
294    })
295}
296
297/// Parse a single-command `InvokeResponseMessage`.
298///
299/// Reads the first `InvokeResponseIB` in the `InvokeResponses` array and
300/// returns either its response-command payload or its status. Additional
301/// `InvokeResponseIB`s (not produced by commissioning) are ignored.
302///
303/// # Errors
304///
305/// Returns [`ImError`] if the message is not a struct, lacks the
306/// `InvokeResponses` array, or the first IB has neither Command nor Status.
307pub fn parse_invoke_response(bytes: &[u8]) -> Result<InvokeResponse, ImError> {
308    let mut r = TlvReader::new(bytes);
309    expect_message_struct(&mut r)?;
310
311    loop {
312        match r.next()? {
313            None | Some(Element::ContainerEnd) => {
314                return Err(ImError::MissingField("InvokeResponses"))
315            }
316            Some(Element::ContainerStart {
317                tag: Tag::Context(1),
318                kind: ContainerKind::Array,
319            }) => break,
320            Some(Element::ContainerStart { .. }) => skip_container(&mut r)?,
321            Some(_) => {}
322        }
323    }
324
325    match r.next()? {
326        Some(Element::ContainerStart {
327            kind: ContainerKind::Structure,
328            ..
329        }) => {}
330        _ => return Err(ImError::MissingField("InvokeResponseIB")),
331    }
332
333    loop {
334        match r.next()? {
335            None | Some(Element::ContainerEnd) => return Err(ImError::EmptyInvokeResponse),
336            Some(Element::ContainerStart {
337                tag: Tag::Context(0),
338                kind: ContainerKind::Structure,
339            }) => {
340                return parse_command_data(&mut r).map(|(path, fields)| InvokeResponse::Command {
341                    path,
342                    fields_tlv: fields,
343                });
344            }
345            Some(Element::ContainerStart {
346                tag: Tag::Context(1),
347                kind: ContainerKind::Structure,
348            }) => {
349                return parse_command_status(&mut r).map(InvokeResponse::Status);
350            }
351            Some(Element::ContainerStart { .. }) => skip_container(&mut r)?,
352            Some(_) => {}
353        }
354    }
355}
356
357/// Parse a multi-command `InvokeResponseMessage`: every `InvokeResponseIB`, each
358/// with its `CommandRef`. The single-command [`parse_invoke_response`] is retained
359/// for the commissioning path (reads only the first IB, ignores `CommandRef`).
360///
361/// # Errors
362///
363/// Returns [`ImError`] if the message is not a struct, lacks the
364/// `InvokeResponses` array, or an IB has neither Command nor Status.
365pub fn parse_invoke_response_batch(bytes: &[u8]) -> Result<Vec<InvokeResponseEntry>, ImError> {
366    let mut r = TlvReader::new(bytes);
367    expect_message_struct(&mut r)?;
368    // Advance to the InvokeResponses array (context tag 1).
369    loop {
370        match r.next()? {
371            None | Some(Element::ContainerEnd) => {
372                return Err(ImError::MissingField("InvokeResponses"))
373            }
374            Some(Element::ContainerStart {
375                tag: Tag::Context(1),
376                kind: ContainerKind::Array,
377            }) => break,
378            Some(Element::ContainerStart { .. }) => skip_container(&mut r)?,
379            Some(_) => {}
380        }
381    }
382    let mut out = Vec::new();
383    loop {
384        match r.next()? {
385            None => return Err(ImError::Codec(matter_codec::Error::UnclosedContainer)),
386            Some(Element::ContainerEnd) => return Ok(out), // end of array
387            Some(Element::ContainerStart {
388                kind: ContainerKind::Structure,
389                ..
390            }) => out.push(parse_invoke_response_ib(&mut r)?),
391            Some(Element::ContainerStart { .. }) => skip_container(&mut r)?,
392            Some(_) => {}
393        }
394    }
395}
396
397/// Parse one `InvokeResponseIB` body into an [`InvokeResponseEntry`] (reader
398/// positioned just after its struct start). Drains the **entire** IB (through its
399/// matching `ContainerEnd`) so the caller's array walk stays in sync.
400fn parse_invoke_response_ib(r: &mut TlvReader<'_>) -> Result<InvokeResponseEntry, ImError> {
401    let mut entry: Option<InvokeResponseEntry> = None;
402    loop {
403        match r.next()? {
404            None => return Err(ImError::Codec(matter_codec::Error::UnclosedContainer)),
405            Some(Element::ContainerEnd) => break, // end of this InvokeResponseIB
406            // Command = CommandDataIB
407            Some(Element::ContainerStart {
408                tag: Tag::Context(0),
409                kind: ContainerKind::Structure,
410            }) => {
411                let (path, fields, command_ref) = parse_command_data_ref(r)?;
412                entry = Some(InvokeResponseEntry {
413                    command_ref,
414                    response: InvokeResponse::Command {
415                        path,
416                        fields_tlv: fields,
417                    },
418                });
419            }
420            // Status = CommandStatusIB
421            Some(Element::ContainerStart {
422                tag: Tag::Context(1),
423                kind: ContainerKind::Structure,
424            }) => {
425                let (status, command_ref) = parse_command_status_ref(r)?;
426                entry = Some(InvokeResponseEntry {
427                    command_ref,
428                    response: InvokeResponse::Status(status),
429                });
430            }
431            Some(Element::ContainerStart { .. }) => skip_container(r)?,
432            Some(_) => {}
433        }
434    }
435    entry.ok_or(ImError::EmptyInvokeResponse)
436}
437
438/// Parse a `CommandDataIB` body (reader positioned just after its struct
439/// start), returning `(path, anonymous-tagged CommandFields bytes)`. The single-
440/// command path ignores the `CommandRef`; [`parse_command_data_ref`] captures it.
441fn parse_command_data(r: &mut TlvReader<'_>) -> Result<(CommandPath, Vec<u8>), ImError> {
442    let (path, fields, _ref) = parse_command_data_ref(r)?;
443    Ok((path, fields))
444}
445
446/// Like [`parse_command_data`] but also captures the `CommandRef` (tag 2).
447fn parse_command_data_ref(
448    r: &mut TlvReader<'_>,
449) -> Result<(CommandPath, Vec<u8>, Option<u16>), ImError> {
450    let mut path = None;
451    let mut fields = Vec::new();
452    let mut command_ref = None;
453    loop {
454        match r.next()? {
455            None => return Err(ImError::MissingField("CommandDataIB.body")),
456            Some(Element::ContainerEnd) => break,
457            Some(Element::ContainerStart {
458                tag: Tag::Context(0),
459                kind: ContainerKind::List,
460            }) => {
461                path = Some(command_path_from_reader(r)?);
462            }
463            Some(Element::ContainerStart {
464                tag: Tag::Context(1),
465                kind,
466            }) => {
467                fields = retag_container_anonymous(r, kind)?;
468            }
469            // CommandRef (tag 2), a scalar uint.
470            Some(Element::Scalar {
471                tag: Tag::Context(2),
472                value: Value::Uint(n),
473            }) => command_ref = u16::try_from(n).ok(),
474            Some(Element::ContainerStart { .. }) => skip_container(r)?,
475            Some(_) => {}
476        }
477    }
478    // If no CommandFields member was present, `fields` is an empty Vec, which
479    // is not valid TLV. Canonicalize to an anonymous empty struct so callers
480    // always receive a valid TLV blob.
481    let fields = if fields.is_empty() {
482        empty_anonymous_struct()
483    } else {
484        fields
485    };
486    Ok((
487        path.ok_or(ImError::MissingField("CommandDataIB.CommandPath"))?,
488        fields,
489        command_ref,
490    ))
491}
492
493/// Parse a `CommandStatusIB` body, returning the `StatusIB.Status` mapped
494/// to [`ImStatus`]. The single-command path ignores the `CommandRef`;
495/// [`parse_command_status_ref`] captures it.
496fn parse_command_status(r: &mut TlvReader<'_>) -> Result<ImStatus, ImError> {
497    let (status, _ref) = parse_command_status_ref(r)?;
498    Ok(status)
499}
500
501/// Like [`parse_command_status`] but also captures the `CommandRef` (tag 2).
502fn parse_command_status_ref(r: &mut TlvReader<'_>) -> Result<(ImStatus, Option<u16>), ImError> {
503    // `None` ⇒ the Status member was never seen (genuinely missing).
504    // `Some(raw)` ⇒ the member was present; `raw` is the verbatim wire value,
505    // which we range-check to a `u8` only after the parse loop so an
506    // out-of-range value reports as `InvalidStatusCode`, not `MissingField`.
507    let mut status: Option<u64> = None;
508    let mut command_ref = None;
509    loop {
510        match r.next()? {
511            None => return Err(ImError::MissingField("CommandStatusIB.body")),
512            Some(Element::ContainerEnd) => break,
513            Some(Element::ContainerStart {
514                tag: Tag::Context(1),
515                kind: ContainerKind::Structure,
516            }) => {
517                // StatusIB body: last Status (ctx 0) wins; range-checked to u8
518                // only after the parse loop (see `status` doc above).
519                loop {
520                    match r.next()? {
521                        None => return Err(ImError::Codec(matter_codec::Error::UnclosedContainer)),
522                        Some(Element::ContainerEnd) => break,
523                        Some(Element::Scalar {
524                            tag: Tag::Context(0),
525                            value: Value::Uint(n),
526                        }) => status = Some(n),
527                        Some(Element::ContainerStart { .. }) => skip_container(r)?,
528                        Some(_) => {}
529                    }
530                }
531            }
532            // CommandRef (tag 2), a scalar uint.
533            Some(Element::Scalar {
534                tag: Tag::Context(2),
535                value: Value::Uint(n),
536            }) => command_ref = u16::try_from(n).ok(),
537            Some(Element::ContainerStart { .. }) => skip_container(r)?,
538            Some(_) => {}
539        }
540    }
541    let raw = status.ok_or(ImError::MissingField("StatusIB.Status"))?;
542    let code = u8::try_from(raw).map_err(|_| ImError::InvalidStatusCode { code: raw })?;
543    Ok((ImStatus::from_u8(code), command_ref))
544}
545
546#[cfg(test)]
547mod tests {
548    // Test-code carve-out: see CLAUDE.md.
549    #![allow(clippy::unwrap_used, clippy::expect_used)]
550
551    use super::*;
552    use matter_codec::{ContainerKind, Element, Tag, TlvReader, Value};
553
554    #[test]
555    fn invoke_request_has_expected_structure() {
556        // ArmFailSafe-like: endpoint 0, cluster 0x0030, command 0x00,
557        // command fields = an empty anonymous struct (0x15 0x18).
558        let fields = vec![0x15, 0x18];
559        let bytes = build_invoke_request(
560            CommandPath {
561                endpoint: 0,
562                cluster: 0x0030,
563                command: 0x00,
564            },
565            &fields,
566        );
567
568        let mut r = TlvReader::new(&bytes);
569        // Top-level InvokeRequestMessage struct (anonymous).
570        assert!(matches!(
571            r.next().unwrap(),
572            Some(Element::ContainerStart {
573                tag: Tag::Anonymous,
574                kind: ContainerKind::Structure
575            })
576        ));
577        // SuppressResponse = false.
578        assert!(matches!(
579            r.next().unwrap(),
580            Some(Element::Scalar {
581                tag: Tag::Context(0),
582                value: Value::Bool(false)
583            })
584        ));
585        // TimedRequest = false.
586        assert!(matches!(
587            r.next().unwrap(),
588            Some(Element::Scalar {
589                tag: Tag::Context(1),
590                value: Value::Bool(false)
591            })
592        ));
593        // InvokeRequests array start.
594        assert!(matches!(
595            r.next().unwrap(),
596            Some(Element::ContainerStart {
597                tag: Tag::Context(2),
598                kind: ContainerKind::Array
599            })
600        ));
601        // CommandDataIB anonymous struct start.
602        assert!(matches!(
603            r.next().unwrap(),
604            Some(Element::ContainerStart {
605                tag: Tag::Anonymous,
606                kind: ContainerKind::Structure
607            })
608        ));
609        // CommandPathIB list at context tag 0.
610        assert!(matches!(
611            r.next().unwrap(),
612            Some(Element::ContainerStart {
613                tag: Tag::Context(0),
614                kind: ContainerKind::List
615            })
616        ));
617        // Endpoint = 0 at context tag 0.
618        assert!(matches!(
619            r.next().unwrap(),
620            Some(Element::Scalar {
621                tag: Tag::Context(0),
622                value: Value::Uint(0)
623            })
624        ));
625        // Cluster = 0x0030 at context tag 1.
626        assert!(matches!(
627            r.next().unwrap(),
628            Some(Element::Scalar {
629                tag: Tag::Context(1),
630                value: Value::Uint(0x0030)
631            })
632        ));
633        // Command = 0x00 at context tag 2.
634        assert!(matches!(
635            r.next().unwrap(),
636            Some(Element::Scalar {
637                tag: Tag::Context(2),
638                value: Value::Uint(0)
639            })
640        ));
641        // End CommandPathIB list.
642        assert!(matches!(r.next().unwrap(), Some(Element::ContainerEnd)));
643        // CommandFields (empty struct) at context tag 1 — ContainerStart then end.
644        assert!(matches!(
645            r.next().unwrap(),
646            Some(Element::ContainerStart {
647                tag: Tag::Context(1),
648                kind: ContainerKind::Structure
649            })
650        ));
651        assert!(matches!(r.next().unwrap(), Some(Element::ContainerEnd)));
652        // End CommandDataIB struct.
653        assert!(matches!(r.next().unwrap(), Some(Element::ContainerEnd)));
654        // End InvokeRequests array.
655        assert!(matches!(r.next().unwrap(), Some(Element::ContainerEnd)));
656        // InteractionModelRevision = IM_REVISION at context tag 0xFF.
657        assert!(matches!(
658            r.next().unwrap(),
659            Some(Element::Scalar { tag: Tag::Context(0xFF), value: Value::Uint(v) })
660                if v == u64::from(IM_REVISION)
661        ));
662        // End top-level struct.
663        assert!(matches!(r.next().unwrap(), Some(Element::ContainerEnd)));
664        // No more elements.
665        assert!(r.next().unwrap().is_none());
666    }
667
668    #[test]
669    fn invoke_request_carries_command_path_and_fields() {
670        // `put_preencoded` re-tags the anonymous-struct control byte (0x15)
671        // to a context-1 struct (0x35 0x01), then appends the body (0x18).
672        // Verify that the re-tagged representation [0x35, 0x01, 0x18] is
673        // present in the output (i.e. the fields blob was embedded).
674        let fields = vec![0x15u8, 0x18]; // anonymous empty struct
675        let bytes = build_invoke_request(
676            CommandPath {
677                endpoint: 1,
678                cluster: 0x0031,
679                command: 0x06,
680            },
681            &fields,
682        );
683        // Retagged form: context-1 struct start (0x35, 0x01) then body (0x18).
684        let retagged = [0x35u8, 0x01, 0x18];
685        assert!(
686            bytes.windows(retagged.len()).any(|w| w == retagged),
687            "command fields not embedded (expected retagged bytes {retagged:02X?} in {bytes:02X?})",
688        );
689    }
690
691    #[test]
692    fn parses_command_response_payload() {
693        use matter_codec::{Tag, TlvWriter};
694        let mut buf = Vec::new();
695        let mut w = TlvWriter::new(&mut buf);
696        w.start_structure(Tag::Anonymous).unwrap();
697        w.put_bool(Tag::Context(0), false).unwrap(); // SuppressResponse
698        w.start_array(Tag::Context(1)).unwrap(); // InvokeResponses
699        {
700            w.start_structure(Tag::Anonymous).unwrap(); // InvokeResponseIB
701            w.start_structure(Tag::Context(0)).unwrap(); // Command = CommandDataIB
702            w.start_list(Tag::Context(0)).unwrap(); // CommandPath
703            w.put_uint(Tag::Context(0), 0).unwrap();
704            w.put_uint(Tag::Context(1), 0x0030).unwrap();
705            w.put_uint(Tag::Context(2), 0x05).unwrap();
706            w.end_container().unwrap();
707            w.start_structure(Tag::Context(1)).unwrap(); // CommandFields (empty)
708            w.end_container().unwrap();
709            w.end_container().unwrap(); // CommandDataIB
710            w.end_container().unwrap(); // InvokeResponseIB
711        }
712        w.end_container().unwrap(); // array
713        w.put_uint(Tag::Context(0xFF), 11).unwrap();
714        w.end_container().unwrap();
715
716        let parsed = parse_invoke_response(&buf).unwrap();
717        match parsed {
718            InvokeResponse::Command { path, fields_tlv } => {
719                assert_eq!(path.endpoint, 0);
720                assert_eq!(path.cluster, 0x0030);
721                assert_eq!(path.command, 0x05);
722                assert_eq!(fields_tlv, vec![0x15, 0x18]); // re-anonymised empty struct
723            }
724            InvokeResponse::Status(_) => panic!("expected Command, got Status"),
725        }
726    }
727
728    #[test]
729    fn parses_command_with_nonempty_fields() {
730        use matter_codec::{Tag, TlvWriter};
731
732        // Build the expected anonymous struct bytes independently for comparison:
733        // anonymous struct containing one scalar: Context(0) = 0x2A (uint).
734        let mut expected_buf = Vec::new();
735        {
736            let mut w = TlvWriter::new(&mut expected_buf);
737            w.start_structure(Tag::Anonymous).unwrap();
738            w.put_uint(Tag::Context(0), 0x2A).unwrap();
739            w.end_container().unwrap();
740        }
741
742        // Build an InvokeResponseMessage whose CommandFields is that same struct.
743        let mut buf = Vec::new();
744        let mut w = TlvWriter::new(&mut buf);
745        w.start_structure(Tag::Anonymous).unwrap();
746        w.put_bool(Tag::Context(0), false).unwrap(); // SuppressResponse
747        w.start_array(Tag::Context(1)).unwrap(); // InvokeResponses
748        {
749            w.start_structure(Tag::Anonymous).unwrap(); // InvokeResponseIB
750            w.start_structure(Tag::Context(0)).unwrap(); // CommandDataIB
751            w.start_list(Tag::Context(0)).unwrap(); // CommandPath
752            w.put_uint(Tag::Context(0), 1).unwrap(); // endpoint
753            w.put_uint(Tag::Context(1), 0x0050).unwrap(); // cluster
754            w.put_uint(Tag::Context(2), 0x01).unwrap(); // command
755            w.end_container().unwrap(); // CommandPath
756                                        // CommandFields at Context(1): a struct with one member
757            w.start_structure(Tag::Context(1)).unwrap();
758            w.put_uint(Tag::Context(0), 0x2A).unwrap();
759            w.end_container().unwrap(); // CommandFields
760            w.end_container().unwrap(); // CommandDataIB
761            w.end_container().unwrap(); // InvokeResponseIB
762        }
763        w.end_container().unwrap(); // array
764        w.put_uint(Tag::Context(0xFF), 11).unwrap();
765        w.end_container().unwrap();
766
767        let parsed = parse_invoke_response(&buf).unwrap();
768        match parsed {
769            InvokeResponse::Command { path, fields_tlv } => {
770                assert_eq!(path.endpoint, 1);
771                assert_eq!(path.cluster, 0x0050);
772                assert_eq!(path.command, 0x01);
773                assert_eq!(
774                    fields_tlv, expected_buf,
775                    "fields_tlv should decode to the same struct content as the original"
776                );
777            }
778            InvokeResponse::Status(_) => panic!("expected Command, got Status"),
779        }
780    }
781
782    #[test]
783    fn rejects_out_of_range_endpoint() {
784        use crate::error::ImError;
785        use matter_codec::{Tag, TlvWriter};
786
787        let mut buf = Vec::new();
788        let mut w = TlvWriter::new(&mut buf);
789        w.start_structure(Tag::Anonymous).unwrap();
790        w.put_bool(Tag::Context(0), false).unwrap();
791        w.start_array(Tag::Context(1)).unwrap();
792        {
793            w.start_structure(Tag::Anonymous).unwrap(); // InvokeResponseIB
794            w.start_structure(Tag::Context(0)).unwrap(); // CommandDataIB
795            w.start_list(Tag::Context(0)).unwrap(); // CommandPath
796            w.put_uint(Tag::Context(0), 0x0001_0000).unwrap(); // endpoint exceeds u16
797            w.put_uint(Tag::Context(1), 0x0030).unwrap();
798            w.put_uint(Tag::Context(2), 0x00).unwrap();
799            w.end_container().unwrap();
800            w.start_structure(Tag::Context(1)).unwrap(); // CommandFields (empty)
801            w.end_container().unwrap();
802            w.end_container().unwrap(); // CommandDataIB
803            w.end_container().unwrap(); // InvokeResponseIB
804        }
805        w.end_container().unwrap();
806        w.put_uint(Tag::Context(0xFF), 11).unwrap();
807        w.end_container().unwrap();
808
809        let result = parse_invoke_response(&buf);
810        assert!(
811            matches!(result, Err(ImError::UnexpectedValue(_))),
812            "expected UnexpectedValue for out-of-range endpoint, got {result:?}"
813        );
814    }
815
816    #[test]
817    fn empty_invoke_responses_array_errors() {
818        use crate::error::ImError;
819        use matter_codec::{Tag, TlvWriter};
820
821        let mut buf = Vec::new();
822        let mut w = TlvWriter::new(&mut buf);
823        w.start_structure(Tag::Anonymous).unwrap();
824        w.put_bool(Tag::Context(0), false).unwrap();
825        w.start_array(Tag::Context(1)).unwrap(); // empty InvokeResponses array
826        w.end_container().unwrap();
827        w.put_uint(Tag::Context(0xFF), 11).unwrap();
828        w.end_container().unwrap();
829
830        let result = parse_invoke_response(&buf);
831        assert!(
832            matches!(result, Err(ImError::MissingField(_))),
833            "expected MissingField for empty InvokeResponses, got {result:?}"
834        );
835    }
836
837    #[test]
838    fn parses_status_response() {
839        use matter_codec::{Tag, TlvWriter};
840        let mut buf = Vec::new();
841        let mut w = TlvWriter::new(&mut buf);
842        w.start_structure(Tag::Anonymous).unwrap();
843        w.put_bool(Tag::Context(0), false).unwrap();
844        w.start_array(Tag::Context(1)).unwrap();
845        {
846            w.start_structure(Tag::Anonymous).unwrap(); // InvokeResponseIB
847            w.start_structure(Tag::Context(1)).unwrap(); // Status = CommandStatusIB
848            w.start_list(Tag::Context(0)).unwrap(); // CommandPath
849            w.put_uint(Tag::Context(0), 0).unwrap();
850            w.put_uint(Tag::Context(1), 0x0030).unwrap();
851            w.put_uint(Tag::Context(2), 0x00).unwrap();
852            w.end_container().unwrap();
853            w.start_structure(Tag::Context(1)).unwrap(); // StatusIB
854            w.put_uint(Tag::Context(0), 0x01).unwrap(); // Status = FAILURE
855            w.end_container().unwrap();
856            w.end_container().unwrap(); // CommandStatusIB
857            w.end_container().unwrap(); // InvokeResponseIB
858        }
859        w.end_container().unwrap();
860        w.put_uint(Tag::Context(0xFF), 11).unwrap();
861        w.end_container().unwrap();
862
863        let parsed = parse_invoke_response(&buf).unwrap();
864        assert!(matches!(
865            parsed,
866            InvokeResponse::Status(ImStatus::Failure(0x01))
867        ));
868    }
869
870    /// Build an `InvokeResponseMessage` whose single `InvokeResponseIB` carries
871    /// a `CommandStatusIB`. `status` controls the `StatusIB.Status` member:
872    /// `Some(v)` writes that raw uint, `None` omits the member entirely.
873    fn invoke_status_response(status: Option<u64>) -> Vec<u8> {
874        use matter_codec::{Tag, TlvWriter};
875        let mut buf = Vec::new();
876        let mut w = TlvWriter::new(&mut buf);
877        w.start_structure(Tag::Anonymous).unwrap();
878        w.put_bool(Tag::Context(0), false).unwrap();
879        w.start_array(Tag::Context(1)).unwrap();
880        w.start_structure(Tag::Anonymous).unwrap(); // InvokeResponseIB
881        w.start_structure(Tag::Context(1)).unwrap(); // Status = CommandStatusIB
882        w.start_list(Tag::Context(0)).unwrap(); // CommandPath
883        w.put_uint(Tag::Context(0), 0).unwrap();
884        w.put_uint(Tag::Context(1), 0x0030).unwrap();
885        w.put_uint(Tag::Context(2), 0x00).unwrap();
886        w.end_container().unwrap();
887        w.start_structure(Tag::Context(1)).unwrap(); // StatusIB
888        if let Some(v) = status {
889            w.put_uint(Tag::Context(0), v).unwrap();
890        }
891        w.end_container().unwrap();
892        w.end_container().unwrap(); // CommandStatusIB
893        w.end_container().unwrap(); // InvokeResponseIB
894        w.end_container().unwrap(); // array
895        w.put_uint(Tag::Context(0xFF), 11).unwrap();
896        w.end_container().unwrap();
897        buf
898    }
899
900    #[test]
901    fn command_status_out_of_range_is_invalid_status_code() {
902        // StatusIB.Status = 0x100 — present on the wire but exceeds the single
903        // octet a Matter status code occupies. Must surface as the distinct
904        // InvalidStatusCode error, NOT MissingField.
905        let buf = invoke_status_response(Some(0x100));
906        match parse_invoke_response(&buf) {
907            Err(ImError::InvalidStatusCode { code }) => assert_eq!(code, 0x100),
908            other => panic!("expected InvalidStatusCode {{ code: 0x100 }}, got {other:?}"),
909        }
910    }
911
912    #[test]
913    fn command_status_valid_code_still_parses() {
914        let buf = invoke_status_response(Some(0x88));
915        assert!(matches!(
916            parse_invoke_response(&buf),
917            Ok(InvokeResponse::Status(ImStatus::Failure(0x88)))
918        ));
919    }
920
921    #[test]
922    fn command_status_missing_field_still_missing_field() {
923        // No Status member at all — genuinely missing, so MissingField is right.
924        let buf = invoke_status_response(None);
925        assert!(matches!(
926            parse_invoke_response(&buf),
927            Err(ImError::MissingField("StatusIB.Status"))
928        ));
929    }
930
931    #[test]
932    fn invoke_response_ib_with_no_command_or_status_errors() {
933        use matter_codec::{Tag, TlvWriter};
934        let mut buf = Vec::new();
935        let mut w = TlvWriter::new(&mut buf);
936        w.start_structure(Tag::Anonymous).unwrap();
937        w.put_bool(Tag::Context(0), false).unwrap();
938        w.start_array(Tag::Context(1)).unwrap(); // InvokeResponses
939        w.start_structure(Tag::Anonymous).unwrap(); // InvokeResponseIB with no Command/Status
940        w.put_uint(Tag::Context(7), 0).unwrap(); // unrelated field
941        w.end_container().unwrap();
942        w.end_container().unwrap(); // array
943        w.put_uint(Tag::Context(0xFF), 11).unwrap();
944        w.end_container().unwrap();
945
946        assert!(matches!(
947            parse_invoke_response(&buf),
948            Err(ImError::EmptyInvokeResponse)
949        ));
950    }
951
952    #[test]
953    fn batch_request_carries_command_refs() {
954        let fields = vec![0x15u8, 0x18]; // anonymous empty struct
955        let bytes = build_invoke_request_batch(&[
956            (
957                CommandPath {
958                    endpoint: 1,
959                    cluster: 0x06,
960                    command: 0x02,
961                },
962                &fields,
963            ),
964            (
965                CommandPath {
966                    endpoint: 2,
967                    cluster: 0x06,
968                    command: 0x00,
969                },
970                &fields,
971            ),
972        ]);
973        // The InvokeRequests array (ctx 2) holds two CommandDataIB structs, each
974        // ending with CommandRef (ctx 2) = 0 then 1. Parse the whole thing back
975        // through the batch response parser shape is not applicable (this is a
976        // request), so just confirm both refs appear in order in the stream.
977        let mut r = TlvReader::new(&bytes);
978        let mut refs = Vec::new();
979        let mut depth = 0i32;
980        while let Some(el) = r.next().unwrap() {
981            match el {
982                Element::ContainerStart { .. } => depth += 1,
983                Element::ContainerEnd => depth -= 1,
984                // CommandRef sits at depth 2 (struct > array > CommandDataIB), tag 2.
985                Element::Scalar {
986                    tag: Tag::Context(2),
987                    value: Value::Uint(n),
988                } if depth == 3 => refs.push(n),
989                _ => {}
990            }
991        }
992        assert_eq!(refs, vec![0, 1], "CommandRefs must be 0 then 1");
993    }
994
995    #[test]
996    fn command_fields_preserve_device_integer_widths() {
997        // Device encodes CommandFields with a NON-minimal width (uint16 42).
998        // Span-copy + retag must return those bytes verbatim under a fresh
999        // anonymous tag — the old Value round-trip collapsed them to uint8.
1000        // Hand-assembled fields: anon struct { ctx0: uint16 0x2A } =
1001        // [0x15, 0x25, 0x00, 0x2A, 0x00, 0x18]; embedded at ctx1 via
1002        // put_preencoded (which keeps the body bytes verbatim).
1003        let nonminimal_fields = [0x15u8, 0x25, 0x00, 0x2A, 0x00, 0x18];
1004        let mut buf = Vec::new();
1005        let mut w = TlvWriter::new(&mut buf);
1006        w.start_structure(Tag::Anonymous).unwrap();
1007        w.put_bool(Tag::Context(0), false).unwrap();
1008        w.start_array(Tag::Context(1)).unwrap();
1009        w.start_structure(Tag::Anonymous).unwrap(); // InvokeResponseIB
1010        w.start_structure(Tag::Context(0)).unwrap(); // CommandDataIB
1011        w.start_list(Tag::Context(0)).unwrap();
1012        w.put_uint(Tag::Context(0), 0).unwrap();
1013        w.put_uint(Tag::Context(1), 0x0030).unwrap();
1014        w.put_uint(Tag::Context(2), 0x05).unwrap();
1015        w.end_container().unwrap();
1016        w.put_preencoded(Tag::Context(1), &nonminimal_fields)
1017            .unwrap();
1018        w.end_container().unwrap();
1019        w.end_container().unwrap();
1020        w.end_container().unwrap();
1021        w.put_uint(Tag::Context(0xFF), 11).unwrap();
1022        w.end_container().unwrap();
1023
1024        match parse_invoke_response(&buf).unwrap() {
1025            InvokeResponse::Command { fields_tlv, .. } => {
1026                assert_eq!(
1027                    fields_tlv, nonminimal_fields,
1028                    "device widths must be preserved verbatim"
1029                );
1030            }
1031            InvokeResponse::Status(_) => panic!("expected Command"),
1032        }
1033    }
1034
1035    #[test]
1036    fn batch_response_parses_all_ibs_with_refs() {
1037        use matter_codec::{Tag, TlvWriter};
1038        // Two InvokeResponseIBs: a Command (ref 0) and a Status (ref 1).
1039        let mut buf = Vec::new();
1040        let mut w = TlvWriter::new(&mut buf);
1041        w.start_structure(Tag::Anonymous).unwrap();
1042        w.put_bool(Tag::Context(0), false).unwrap(); // SuppressResponse
1043        w.start_array(Tag::Context(1)).unwrap(); // InvokeResponses
1044        {
1045            // IB 1: Command = CommandDataIB { path, fields(empty), ref=0 }
1046            w.start_structure(Tag::Anonymous).unwrap();
1047            w.start_structure(Tag::Context(0)).unwrap(); // Command
1048            w.start_list(Tag::Context(0)).unwrap();
1049            w.put_uint(Tag::Context(0), 1).unwrap();
1050            w.put_uint(Tag::Context(1), 0x06).unwrap();
1051            w.put_uint(Tag::Context(2), 0x02).unwrap();
1052            w.end_container().unwrap();
1053            w.start_structure(Tag::Context(1)).unwrap();
1054            w.end_container().unwrap(); // empty fields
1055            w.put_uint(Tag::Context(2), 0).unwrap(); // CommandRef
1056            w.end_container().unwrap(); // Command
1057            w.end_container().unwrap(); // IB 1
1058                                        // IB 2: Status = CommandStatusIB { path, status=SUCCESS, ref=1 }
1059            w.start_structure(Tag::Anonymous).unwrap();
1060            w.start_structure(Tag::Context(1)).unwrap(); // Status
1061            w.start_list(Tag::Context(0)).unwrap();
1062            w.put_uint(Tag::Context(0), 2).unwrap();
1063            w.put_uint(Tag::Context(1), 0x06).unwrap();
1064            w.put_uint(Tag::Context(2), 0x00).unwrap();
1065            w.end_container().unwrap();
1066            w.start_structure(Tag::Context(1)).unwrap(); // StatusIB
1067            w.put_uint(Tag::Context(0), 0).unwrap(); // SUCCESS
1068            w.end_container().unwrap();
1069            w.put_uint(Tag::Context(2), 1).unwrap(); // CommandRef
1070            w.end_container().unwrap(); // Status
1071            w.end_container().unwrap(); // IB 2
1072        }
1073        w.end_container().unwrap(); // array
1074        w.put_uint(Tag::Context(0xFF), 11).unwrap();
1075        w.end_container().unwrap();
1076
1077        let entries = parse_invoke_response_batch(&buf).unwrap();
1078        assert_eq!(entries.len(), 2);
1079        assert_eq!(entries[0].command_ref, Some(0));
1080        assert!(matches!(
1081            entries[0].response,
1082            InvokeResponse::Command { ref path, .. } if path.endpoint == 1 && path.command == 0x02
1083        ));
1084        assert_eq!(entries[1].command_ref, Some(1));
1085        assert_eq!(
1086            entries[1].response,
1087            InvokeResponse::Status(ImStatus::Success)
1088        );
1089
1090        // Back-compat: the single-command parser reads the first IB only.
1091        match parse_invoke_response(&buf).unwrap() {
1092            InvokeResponse::Command { path, .. } => assert_eq!(path.endpoint, 1),
1093            InvokeResponse::Status(_) => panic!("expected the first IB (a Command)"),
1094        }
1095    }
1096
1097    /// Drive `command_path_from_reader` over a writer-built `CommandPathIB`.
1098    fn parse_cmd_path(build: impl FnOnce(&mut TlvWriter<'_>)) -> Result<CommandPath, ImError> {
1099        let mut buf = Vec::new();
1100        let mut w = TlvWriter::new(&mut buf);
1101        w.start_list(Tag::Anonymous).unwrap();
1102        build(&mut w);
1103        w.end_container().unwrap();
1104        let mut r = TlvReader::new(&buf);
1105        assert!(matches!(
1106            r.next().unwrap(),
1107            Some(Element::ContainerStart { .. })
1108        ));
1109        command_path_from_reader(&mut r)
1110    }
1111
1112    #[test]
1113    fn command_path_parses_members_and_errors() {
1114        let p = parse_cmd_path(|w| {
1115            w.put_uint(Tag::Context(0), 1).unwrap();
1116            w.put_uint(Tag::Context(1), 6).unwrap();
1117            w.put_uint(Tag::Context(2), 2).unwrap();
1118        })
1119        .unwrap();
1120        assert_eq!((p.endpoint, p.cluster, p.command), (1, 6, 2));
1121
1122        assert!(matches!(
1123            parse_cmd_path(|w| {
1124                w.put_uint(Tag::Context(0), 1).unwrap();
1125                w.put_uint(Tag::Context(1), 6).unwrap();
1126            }),
1127            Err(ImError::MissingField("CommandPath.command"))
1128        ));
1129
1130        assert!(matches!(
1131            parse_cmd_path(|w| {
1132                w.put_uint(Tag::Context(0), u64::from(u16::MAX) + 1)
1133                    .unwrap();
1134                w.put_uint(Tag::Context(1), 6).unwrap();
1135                w.put_uint(Tag::Context(2), 2).unwrap();
1136            }),
1137            Err(ImError::UnexpectedValue(_))
1138        ));
1139    }
1140
1141    #[test]
1142    fn empty_command_fields_fallback_to_anonymous_empty_struct() {
1143        // Same shape as `parses_command_response_payload`, but the CommandFields
1144        // (ctx1) member is OMITTED entirely — exercises the fallback at
1145        // `parse_command_data_ref` that canonicalizes an absent CommandFields to
1146        // the anonymous empty struct `[0x15, 0x18]`.
1147        let mut buf = Vec::new();
1148        let mut w = TlvWriter::new(&mut buf);
1149        w.start_structure(Tag::Anonymous).unwrap();
1150        w.put_bool(Tag::Context(0), false).unwrap(); // SuppressResponse
1151        w.start_array(Tag::Context(1)).unwrap(); // InvokeResponses
1152        {
1153            w.start_structure(Tag::Anonymous).unwrap(); // InvokeResponseIB
1154            w.start_structure(Tag::Context(0)).unwrap(); // Command = CommandDataIB
1155            w.start_list(Tag::Context(0)).unwrap(); // CommandPath
1156            w.put_uint(Tag::Context(0), 0).unwrap();
1157            w.put_uint(Tag::Context(1), 0x0030).unwrap();
1158            w.put_uint(Tag::Context(2), 0x05).unwrap();
1159            w.end_container().unwrap();
1160            // No CommandFields (ctx1) member at all.
1161            w.end_container().unwrap(); // CommandDataIB
1162            w.end_container().unwrap(); // InvokeResponseIB
1163        }
1164        w.end_container().unwrap(); // array
1165        w.put_uint(Tag::Context(0xFF), 11).unwrap();
1166        w.end_container().unwrap();
1167
1168        let parsed = parse_invoke_response(&buf).unwrap();
1169        match parsed {
1170            InvokeResponse::Command { fields_tlv, .. } => {
1171                assert_eq!(fields_tlv, vec![0x15, 0x18]);
1172            }
1173            InvokeResponse::Status(_) => panic!("expected Command, got Status"),
1174        }
1175    }
1176}