Skip to main content

matter_commissioning/clusters/
network_commissioning.rs

1//! `NetworkCommissioning` cluster (id `0x0031`) command + response
2//! codecs.
3//!
4//! Spec §11.9. M6.5 ships the Wi-Fi subset: `AddOrUpdateWiFiNetwork`,
5//! `ConnectNetwork`, plus the `FeatureMap` attribute read for
6//! Wi-Fi/Ethernet/Thread branching. `ScanNetworks`, Thread commands,
7//! and `RemoveNetwork`/`ReorderNetworks` are deferred per the M6.5 spec.
8
9#![forbid(unsafe_code)]
10
11use crate::state_machine::{CommissioningError, RemediationHint, Stage};
12
13/// Cluster ID: `0x0031`.
14pub const CLUSTER_ID: u32 = 0x0031;
15
16/// Command IDs (Matter Core Spec §11.9.6).
17pub mod command_id {
18    /// `AddOrUpdateWiFiNetwork` request.
19    pub const ADD_OR_UPDATE_WIFI_NETWORK: u32 = 0x02;
20    /// `AddOrUpdateThreadNetwork` request.
21    pub const ADD_OR_UPDATE_THREAD_NETWORK: u32 = 0x03;
22    /// `ConnectNetwork` request.
23    pub const CONNECT_NETWORK: u32 = 0x06;
24}
25
26/// Response IDs (Matter Core Spec §11.9.6).
27pub mod response_id {
28    /// `NetworkConfigResponse` — emitted for
29    /// `AddOrUpdateWiFiNetwork` / `RemoveNetwork` / `ReorderNetworks`.
30    pub const NETWORK_CONFIG_RESPONSE: u32 = 0x05;
31    /// `ConnectNetworkResponse`.
32    pub const CONNECT_NETWORK_RESPONSE: u32 = 0x07;
33}
34
35/// Attribute IDs.
36pub mod attribute_id {
37    /// Universal Matter cluster meta-attribute. Spec §7.13.
38    pub const FEATURE_MAP: u32 = 0xFFFC;
39    /// `ConnectMaxTimeSeconds` (spec §11.9.5.4; chip
40    /// `network-commissioning-cluster.xml` `code="0x0003"`). Type
41    /// `int8u` — maximum time, in seconds (0-255), the device may take
42    /// to connect to an operational network after `ConnectNetwork`.
43    /// Used to size the failsafe extension before `ConnectNetwork`
44    /// (Thread attach is slower than Wi-Fi association).
45    pub const CONNECT_MAX_TIME_SECONDS: u32 = 0x0003;
46}
47
48bitflags::bitflags! {
49    /// Bits from `NetworkCommissioning::FeatureMap` (spec §11.9.4).
50    ///
51    /// Bit 0 = `WiFiNetworkInterface`, bit 1 = `ThreadNetworkInterface`,
52    /// bit 2 = `EthernetNetworkInterface`. Higher bits reserved.
53    #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
54    pub struct NetworkCommissioningFeature: u32 {
55        /// Device exposes a Wi-Fi network interface.
56        const WIFI     = 1 << 0;
57        /// Device exposes a Thread network interface.
58        const THREAD   = 1 << 1;
59        /// Device exposes an Ethernet network interface.
60        const ETHERNET = 1 << 2;
61    }
62}
63
64/// Encode `AddOrUpdateWiFiNetwork` (spec §11.9.6.3).
65///
66/// `ssid` must be 1–32 bytes; `credentials` 0–64 bytes. The encoder
67/// does NOT validate lengths — callers (state machine `Commissioner`)
68/// validate at config-load time.
69#[must_use]
70#[allow(clippy::expect_used, clippy::missing_panics_doc)] // Vec-backed TlvWriter is infallible.
71pub fn encode_add_or_update_wifi_network(
72    ssid: &[u8],
73    credentials: &[u8],
74    breadcrumb: u64,
75) -> Vec<u8> {
76    use matter_codec::{Tag, TlvWriter};
77    let mut buf = Vec::new();
78    let mut w = TlvWriter::new(&mut buf);
79    w.start_structure(Tag::Anonymous)
80        .expect("infallible: vec writer");
81    w.put_bytes(Tag::Context(0), ssid)
82        .expect("infallible: vec writer");
83    w.put_bytes(Tag::Context(1), credentials)
84        .expect("infallible: vec writer");
85    w.put_uint(Tag::Context(2), breadcrumb)
86        .expect("infallible: vec writer");
87    w.end_container().expect("infallible: vec writer");
88    buf
89}
90
91/// Encode `AddOrUpdateThreadNetwork` (spec §11.9.6.4).
92///
93/// `operational_dataset` is the opaque Thread Operational Dataset TLV, in
94/// Thread's own TLV format (type-length-value, distinct from Matter TLV) —
95/// captured verbatim from the Thread Border Router (e.g. `ot-ctl dataset
96/// active -x`). The encoder does not parse or validate the dataset; it is
97/// carried as an octet string. Unlike `AddOrUpdateWiFiNetwork`, there is no
98/// credentials field, so `breadcrumb` lands at `Tag::Context(1)` here
99/// rather than `Tag::Context(2)`.
100#[must_use]
101#[allow(clippy::expect_used, clippy::missing_panics_doc)] // Vec-backed TlvWriter is infallible.
102pub fn encode_add_or_update_thread_network(operational_dataset: &[u8], breadcrumb: u64) -> Vec<u8> {
103    use matter_codec::{Tag, TlvWriter};
104    let mut buf = Vec::new();
105    let mut w = TlvWriter::new(&mut buf);
106    w.start_structure(Tag::Anonymous)
107        .expect("infallible: vec writer");
108    w.put_bytes(Tag::Context(0), operational_dataset)
109        .expect("infallible: vec writer");
110    w.put_uint(Tag::Context(1), breadcrumb)
111        .expect("infallible: vec writer");
112    w.end_container().expect("infallible: vec writer");
113    buf
114}
115
116/// Encode `ConnectNetwork` (spec §11.9.6.6).
117///
118/// `network_id` is the SSID bytes for Wi-Fi networks (re-uses the
119/// `ssid` field as the network identity per spec §11.9.5.2). For
120/// Wi-Fi commissioning the value is identical to the SSID supplied
121/// to `encode_add_or_update_wifi_network`.
122#[must_use]
123#[allow(clippy::expect_used, clippy::missing_panics_doc)] // Vec-backed TlvWriter is infallible.
124pub fn encode_connect_network(network_id: &[u8], breadcrumb: u64) -> Vec<u8> {
125    use matter_codec::{Tag, TlvWriter};
126    let mut buf = Vec::new();
127    let mut w = TlvWriter::new(&mut buf);
128    w.start_structure(Tag::Anonymous)
129        .expect("infallible: vec writer");
130    w.put_bytes(Tag::Context(0), network_id)
131        .expect("infallible: vec writer");
132    w.put_uint(Tag::Context(1), breadcrumb)
133        .expect("infallible: vec writer");
134    w.end_container().expect("infallible: vec writer");
135    buf
136}
137
138/// Decode the `NetworkCommissioning::FeatureMap` attribute value.
139///
140/// Expects the bare TLV encoding of the u32 attribute value (no
141/// `AttributeReportIB` envelope). The state machine's M6.6 driver
142/// unwraps the Interaction Model envelope and delivers just the value
143/// TLV to `Commissioner::on_response`.
144///
145/// # Errors
146///
147/// Returns `CommissioningError::MalformedResponse(Stage::ReadNetworkCommissioningInfo)`
148/// if the bytes are not a well-formed unsigned-integer TLV element.
149pub fn decode_feature_map(tlv: &[u8]) -> Result<NetworkCommissioningFeature, CommissioningError> {
150    use matter_codec::{Element, TlvReader, Value};
151    let mut reader = TlvReader::new(tlv);
152    match reader
153        .next()
154        .map_err(|_| CommissioningError::MalformedResponse(Stage::ReadNetworkCommissioningInfo))?
155    {
156        Some(Element::Scalar {
157            value: Value::Uint(raw),
158            ..
159        }) => {
160            let truncated = u32::try_from(raw).map_err(|_| {
161                CommissioningError::MalformedResponse(Stage::ReadNetworkCommissioningInfo)
162            })?;
163            Ok(NetworkCommissioningFeature::from_bits_truncate(truncated))
164        }
165        _ => Err(CommissioningError::MalformedResponse(
166            Stage::ReadNetworkCommissioningInfo,
167        )),
168    }
169}
170
171/// Decode the `NetworkCommissioning::ConnectMaxTimeSeconds` attribute
172/// value (spec §11.9.5.4).
173///
174/// Expects the bare TLV encoding of the unsigned attribute value (no
175/// `AttributeReportIB` envelope) — the same single-attribute shape
176/// [`decode_feature_map`] parses. The state machine's driver unwraps the
177/// Interaction Model report and delivers just the value TLV.
178///
179/// The attribute is a `u8` (`int8u`) seconds count on the wire; the
180/// value is widened into a `u16` return type for decoder headroom, and
181/// any value that does not fit `u16` (which a spec-conformant `u8`
182/// device will never send) is clamped to [`u16::MAX`] rather than
183/// rejected, since the value only sizes a local timeout (over-large is
184/// harmless, and a hostile device cannot use it to reject a
185/// well-formed read).
186///
187/// # Errors
188///
189/// Returns `CommissioningError::MalformedResponse(Stage::ReadNetworkCommissioningInfo)`
190/// if the bytes are not a well-formed unsigned-integer TLV element.
191pub fn decode_connect_max_time_seconds(tlv: &[u8]) -> Result<u16, CommissioningError> {
192    use matter_codec::{Element, TlvReader, Value};
193    let mut reader = TlvReader::new(tlv);
194    match reader
195        .next()
196        .map_err(|_| CommissioningError::MalformedResponse(Stage::ReadNetworkCommissioningInfo))?
197    {
198        Some(Element::Scalar {
199            value: Value::Uint(raw),
200            ..
201        }) => Ok(u16::try_from(raw).unwrap_or(u16::MAX)),
202        _ => Err(CommissioningError::MalformedResponse(
203            Stage::ReadNetworkCommissioningInfo,
204        )),
205    }
206}
207
208/// Cap `s` at `max_bytes` bytes, flooring to a UTF-8 char boundary (Matter
209/// string bounds are octet counts; `String::truncate` panics mid-char, and
210/// `str::floor_char_boundary` is unstable at MSRV 1.88).
211pub(crate) fn truncate_utf8(mut s: String, max_bytes: usize) -> String {
212    if s.len() <= max_bytes {
213        return s;
214    }
215    let mut end = max_bytes;
216    while end > 0 && !s.is_char_boundary(end) {
217        end -= 1;
218    }
219    s.truncate(end);
220    s
221}
222
223/// Decoded `NetworkConfigResponse` (spec §11.9.6.5). Emitted by
224/// `AddOrUpdateWiFiNetwork`, `RemoveNetwork`, `ReorderNetworks`.
225#[derive(Debug, Clone, PartialEq, Eq)]
226#[non_exhaustive]
227pub struct NetworkConfigResponse {
228    /// `NetworkCommissioningStatusEnum` (spec §11.9.5.1). 0 = OK.
229    pub networking_status: u8,
230    /// Optional human-readable debug text echoed by the device, capped at
231    /// the spec's 512-octet bound at decode. **Device-controlled free
232    /// text** — it may name networks (e.g. an SSID); log deliberately.
233    pub debug_text: Option<String>,
234    // `network_index` deliberately omitted — only meaningful on the
235    // scan path, which M6.5 does not ship.
236}
237
238/// Decoded `ConnectNetworkResponse` (spec §11.9.6.6.2).
239#[derive(Debug, Clone, PartialEq, Eq)]
240#[non_exhaustive]
241pub struct ConnectNetworkResponse {
242    /// `NetworkCommissioningStatusEnum`. 0 = OK.
243    pub networking_status: u8,
244    /// Optional human-readable debug text echoed by the device, capped at
245    /// the spec's 512-octet bound at decode. **Device-controlled free
246    /// text** — it may name networks (e.g. an SSID); log deliberately.
247    pub debug_text: Option<String>,
248    /// Platform-specific Wi-Fi error code (spec §11.9.6.6.3). Optional.
249    pub error_value: Option<i32>,
250}
251
252/// Decode `NetworkConfigResponse` (spec §11.9.6.5).
253///
254/// `stage` is plumbed through so any error includes the right cursor
255/// position in `CommissioningError::MalformedResponse(_)`. Callers
256/// pass `Stage::NetworkSetup` in production.
257///
258/// # Errors
259///
260/// Returns `CommissioningError::MalformedResponse(stage)` on garbled
261/// TLV. A `networking_status != 0` is a *successful* decode whose
262/// non-OK value is mapped to
263/// `CommissioningError::NetworkRejected { remediation_hint, .. }` by
264/// the state-machine dispatch layer (M6.5.2).
265pub fn decode_network_config_response(
266    stage: Stage,
267    tlv: &[u8],
268) -> Result<NetworkConfigResponse, CommissioningError> {
269    use matter_codec::{ContainerKind, Element, Tag, TlvReader, Value};
270    let mut reader = TlvReader::new(tlv);
271    match reader
272        .next()
273        .map_err(|_| CommissioningError::MalformedResponse(stage))?
274    {
275        Some(Element::ContainerStart {
276            tag: Tag::Anonymous,
277            kind: ContainerKind::Structure,
278        }) => {}
279        _ => return Err(CommissioningError::MalformedResponse(stage)),
280    }
281    let mut networking_status: Option<u8> = None;
282    let mut debug_text: Option<String> = None;
283    loop {
284        match reader
285            .next()
286            .map_err(|_| CommissioningError::MalformedResponse(stage))?
287        {
288            Some(Element::ContainerEnd) => break,
289            Some(Element::Scalar {
290                tag: Tag::Context(0),
291                value: Value::Uint(v),
292            }) => {
293                if networking_status.is_some() {
294                    return Err(CommissioningError::MalformedResponse(stage));
295                }
296                networking_status = Some(
297                    u8::try_from(v).map_err(|_| CommissioningError::MalformedResponse(stage))?,
298                );
299            }
300            Some(Element::Scalar {
301                tag: Tag::Context(1),
302                value: Value::Utf8(s),
303            }) => {
304                if debug_text.is_some() {
305                    return Err(CommissioningError::MalformedResponse(stage));
306                }
307                // Spec bound (§11.9): DebugText ≤ 512 octets. Device-echoed free text — cap defensively.
308                debug_text = Some(truncate_utf8(s, 512));
309            }
310            // Forward-compat: ignore tag 2 (network_index) on NetworkConfigResponse
311            // and all other unknown tags.
312            Some(Element::Scalar { .. } | Element::ContainerStart { .. }) => {}
313            None | Some(_) => return Err(CommissioningError::MalformedResponse(stage)),
314        }
315    }
316    let networking_status =
317        networking_status.ok_or(CommissioningError::MalformedResponse(stage))?;
318    Ok(NetworkConfigResponse {
319        networking_status,
320        debug_text,
321    })
322}
323
324/// Decode `ConnectNetworkResponse` (spec §11.9.6.6.2).
325///
326/// # Errors
327///
328/// Returns `CommissioningError::MalformedResponse(stage)` on garbled
329/// TLV. A `networking_status != 0` is a *successful* decode whose
330/// non-OK value is mapped to
331/// `CommissioningError::NetworkRejected { remediation_hint, .. }` by
332/// the state-machine dispatch layer (M6.5.2).
333pub fn decode_connect_network_response(
334    stage: Stage,
335    tlv: &[u8],
336) -> Result<ConnectNetworkResponse, CommissioningError> {
337    use matter_codec::{ContainerKind, Element, Tag, TlvReader, Value};
338    let mut reader = TlvReader::new(tlv);
339    match reader
340        .next()
341        .map_err(|_| CommissioningError::MalformedResponse(stage))?
342    {
343        Some(Element::ContainerStart {
344            tag: Tag::Anonymous,
345            kind: ContainerKind::Structure,
346        }) => {}
347        _ => return Err(CommissioningError::MalformedResponse(stage)),
348    }
349    let mut networking_status: Option<u8> = None;
350    let mut debug_text: Option<String> = None;
351    let mut error_value: Option<i32> = None;
352    loop {
353        match reader
354            .next()
355            .map_err(|_| CommissioningError::MalformedResponse(stage))?
356        {
357            Some(Element::ContainerEnd) => break,
358            Some(Element::Scalar {
359                tag: Tag::Context(0),
360                value: Value::Uint(v),
361            }) => {
362                if networking_status.is_some() {
363                    return Err(CommissioningError::MalformedResponse(stage));
364                }
365                networking_status = Some(
366                    u8::try_from(v).map_err(|_| CommissioningError::MalformedResponse(stage))?,
367                );
368            }
369            Some(Element::Scalar {
370                tag: Tag::Context(1),
371                value: Value::Utf8(s),
372            }) => {
373                if debug_text.is_some() {
374                    return Err(CommissioningError::MalformedResponse(stage));
375                }
376                // Spec bound (§11.9): DebugText ≤ 512 octets. Device-echoed free text — cap defensively.
377                debug_text = Some(truncate_utf8(s, 512));
378            }
379            Some(Element::Scalar {
380                tag: Tag::Context(2),
381                value: Value::Int(v),
382            }) => {
383                if error_value.is_some() {
384                    return Err(CommissioningError::MalformedResponse(stage));
385                }
386                error_value = Some(
387                    i32::try_from(v).map_err(|_| CommissioningError::MalformedResponse(stage))?,
388                );
389            }
390            // Forward-compat: ignore unknown tags.
391            Some(Element::Scalar { .. } | Element::ContainerStart { .. }) => {}
392            None | Some(_) => return Err(CommissioningError::MalformedResponse(stage)),
393        }
394    }
395    let networking_status =
396        networking_status.ok_or(CommissioningError::MalformedResponse(stage))?;
397    Ok(ConnectNetworkResponse {
398        networking_status,
399        debug_text,
400        error_value,
401    })
402}
403
404/// Map a Matter `NetworkCommissioningStatusEnum` value (spec §11.9.5.1)
405/// to its [`RemediationHint`] category. Used by the M6.5.2 dispatch
406/// layer when constructing
407/// `CommissioningError::NetworkRejected` (lands in M6.5.2).
408///
409/// Any unmapped value (including values outside the defined enum
410/// range) returns [`RemediationHint::None`].
411#[must_use]
412pub const fn remediation_for(networking_status: u8) -> RemediationHint {
413    match networking_status {
414        2 => RemediationHint::DeviceNetworkSlotsFull,
415        3 | 5 => RemediationHint::CheckSsid,
416        6 => RemediationHint::CheckRegulatoryRegion,
417        7 => RemediationHint::CheckPassphrase,
418        8 => RemediationHint::UpgradeSecurityMode,
419        10 | 11 => RemediationHint::DeviceIpStackFailure,
420        _ => RemediationHint::None,
421    }
422}
423
424#[cfg(test)]
425#[allow(clippy::unwrap_used, clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
426mod tests {
427    use super::*;
428
429    #[test]
430    fn feature_bits_disjoint() {
431        assert_eq!(NetworkCommissioningFeature::WIFI.bits(), 0b001);
432        assert_eq!(NetworkCommissioningFeature::THREAD.bits(), 0b010);
433        assert_eq!(NetworkCommissioningFeature::ETHERNET.bits(), 0b100);
434    }
435
436    #[test]
437    fn cluster_id_is_0x0031() {
438        assert_eq!(CLUSTER_ID, 0x0031);
439    }
440
441    #[test]
442    fn add_or_update_wifi_network_matter_no_creds_matches_spec_bytes() {
443        let bytes = encode_add_or_update_wifi_network(b"matter", b"", 0);
444        assert_eq!(
445            bytes,
446            vec![
447                0x15, 0x30, 0x00, 0x06, b'm', b'a', b't', b't', b'e', b'r', 0x30, 0x01, 0x00, 0x24,
448                0x02, 0x00, 0x18,
449            ],
450            "encoded bytes: {bytes:02x?}",
451        );
452    }
453
454    #[test]
455    fn add_or_update_wifi_network_with_creds_includes_passphrase_bytes() {
456        let bytes = encode_add_or_update_wifi_network(b"matter", b"hunter22", 1);
457        // Check structural invariants without hand-computing the full byte string.
458        assert_eq!(bytes.first(), Some(&0x15));
459        assert_eq!(bytes.last(), Some(&0x18));
460        let window = b"hunter22";
461        assert!(
462            bytes.windows(window.len()).any(|w| w == window),
463            "credentials should appear in the payload literal",
464        );
465    }
466
467    #[test]
468    fn add_or_update_thread_network_bytes_match_vector() {
469        // Vector: test-vectors/thread/network_commissioning.json
470        // "add_or_update_thread_network" — reference dataset from
471        // "reference_operational_dataset" in the same file (111 bytes,
472        // OTBR `ot-ctl dataset active -x` capture).
473        let ds = hex::decode(
474            "0e08000000000001000000030000184a0300001235060004001fffe0020878\
475             96217f787f6ebe0708fdec3f34f3cd2020051071dccee3f164f15da92254e0\
476             b9c8a3a5030f4f70656e5468726561642d3839643701\
477             0289d70410dc4b544c7a58671a2ce4f876f5d6dcd90c0402a0f7f8",
478        )
479        .expect("valid hex literal");
480        assert_eq!(ds.len(), 111, "reference dataset must be 111 bytes");
481        let got = encode_add_or_update_thread_network(&ds, 1);
482        assert_eq!(
483            hex::encode(&got),
484            "1530006f0e08000000000001000000030000184a0300001235060004001fff\
485             e002087896217f787f6ebe0708fdec3f34f3cd2020051071dccee3f164f15d\
486             a92254e0b9c8a3a5030f4f70656e5468726561642d383964370102\
487             89d70410dc4b544c7a58671a2ce4f876f5d6dcd90c0402a0f7f824010118",
488            "encoded bytes: {got:02x?}",
489        );
490    }
491
492    #[test]
493    fn connect_network_thread_bytes_match_vector() {
494        // Vector: test-vectors/thread/network_commissioning.json
495        // "connect_network_thread" — network_id is the Extended PAN ID
496        // (8 bytes) extracted from the reference dataset, not an SSID.
497        let ext_pan_id = [0x78, 0x96, 0x21, 0x7f, 0x78, 0x7f, 0x6e, 0xbe];
498        let got = encode_connect_network(&ext_pan_id, 1);
499        assert_eq!(
500            hex::encode(&got),
501            "153000087896217f787f6ebe24010118",
502            "encoded bytes: {got:02x?}",
503        );
504    }
505
506    #[test]
507    fn connect_network_matter_matches_spec_bytes() {
508        let bytes = encode_connect_network(b"matter", 0);
509        assert_eq!(
510            bytes,
511            vec![
512                0x15, 0x30, 0x00, 0x06, b'm', b'a', b't', b't', b'e', b'r', 0x24, 0x01, 0x00, 0x18,
513            ],
514            "encoded bytes: {bytes:02x?}",
515        );
516    }
517
518    #[test]
519    fn decode_feature_map_round_trips_all_8_combinations() {
520        // TLV encoding for u32 value `v` (anonymous tag, minimum width):
521        // - 0 ..= 0xFF        → 0x04 0xVV               (uint-1B)
522        // - 0x100 ..= 0xFFFF  → 0x05 0xLO 0xHI          (uint-2B)
523        // For all 3-bit values (0..=7) we hit the uint-1B branch.
524        for raw in 0u8..8 {
525            let tlv = vec![0x04, raw];
526            let decoded = decode_feature_map(&tlv).expect("happy path decodes");
527            assert_eq!(decoded.bits(), u32::from(raw));
528        }
529    }
530
531    #[test]
532    fn decode_feature_map_rejects_non_uint_tlv() {
533        // Octet-string TLV — wrong element type.
534        let tlv = vec![0x10, 0x00];
535        let err = decode_feature_map(&tlv).expect_err("should fail");
536        assert!(
537            matches!(err, CommissioningError::MalformedResponse(_)),
538            "got {err:?}",
539        );
540    }
541
542    #[test]
543    fn decode_feature_map_truncates_high_bits_safely() {
544        // Reserved bits ignored — only WIFI|THREAD|ETHERNET (bits 0-2) recognised.
545        // raw value 0x0F = WIFI|THREAD|ETHERNET + bit 3 (reserved). Bit 3 dropped
546        // by from_bits_truncate.
547        let tlv = vec![0x04, 0x0F];
548        let decoded = decode_feature_map(&tlv).expect("decodes");
549        assert_eq!(
550            decoded,
551            NetworkCommissioningFeature::WIFI
552                | NetworkCommissioningFeature::THREAD
553                | NetworkCommissioningFeature::ETHERNET,
554        );
555    }
556
557    #[test]
558    fn decode_connect_max_time_seconds_round_trips() {
559        // 1-byte uint: { 0x04, 30 } → 30 seconds.
560        assert_eq!(decode_connect_max_time_seconds(&[0x04, 30]).unwrap(), 30);
561        // 2-byte uint: 0x05 <lo> <hi> → 300 seconds (0x012C).
562        assert_eq!(
563            decode_connect_max_time_seconds(&[0x05, 0x2C, 0x01]).unwrap(),
564            300
565        );
566    }
567
568    #[test]
569    fn decode_connect_max_time_seconds_clamps_oversize_to_u16_max() {
570        // 4-byte uint 0x0001_0000 (65536) exceeds u16 → clamped, not rejected.
571        let tlv = vec![0x06, 0x00, 0x00, 0x01, 0x00];
572        assert_eq!(decode_connect_max_time_seconds(&tlv).unwrap(), u16::MAX);
573    }
574
575    #[test]
576    fn decode_connect_max_time_seconds_rejects_non_uint() {
577        // Octet-string TLV — wrong element type.
578        let err = decode_connect_max_time_seconds(&[0x10, 0x00]).expect_err("should fail");
579        assert!(
580            matches!(err, CommissioningError::MalformedResponse(_)),
581            "got {err:?}",
582        );
583    }
584
585    #[test]
586    fn network_config_response_ok_round_trips() {
587        // { 0: 0_u8 }
588        let tlv = vec![0x15, 0x24, 0x00, 0x00, 0x18];
589        let decoded =
590            decode_network_config_response(Stage::NetworkSetup, &tlv).expect("happy path decodes");
591        assert_eq!(decoded.networking_status, 0);
592        assert_eq!(decoded.debug_text, None);
593    }
594
595    #[test]
596    fn network_config_response_auth_failure_with_debug_text() {
597        // { 0: 7_u8, 1: "wrong-pw" }
598        let tlv = vec![
599            0x15, 0x24, 0x00, 0x07, 0x2C, 0x01, 0x08, b'w', b'r', b'o', b'n', b'g', b'-', b'p',
600            b'w', 0x18,
601        ];
602        let decoded =
603            decode_network_config_response(Stage::NetworkSetup, &tlv).expect("happy path decodes");
604        assert_eq!(decoded.networking_status, 7);
605        assert_eq!(decoded.debug_text.as_deref(), Some("wrong-pw"));
606    }
607
608    #[test]
609    fn network_config_response_malformed_returns_error() {
610        let err =
611            decode_network_config_response(Stage::NetworkSetup, &[0xFF]).expect_err("should fail");
612        assert!(
613            matches!(err, CommissioningError::MalformedResponse(_)),
614            "got {err:?}"
615        );
616    }
617
618    #[test]
619    fn connect_network_response_ok_round_trips() {
620        let tlv = vec![0x15, 0x24, 0x00, 0x00, 0x18];
621        let decoded = decode_connect_network_response(Stage::NetworkEnable, &tlv)
622            .expect("happy path decodes");
623        assert_eq!(decoded.networking_status, 0);
624        assert_eq!(decoded.debug_text, None);
625        assert_eq!(decoded.error_value, None);
626    }
627
628    #[test]
629    fn connect_network_response_carries_error_value() {
630        // { 0: 9_u8, 2: i32 +10 }  — signed-int control byte 0x20, 1-byte value
631        let tlv = vec![
632            0x15, 0x24, 0x00, 0x09, // networking_status = 9
633            0x20, 0x02, 0x0A, // signed-int 1B, value +10
634            0x18,
635        ];
636        let decoded = decode_connect_network_response(Stage::NetworkEnable, &tlv).expect("decodes");
637        assert_eq!(decoded.networking_status, 9);
638        assert_eq!(decoded.error_value, Some(10));
639    }
640
641    #[test]
642    fn connect_network_response_malformed_returns_error() {
643        let err = decode_connect_network_response(Stage::NetworkEnable, &[0xFF])
644            .expect_err("should fail");
645        assert!(
646            matches!(err, CommissioningError::MalformedResponse(_)),
647            "got {err:?}"
648        );
649    }
650
651    #[test]
652    fn truncate_utf8_caps_bytes_without_splitting_chars() {
653        // ASCII: exact byte cap.
654        let s = "a".repeat(600);
655        assert_eq!(truncate_utf8(s, 512).len(), 512);
656
657        // Under the cap: untouched.
658        assert_eq!(truncate_utf8("short".to_string(), 512), "short");
659
660        // Multi-byte char straddling the boundary: floor to the previous char
661        // boundary — never panic, never emit invalid UTF-8. 'é' is 2 bytes;
662        // 511 ASCII bytes + 'é' puts the boundary mid-char at 512.
663        let mut s = "a".repeat(511);
664        s.push('é');
665        let t = truncate_utf8(s, 512);
666        assert_eq!(t.len(), 511, "must floor to the char boundary");
667        assert!(t.is_char_boundary(t.len()));
668    }
669
670    #[test]
671    fn decode_caps_debug_text_at_512_bytes() {
672        use matter_codec::{Tag, TlvWriter};
673        // NetworkConfigResponse TLV: anonymous struct { [0]=5u, [1]=600x'x' }.
674        let long_text = "x".repeat(600);
675        let mut buf = Vec::new();
676        let mut w = TlvWriter::new(&mut buf);
677        w.start_structure(Tag::Anonymous).unwrap();
678        w.put_uint(Tag::Context(0), 5).unwrap();
679        w.put_utf8(Tag::Context(1), &long_text).unwrap();
680        w.end_container().unwrap();
681
682        let resp = decode_network_config_response(Stage::NetworkSetup, &buf).unwrap();
683        assert_eq!(resp.networking_status, 5);
684        assert_eq!(resp.debug_text.unwrap().len(), 512, "capped at spec bound");
685    }
686
687    #[test]
688    fn decode_connect_network_response_caps_debug_text_at_512_bytes() {
689        use matter_codec::{Tag, TlvWriter};
690        // ConnectNetworkResponse TLV: anonymous struct { [0]=5u, [1]=600x'x' }.
691        let long_text = "x".repeat(600);
692        let mut buf = Vec::new();
693        let mut w = TlvWriter::new(&mut buf);
694        w.start_structure(Tag::Anonymous).unwrap();
695        w.put_uint(Tag::Context(0), 5).unwrap();
696        w.put_utf8(Tag::Context(1), &long_text).unwrap();
697        w.end_container().unwrap();
698
699        let resp = decode_connect_network_response(Stage::NetworkEnable, &buf).unwrap();
700        assert_eq!(resp.networking_status, 5);
701        assert_eq!(resp.debug_text.unwrap().len(), 512, "capped at spec bound");
702    }
703
704    #[test]
705    fn remediation_for_table_matches_spec() {
706        use RemediationHint::*;
707        let table: &[(u8, RemediationHint)] = &[
708            (0, None),                   // Success — non-error path, mapping is best-effort.
709            (1, None),                   // OutOfRange
710            (2, DeviceNetworkSlotsFull), // BoundsExceeded
711            (3, CheckSsid),              // NetworkIDNotFound
712            (4, None),                   // DuplicateNetworkID
713            (5, CheckSsid),              // NetworkNotFound
714            (6, CheckRegulatoryRegion),  // RegulatoryError
715            (7, CheckPassphrase),        // AuthFailure
716            (8, UpgradeSecurityMode),    // UnsupportedSecurity
717            (9, None),                   // OtherConnectionFailure
718            (10, DeviceIpStackFailure),  // IPV6Failed
719            (11, DeviceIpStackFailure),  // IPBindFailed
720            (12, None),                  // UnknownError
721        ];
722        for (code, expected) in table {
723            assert_eq!(
724                remediation_for(*code),
725                *expected,
726                "remediation_for({code}) mismatch",
727            );
728        }
729        // Unknown values (above the defined enum range) fall through to None.
730        assert_eq!(remediation_for(99), RemediationHint::None);
731        assert_eq!(remediation_for(u8::MAX), RemediationHint::None);
732    }
733}