Skip to main content

matter_commissioning/clusters/
general_commissioning.rs

1//! `GeneralCommissioning` cluster (id `0x0030`) command + response
2//! codecs.
3//!
4//! Spec §11.10. M6.4 uses `ArmFailSafe`, `SetRegulatoryConfig`, and
5//! `CommissioningComplete` plus their responses.
6
7#![forbid(unsafe_code)]
8
9use crate::clusters::network_commissioning::truncate_utf8;
10use crate::state_machine::{CommissioningError, Stage};
11
12/// Cluster ID: `0x0030`.
13pub const CLUSTER_ID: u32 = 0x0030;
14
15/// Command IDs (Matter Core Spec §11.10.6).
16pub mod command_id {
17    /// `ArmFailSafe` request.
18    pub const ARM_FAIL_SAFE: u32 = 0x00;
19    /// `SetRegulatoryConfig` request.
20    pub const SET_REGULATORY_CONFIG: u32 = 0x02;
21    /// `CommissioningComplete` request.
22    pub const COMMISSIONING_COMPLETE: u32 = 0x04;
23}
24
25/// Decoded `ArmFailSafeResponse` (spec §11.10.6.2).
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ArmFailSafeResponse {
28    /// `CommissioningErrorEnum` (spec §11.10.5.1). 0 = OK.
29    pub error_code: u8,
30    /// Optional human-readable debug text (≤128 chars), capped at the
31    /// spec's 512-octet bound at decode. **Device-controlled free text** —
32    /// log deliberately.
33    pub debug_text: Option<String>,
34}
35
36/// Decoded `SetRegulatoryConfigResponse` (spec §11.10.6.4).
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct SetRegulatoryConfigResponse {
39    /// `CommissioningErrorEnum`. 0 = OK.
40    pub error_code: u8,
41    /// Optional debug text, capped at the spec's 512-octet bound at
42    /// decode. **Device-controlled free text** — log deliberately.
43    pub debug_text: Option<String>,
44}
45
46/// `RegulatoryLocationTypeEnum` (spec §11.10.5.2).
47#[derive(Copy, Clone, Debug, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum RegulatoryLocation {
50    /// Indoor only.
51    Indoor = 0,
52    /// Outdoor only.
53    Outdoor = 1,
54    /// Indoor + outdoor.
55    IndoorOutdoor = 2,
56}
57
58/// Encode `ArmFailSafe` (spec §11.10.6.1).
59///
60/// `expiry_length_seconds == 0` disarms the failsafe.
61#[must_use]
62#[allow(clippy::expect_used, clippy::missing_panics_doc)] // Vec-backed TlvWriter is infallible.
63pub fn encode_arm_fail_safe(expiry_length_seconds: u16, breadcrumb: u64) -> Vec<u8> {
64    use matter_codec::{Tag, TlvWriter};
65    let mut buf = Vec::new();
66    let mut w = TlvWriter::new(&mut buf);
67    w.start_structure(Tag::Anonymous)
68        .expect("infallible: vec writer");
69    w.put_uint(Tag::Context(0), u64::from(expiry_length_seconds))
70        .expect("infallible: vec writer");
71    w.put_uint(Tag::Context(1), breadcrumb)
72        .expect("infallible: vec writer");
73    w.end_container().expect("infallible: vec writer");
74    buf
75}
76
77/// Encode `SetRegulatoryConfig` (spec §11.10.6.3).
78#[must_use]
79#[allow(clippy::expect_used, clippy::missing_panics_doc)] // Vec-backed TlvWriter is infallible.
80pub fn encode_set_regulatory_config(
81    new_regulatory_config: RegulatoryLocation,
82    country_code: &str,
83    breadcrumb: u64,
84) -> Vec<u8> {
85    use matter_codec::{Tag, TlvWriter};
86    let mut buf = Vec::new();
87    let mut w = TlvWriter::new(&mut buf);
88    w.start_structure(Tag::Anonymous)
89        .expect("infallible: vec writer");
90    w.put_uint(Tag::Context(0), u64::from(new_regulatory_config as u8))
91        .expect("infallible: vec writer");
92    w.put_utf8(Tag::Context(1), country_code)
93        .expect("infallible: vec writer");
94    w.put_uint(Tag::Context(2), breadcrumb)
95        .expect("infallible: vec writer");
96    w.end_container().expect("infallible: vec writer");
97    buf
98}
99
100/// Encode `CommissioningComplete` (spec §11.10.6.5).
101///
102/// `CommissioningComplete` carries no payload fields — just an empty
103/// anonymous structure. Sent over the CASE session at
104/// [`crate::state_machine::Stage::SendComplete`].
105#[must_use]
106#[allow(clippy::expect_used, clippy::missing_panics_doc)] // Vec-backed TlvWriter is infallible.
107pub fn encode_commissioning_complete() -> Vec<u8> {
108    use matter_codec::{Tag, TlvWriter};
109    let mut buf = Vec::new();
110    let mut w = TlvWriter::new(&mut buf);
111    w.start_structure(Tag::Anonymous)
112        .expect("infallible: vec writer");
113    w.end_container().expect("infallible: vec writer");
114    buf
115}
116
117/// Decode the shared `(error_code, debug_text)` shape used by
118/// `ArmFailSafeResponse`, `SetRegulatoryConfigResponse`, and
119/// `CommissioningCompleteResponse`.
120///
121/// `stage` is plumbed through so any error includes the right cursor
122/// position in the `CommissioningError::MalformedResponse(...)` variant.
123#[allow(clippy::match_same_arms)] // truncation vs malformed-shape are conceptually distinct, both surface as MalformedResponse.
124pub(crate) fn decode_commissioning_error_response(
125    stage: Stage,
126    tlv: &[u8],
127) -> Result<(u8, Option<String>), CommissioningError> {
128    use matter_codec::{ContainerKind, Element, Tag, TlvReader, Value};
129    let mut reader = TlvReader::new(tlv);
130    match reader
131        .next()
132        .map_err(|_| CommissioningError::MalformedResponse(stage))?
133    {
134        Some(Element::ContainerStart {
135            tag: Tag::Anonymous,
136            kind: ContainerKind::Structure,
137        }) => {}
138        _ => return Err(CommissioningError::MalformedResponse(stage)),
139    }
140    let mut error_code: Option<u8> = None;
141    let mut debug_text: Option<String> = None;
142    loop {
143        match reader
144            .next()
145            .map_err(|_| CommissioningError::MalformedResponse(stage))?
146        {
147            None => return Err(CommissioningError::MalformedResponse(stage)),
148            Some(Element::ContainerEnd) => break,
149            Some(Element::Scalar {
150                tag: Tag::Context(0),
151                value: Value::Uint(v),
152            }) => {
153                if error_code.is_some() {
154                    return Err(CommissioningError::MalformedResponse(stage));
155                }
156                let n =
157                    u8::try_from(v).map_err(|_| CommissioningError::MalformedResponse(stage))?;
158                error_code = Some(n);
159            }
160            Some(Element::Scalar {
161                tag: Tag::Context(1),
162                value: Value::Utf8(s),
163            }) => {
164                if debug_text.is_some() {
165                    return Err(CommissioningError::MalformedResponse(stage));
166                }
167                // Spec bound (§11.10): DebugText ≤ 512 octets. Device-echoed free text — cap defensively.
168                debug_text = Some(truncate_utf8(s, 512));
169            }
170            // Forward-compat: ignore future tags.
171            Some(Element::Scalar { .. } | Element::ContainerStart { .. }) => {}
172            Some(_) => return Err(CommissioningError::MalformedResponse(stage)),
173        }
174    }
175    let error_code = error_code.ok_or(CommissioningError::MalformedResponse(stage))?;
176    Ok((error_code, debug_text))
177}
178
179/// Decode `ArmFailSafeResponse` (spec §11.10.6.2).
180///
181/// # Errors
182///
183/// Returns `CommissioningError::MalformedResponse(Stage::ArmFailsafe)`
184/// on malformed input.
185pub fn decode_arm_fail_safe_response(
186    tlv: &[u8],
187) -> Result<ArmFailSafeResponse, CommissioningError> {
188    let (error_code, debug_text) = decode_commissioning_error_response(Stage::ArmFailsafe, tlv)?;
189    Ok(ArmFailSafeResponse {
190        error_code,
191        debug_text,
192    })
193}
194
195/// Decode `SetRegulatoryConfigResponse` (spec §11.10.6.4).
196///
197/// # Errors
198///
199/// Returns `CommissioningError::MalformedResponse(Stage::ConfigRegulatory)`
200/// on malformed input.
201pub fn decode_set_regulatory_config_response(
202    tlv: &[u8],
203) -> Result<SetRegulatoryConfigResponse, CommissioningError> {
204    let (error_code, debug_text) =
205        decode_commissioning_error_response(Stage::ConfigRegulatory, tlv)?;
206    Ok(SetRegulatoryConfigResponse {
207        error_code,
208        debug_text,
209    })
210}
211
212/// Decoded `BasicCommissioningInfo` struct (spec §11.10.5.5).
213///
214/// The commissioner reads `failsafe_expiry_length_seconds` as the requested
215/// `ArmFailSafe` expiry and caps it against `max_cumulative_failsafe_seconds`
216/// so it never sends a value the device is guaranteed to reject with
217/// `BoundsExceeded`.
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub struct BasicCommissioningInfo {
220    /// Maximum failsafe expiry the device will honour, in seconds.
221    /// Context tag 0 inside the `BasicCommissioningInfo` struct.
222    pub failsafe_expiry_length_seconds: u16,
223    /// Spec §11.10.5.5 — context tag 1. Optional in the spec; if
224    /// absent in the wire payload this field is `0`.
225    pub max_cumulative_failsafe_seconds: u16,
226}
227
228/// Best-effort decode of a `BasicCommissioningInfo` struct from its
229/// TLV bytes.
230///
231/// Returns `None` on any parse failure — the state machine treats a
232/// missing value as "use the M6.4 fallback of 60 seconds." This keeps
233/// the state machine moving even against malformed devices.
234#[must_use]
235pub fn decode_basic_commissioning_info(tlv: &[u8]) -> Option<BasicCommissioningInfo> {
236    use matter_codec::{ContainerKind, Element, Tag, TlvReader, Value};
237    let mut reader = TlvReader::new(tlv);
238    match reader.next().ok().flatten()? {
239        Element::ContainerStart {
240            tag: Tag::Anonymous,
241            kind: ContainerKind::Structure,
242        } => {}
243        _ => return None,
244    }
245    let mut failsafe: Option<u16> = None;
246    let mut max_cumulative: u16 = 0;
247    loop {
248        match reader.next().ok().flatten() {
249            None => return None,
250            Some(Element::ContainerEnd) => break,
251            Some(Element::Scalar {
252                tag: Tag::Context(0),
253                value: Value::Uint(v),
254            }) => {
255                failsafe = u16::try_from(v).ok();
256            }
257            Some(Element::Scalar {
258                tag: Tag::Context(1),
259                value: Value::Uint(v),
260            }) => {
261                if let Ok(n) = u16::try_from(v) {
262                    max_cumulative = n;
263                }
264            }
265            // Forward-compat: ignore unrecognised tags.
266            Some(_) => {}
267        }
268    }
269    failsafe.map(|fs| BasicCommissioningInfo {
270        failsafe_expiry_length_seconds: fs,
271        max_cumulative_failsafe_seconds: max_cumulative,
272    })
273}
274
275#[cfg(test)]
276#[allow(
277    clippy::unwrap_used,
278    clippy::expect_used,
279    clippy::items_after_statements
280)] // Test-code carve-out: see CLAUDE.md.
281mod tests {
282    use super::*;
283
284    #[test]
285    fn arm_fail_safe_60_0_matches_spec_bytes() {
286        let bytes = encode_arm_fail_safe(60, 0);
287        assert_eq!(
288            bytes,
289            vec![0x15, 0x24, 0x00, 0x3C, 0x24, 0x01, 0x00, 0x18],
290            "encoded bytes: {bytes:02x?}"
291        );
292    }
293
294    #[test]
295    fn commissioning_complete_is_empty_anonymous_struct() {
296        let bytes = encode_commissioning_complete();
297        assert_eq!(bytes, vec![0x15, 0x18]);
298    }
299
300    #[test]
301    fn arm_fail_safe_response_ok_round_trips() {
302        // Encode an ok response by hand: { 0: 0_u8 } — debug_text omitted.
303        let tlv = vec![0x15, 0x24, 0x00, 0x00, 0x18];
304        let decoded = decode_arm_fail_safe_response(&tlv).expect("happy path decodes");
305        assert_eq!(decoded.error_code, 0);
306        assert_eq!(decoded.debug_text, None);
307    }
308
309    #[test]
310    fn arm_fail_safe_response_with_debug_text_round_trips() {
311        // { 0: 1_u8, 1: "busy" }
312        let tlv = vec![
313            0x15, 0x24, 0x00, 0x01, // error_code = 1 (busy)
314            0x2C, 0x01, 0x04, b'b', b'u', b's', b'y', // debug_text = "busy"
315            0x18, // end
316        ];
317        let decoded = decode_arm_fail_safe_response(&tlv).expect("happy path decodes");
318        assert_eq!(decoded.error_code, 1);
319        assert_eq!(decoded.debug_text.as_deref(), Some("busy"));
320    }
321
322    #[test]
323    fn malformed_response_returns_error() {
324        // Test-code carve-out: see CLAUDE.md.
325        let err = decode_arm_fail_safe_response(&[0xFF]).expect_err("should fail");
326        assert!(
327            matches!(
328                err,
329                crate::state_machine::CommissioningError::MalformedResponse(
330                    crate::state_machine::Stage::ArmFailsafe
331                )
332            ),
333            "got {err:?}"
334        );
335    }
336
337    #[test]
338    fn basic_commissioning_info_120_decodes() {
339        // { 0: 120_u16, 1: 900_u16 }
340        let tlv = vec![
341            0x15, 0x25, 0x00, 0x78, 0x00, // u16 = 120
342            0x25, 0x01, 0x84, 0x03, // u16 = 900
343            0x18,
344        ];
345        let info = decode_basic_commissioning_info(&tlv).expect("decodes");
346        assert_eq!(info.failsafe_expiry_length_seconds, 120);
347        assert_eq!(info.max_cumulative_failsafe_seconds, 900);
348    }
349
350    #[test]
351    fn basic_commissioning_info_malformed_returns_none() {
352        assert!(decode_basic_commissioning_info(&[0xFF]).is_none());
353        assert!(decode_basic_commissioning_info(&[]).is_none());
354    }
355
356    #[test]
357    fn arm_fail_safe_response_caps_debug_text_at_512_bytes() {
358        use matter_codec::{Tag, TlvWriter};
359        // ArmFailSafeResponse TLV: anonymous struct { [0]=1u, [1]=600x'x' }.
360        let long_text = "x".repeat(600);
361        let mut buf = Vec::new();
362        let mut w = TlvWriter::new(&mut buf);
363        w.start_structure(Tag::Anonymous).unwrap();
364        w.put_uint(Tag::Context(0), 1).unwrap();
365        w.put_utf8(Tag::Context(1), &long_text).unwrap();
366        w.end_container().unwrap();
367
368        let decoded = decode_arm_fail_safe_response(&buf).expect("happy path decodes");
369        assert_eq!(decoded.error_code, 1);
370        assert_eq!(
371            decoded.debug_text.unwrap().len(),
372            512,
373            "capped at spec bound"
374        );
375    }
376}