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