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/// Decoded `NetworkConfigResponse` (spec §11.9.6.5). Emitted by
209/// `AddOrUpdateWiFiNetwork`, `RemoveNetwork`, `ReorderNetworks`.
210#[derive(Debug, Clone, PartialEq, Eq)]
211#[non_exhaustive]
212pub struct NetworkConfigResponse {
213    /// `NetworkCommissioningStatusEnum` (spec §11.9.5.1). 0 = OK.
214    pub networking_status: u8,
215    /// Optional human-readable debug text (≤512 chars).
216    pub debug_text: Option<String>,
217    // `network_index` deliberately omitted — only meaningful on the
218    // scan path, which M6.5 does not ship.
219}
220
221/// Decoded `ConnectNetworkResponse` (spec §11.9.6.6.2).
222#[derive(Debug, Clone, PartialEq, Eq)]
223#[non_exhaustive]
224pub struct ConnectNetworkResponse {
225    /// `NetworkCommissioningStatusEnum`. 0 = OK.
226    pub networking_status: u8,
227    /// Optional human-readable debug text.
228    pub debug_text: Option<String>,
229    /// Platform-specific Wi-Fi error code (spec §11.9.6.6.3). Optional.
230    pub error_value: Option<i32>,
231}
232
233/// Decode `NetworkConfigResponse` (spec §11.9.6.5).
234///
235/// `stage` is plumbed through so any error includes the right cursor
236/// position in `CommissioningError::MalformedResponse(_)`. Callers
237/// pass `Stage::NetworkSetup` in production.
238///
239/// # Errors
240///
241/// Returns `CommissioningError::MalformedResponse(stage)` on garbled
242/// TLV. A `networking_status != 0` is a *successful* decode whose
243/// non-OK value is mapped to
244/// `CommissioningError::NetworkRejected { remediation_hint, .. }` by
245/// the state-machine dispatch layer (M6.5.2).
246pub fn decode_network_config_response(
247    stage: Stage,
248    tlv: &[u8],
249) -> Result<NetworkConfigResponse, CommissioningError> {
250    use matter_codec::{ContainerKind, Element, Tag, TlvReader, Value};
251    let mut reader = TlvReader::new(tlv);
252    match reader
253        .next()
254        .map_err(|_| CommissioningError::MalformedResponse(stage))?
255    {
256        Some(Element::ContainerStart {
257            tag: Tag::Anonymous,
258            kind: ContainerKind::Structure,
259        }) => {}
260        _ => return Err(CommissioningError::MalformedResponse(stage)),
261    }
262    let mut networking_status: Option<u8> = None;
263    let mut debug_text: Option<String> = None;
264    loop {
265        match reader
266            .next()
267            .map_err(|_| CommissioningError::MalformedResponse(stage))?
268        {
269            Some(Element::ContainerEnd) => break,
270            Some(Element::Scalar {
271                tag: Tag::Context(0),
272                value: Value::Uint(v),
273            }) => {
274                if networking_status.is_some() {
275                    return Err(CommissioningError::MalformedResponse(stage));
276                }
277                networking_status = Some(
278                    u8::try_from(v).map_err(|_| CommissioningError::MalformedResponse(stage))?,
279                );
280            }
281            Some(Element::Scalar {
282                tag: Tag::Context(1),
283                value: Value::Utf8(s),
284            }) => {
285                if debug_text.is_some() {
286                    return Err(CommissioningError::MalformedResponse(stage));
287                }
288                debug_text = Some(s);
289            }
290            // Forward-compat: ignore tag 2 (network_index) on NetworkConfigResponse
291            // and all other unknown tags.
292            Some(Element::Scalar { .. } | Element::ContainerStart { .. }) => {}
293            None | Some(_) => return Err(CommissioningError::MalformedResponse(stage)),
294        }
295    }
296    let networking_status =
297        networking_status.ok_or(CommissioningError::MalformedResponse(stage))?;
298    Ok(NetworkConfigResponse {
299        networking_status,
300        debug_text,
301    })
302}
303
304/// Decode `ConnectNetworkResponse` (spec §11.9.6.6.2).
305///
306/// # Errors
307///
308/// Returns `CommissioningError::MalformedResponse(stage)` on garbled
309/// TLV. A `networking_status != 0` is a *successful* decode whose
310/// non-OK value is mapped to
311/// `CommissioningError::NetworkRejected { remediation_hint, .. }` by
312/// the state-machine dispatch layer (M6.5.2).
313pub fn decode_connect_network_response(
314    stage: Stage,
315    tlv: &[u8],
316) -> Result<ConnectNetworkResponse, CommissioningError> {
317    use matter_codec::{ContainerKind, Element, Tag, TlvReader, Value};
318    let mut reader = TlvReader::new(tlv);
319    match reader
320        .next()
321        .map_err(|_| CommissioningError::MalformedResponse(stage))?
322    {
323        Some(Element::ContainerStart {
324            tag: Tag::Anonymous,
325            kind: ContainerKind::Structure,
326        }) => {}
327        _ => return Err(CommissioningError::MalformedResponse(stage)),
328    }
329    let mut networking_status: Option<u8> = None;
330    let mut debug_text: Option<String> = None;
331    let mut error_value: Option<i32> = None;
332    loop {
333        match reader
334            .next()
335            .map_err(|_| CommissioningError::MalformedResponse(stage))?
336        {
337            Some(Element::ContainerEnd) => break,
338            Some(Element::Scalar {
339                tag: Tag::Context(0),
340                value: Value::Uint(v),
341            }) => {
342                if networking_status.is_some() {
343                    return Err(CommissioningError::MalformedResponse(stage));
344                }
345                networking_status = Some(
346                    u8::try_from(v).map_err(|_| CommissioningError::MalformedResponse(stage))?,
347                );
348            }
349            Some(Element::Scalar {
350                tag: Tag::Context(1),
351                value: Value::Utf8(s),
352            }) => {
353                if debug_text.is_some() {
354                    return Err(CommissioningError::MalformedResponse(stage));
355                }
356                debug_text = Some(s);
357            }
358            Some(Element::Scalar {
359                tag: Tag::Context(2),
360                value: Value::Int(v),
361            }) => {
362                if error_value.is_some() {
363                    return Err(CommissioningError::MalformedResponse(stage));
364                }
365                error_value = Some(
366                    i32::try_from(v).map_err(|_| CommissioningError::MalformedResponse(stage))?,
367                );
368            }
369            // Forward-compat: ignore unknown tags.
370            Some(Element::Scalar { .. } | Element::ContainerStart { .. }) => {}
371            None | Some(_) => return Err(CommissioningError::MalformedResponse(stage)),
372        }
373    }
374    let networking_status =
375        networking_status.ok_or(CommissioningError::MalformedResponse(stage))?;
376    Ok(ConnectNetworkResponse {
377        networking_status,
378        debug_text,
379        error_value,
380    })
381}
382
383/// Map a Matter `NetworkCommissioningStatusEnum` value (spec §11.9.5.1)
384/// to its [`RemediationHint`] category. Used by the M6.5.2 dispatch
385/// layer when constructing
386/// `CommissioningError::NetworkRejected` (lands in M6.5.2).
387///
388/// Any unmapped value (including values outside the defined enum
389/// range) returns [`RemediationHint::None`].
390#[must_use]
391pub const fn remediation_for(networking_status: u8) -> RemediationHint {
392    match networking_status {
393        2 => RemediationHint::DeviceNetworkSlotsFull,
394        3 | 5 => RemediationHint::CheckSsid,
395        6 => RemediationHint::CheckRegulatoryRegion,
396        7 => RemediationHint::CheckPassphrase,
397        8 => RemediationHint::UpgradeSecurityMode,
398        10 | 11 => RemediationHint::DeviceIpStackFailure,
399        _ => RemediationHint::None,
400    }
401}
402
403#[cfg(test)]
404#[allow(clippy::unwrap_used, clippy::expect_used)] // Test-code carve-out: see CLAUDE.md.
405mod tests {
406    use super::*;
407
408    #[test]
409    fn feature_bits_disjoint() {
410        assert_eq!(NetworkCommissioningFeature::WIFI.bits(), 0b001);
411        assert_eq!(NetworkCommissioningFeature::THREAD.bits(), 0b010);
412        assert_eq!(NetworkCommissioningFeature::ETHERNET.bits(), 0b100);
413    }
414
415    #[test]
416    fn cluster_id_is_0x0031() {
417        assert_eq!(CLUSTER_ID, 0x0031);
418    }
419
420    #[test]
421    fn add_or_update_wifi_network_matter_no_creds_matches_spec_bytes() {
422        let bytes = encode_add_or_update_wifi_network(b"matter", b"", 0);
423        assert_eq!(
424            bytes,
425            vec![
426                0x15, 0x30, 0x00, 0x06, b'm', b'a', b't', b't', b'e', b'r', 0x30, 0x01, 0x00, 0x24,
427                0x02, 0x00, 0x18,
428            ],
429            "encoded bytes: {bytes:02x?}",
430        );
431    }
432
433    #[test]
434    fn add_or_update_wifi_network_with_creds_includes_passphrase_bytes() {
435        let bytes = encode_add_or_update_wifi_network(b"matter", b"hunter22", 1);
436        // Check structural invariants without hand-computing the full byte string.
437        assert_eq!(bytes.first(), Some(&0x15));
438        assert_eq!(bytes.last(), Some(&0x18));
439        let window = b"hunter22";
440        assert!(
441            bytes.windows(window.len()).any(|w| w == window),
442            "credentials should appear in the payload literal",
443        );
444    }
445
446    #[test]
447    fn add_or_update_thread_network_bytes_match_vector() {
448        // Vector: test-vectors/thread/network_commissioning.json
449        // "add_or_update_thread_network" — reference dataset from
450        // "reference_operational_dataset" in the same file (111 bytes,
451        // OTBR `ot-ctl dataset active -x` capture).
452        let ds = hex::decode(
453            "0e08000000000001000000030000184a0300001235060004001fffe0020878\
454             96217f787f6ebe0708fdec3f34f3cd2020051071dccee3f164f15da92254e0\
455             b9c8a3a5030f4f70656e5468726561642d3839643701\
456             0289d70410dc4b544c7a58671a2ce4f876f5d6dcd90c0402a0f7f8",
457        )
458        .expect("valid hex literal");
459        assert_eq!(ds.len(), 111, "reference dataset must be 111 bytes");
460        let got = encode_add_or_update_thread_network(&ds, 1);
461        assert_eq!(
462            hex::encode(&got),
463            "1530006f0e08000000000001000000030000184a0300001235060004001fff\
464             e002087896217f787f6ebe0708fdec3f34f3cd2020051071dccee3f164f15d\
465             a92254e0b9c8a3a5030f4f70656e5468726561642d383964370102\
466             89d70410dc4b544c7a58671a2ce4f876f5d6dcd90c0402a0f7f824010118",
467            "encoded bytes: {got:02x?}",
468        );
469    }
470
471    #[test]
472    fn connect_network_thread_bytes_match_vector() {
473        // Vector: test-vectors/thread/network_commissioning.json
474        // "connect_network_thread" — network_id is the Extended PAN ID
475        // (8 bytes) extracted from the reference dataset, not an SSID.
476        let ext_pan_id = [0x78, 0x96, 0x21, 0x7f, 0x78, 0x7f, 0x6e, 0xbe];
477        let got = encode_connect_network(&ext_pan_id, 1);
478        assert_eq!(
479            hex::encode(&got),
480            "153000087896217f787f6ebe24010118",
481            "encoded bytes: {got:02x?}",
482        );
483    }
484
485    #[test]
486    fn connect_network_matter_matches_spec_bytes() {
487        let bytes = encode_connect_network(b"matter", 0);
488        assert_eq!(
489            bytes,
490            vec![
491                0x15, 0x30, 0x00, 0x06, b'm', b'a', b't', b't', b'e', b'r', 0x24, 0x01, 0x00, 0x18,
492            ],
493            "encoded bytes: {bytes:02x?}",
494        );
495    }
496
497    #[test]
498    fn decode_feature_map_round_trips_all_8_combinations() {
499        // TLV encoding for u32 value `v` (anonymous tag, minimum width):
500        // - 0 ..= 0xFF        → 0x04 0xVV               (uint-1B)
501        // - 0x100 ..= 0xFFFF  → 0x05 0xLO 0xHI          (uint-2B)
502        // For all 3-bit values (0..=7) we hit the uint-1B branch.
503        for raw in 0u8..8 {
504            let tlv = vec![0x04, raw];
505            let decoded = decode_feature_map(&tlv).expect("happy path decodes");
506            assert_eq!(decoded.bits(), u32::from(raw));
507        }
508    }
509
510    #[test]
511    fn decode_feature_map_rejects_non_uint_tlv() {
512        // Octet-string TLV — wrong element type.
513        let tlv = vec![0x10, 0x00];
514        let err = decode_feature_map(&tlv).expect_err("should fail");
515        assert!(
516            matches!(err, CommissioningError::MalformedResponse(_)),
517            "got {err:?}",
518        );
519    }
520
521    #[test]
522    fn decode_feature_map_truncates_high_bits_safely() {
523        // Reserved bits ignored — only WIFI|THREAD|ETHERNET (bits 0-2) recognised.
524        // raw value 0x0F = WIFI|THREAD|ETHERNET + bit 3 (reserved). Bit 3 dropped
525        // by from_bits_truncate.
526        let tlv = vec![0x04, 0x0F];
527        let decoded = decode_feature_map(&tlv).expect("decodes");
528        assert_eq!(
529            decoded,
530            NetworkCommissioningFeature::WIFI
531                | NetworkCommissioningFeature::THREAD
532                | NetworkCommissioningFeature::ETHERNET,
533        );
534    }
535
536    #[test]
537    fn decode_connect_max_time_seconds_round_trips() {
538        // 1-byte uint: { 0x04, 30 } → 30 seconds.
539        assert_eq!(decode_connect_max_time_seconds(&[0x04, 30]).unwrap(), 30);
540        // 2-byte uint: 0x05 <lo> <hi> → 300 seconds (0x012C).
541        assert_eq!(
542            decode_connect_max_time_seconds(&[0x05, 0x2C, 0x01]).unwrap(),
543            300
544        );
545    }
546
547    #[test]
548    fn decode_connect_max_time_seconds_clamps_oversize_to_u16_max() {
549        // 4-byte uint 0x0001_0000 (65536) exceeds u16 → clamped, not rejected.
550        let tlv = vec![0x06, 0x00, 0x00, 0x01, 0x00];
551        assert_eq!(decode_connect_max_time_seconds(&tlv).unwrap(), u16::MAX);
552    }
553
554    #[test]
555    fn decode_connect_max_time_seconds_rejects_non_uint() {
556        // Octet-string TLV — wrong element type.
557        let err = decode_connect_max_time_seconds(&[0x10, 0x00]).expect_err("should fail");
558        assert!(
559            matches!(err, CommissioningError::MalformedResponse(_)),
560            "got {err:?}",
561        );
562    }
563
564    #[test]
565    fn network_config_response_ok_round_trips() {
566        // { 0: 0_u8 }
567        let tlv = vec![0x15, 0x24, 0x00, 0x00, 0x18];
568        let decoded =
569            decode_network_config_response(Stage::NetworkSetup, &tlv).expect("happy path decodes");
570        assert_eq!(decoded.networking_status, 0);
571        assert_eq!(decoded.debug_text, None);
572    }
573
574    #[test]
575    fn network_config_response_auth_failure_with_debug_text() {
576        // { 0: 7_u8, 1: "wrong-pw" }
577        let tlv = vec![
578            0x15, 0x24, 0x00, 0x07, 0x2C, 0x01, 0x08, b'w', b'r', b'o', b'n', b'g', b'-', b'p',
579            b'w', 0x18,
580        ];
581        let decoded =
582            decode_network_config_response(Stage::NetworkSetup, &tlv).expect("happy path decodes");
583        assert_eq!(decoded.networking_status, 7);
584        assert_eq!(decoded.debug_text.as_deref(), Some("wrong-pw"));
585    }
586
587    #[test]
588    fn network_config_response_malformed_returns_error() {
589        let err =
590            decode_network_config_response(Stage::NetworkSetup, &[0xFF]).expect_err("should fail");
591        assert!(
592            matches!(err, CommissioningError::MalformedResponse(_)),
593            "got {err:?}"
594        );
595    }
596
597    #[test]
598    fn connect_network_response_ok_round_trips() {
599        let tlv = vec![0x15, 0x24, 0x00, 0x00, 0x18];
600        let decoded = decode_connect_network_response(Stage::NetworkEnable, &tlv)
601            .expect("happy path decodes");
602        assert_eq!(decoded.networking_status, 0);
603        assert_eq!(decoded.debug_text, None);
604        assert_eq!(decoded.error_value, None);
605    }
606
607    #[test]
608    fn connect_network_response_carries_error_value() {
609        // { 0: 9_u8, 2: i32 +10 }  — signed-int control byte 0x20, 1-byte value
610        let tlv = vec![
611            0x15, 0x24, 0x00, 0x09, // networking_status = 9
612            0x20, 0x02, 0x0A, // signed-int 1B, value +10
613            0x18,
614        ];
615        let decoded = decode_connect_network_response(Stage::NetworkEnable, &tlv).expect("decodes");
616        assert_eq!(decoded.networking_status, 9);
617        assert_eq!(decoded.error_value, Some(10));
618    }
619
620    #[test]
621    fn connect_network_response_malformed_returns_error() {
622        let err = decode_connect_network_response(Stage::NetworkEnable, &[0xFF])
623            .expect_err("should fail");
624        assert!(
625            matches!(err, CommissioningError::MalformedResponse(_)),
626            "got {err:?}"
627        );
628    }
629
630    #[test]
631    fn remediation_for_table_matches_spec() {
632        use RemediationHint::*;
633        let table: &[(u8, RemediationHint)] = &[
634            (0, None),                   // Success — non-error path, mapping is best-effort.
635            (1, None),                   // OutOfRange
636            (2, DeviceNetworkSlotsFull), // BoundsExceeded
637            (3, CheckSsid),              // NetworkIDNotFound
638            (4, None),                   // DuplicateNetworkID
639            (5, CheckSsid),              // NetworkNotFound
640            (6, CheckRegulatoryRegion),  // RegulatoryError
641            (7, CheckPassphrase),        // AuthFailure
642            (8, UpgradeSecurityMode),    // UnsupportedSecurity
643            (9, None),                   // OtherConnectionFailure
644            (10, DeviceIpStackFailure),  // IPV6Failed
645            (11, DeviceIpStackFailure),  // IPBindFailed
646            (12, None),                  // UnknownError
647        ];
648        for (code, expected) in table {
649            assert_eq!(
650                remediation_for(*code),
651                *expected,
652                "remediation_for({code}) mismatch",
653            );
654        }
655        // Unknown values (above the defined enum range) fall through to None.
656        assert_eq!(remediation_for(99), RemediationHint::None);
657        assert_eq!(remediation_for(u8::MAX), RemediationHint::None);
658    }
659}