Skip to main content

matter_commissioning/setup/
mod.rs

1//! Setup payload parsing and encoding for Matter QR codes and manual
2//! pairing codes (Matter Core Spec §5.1).
3//!
4//! This is Milestone 6 phase 1 of the `matter-rust` roadmap. See
5//! `docs/superpowers/specs/2026-05-22-matter-commissioning-setup-payload-design.md`
6//! for design rationale and `docs/superpowers/specs/2026-05-22-matter-commissioning-design.md`
7//! for the M6 umbrella.
8//!
9//! # Phase status
10//!
11//! - **M6.1 (this revision):** QR-code and manual-pairing-code codec, no
12//!   vendor TLV (deferred to a later phase). `SetupPayload` is the
13//!   canonical in-memory representation.
14
15#![forbid(unsafe_code)]
16
17mod base38;
18mod manual_packer;
19mod qr_packer;
20mod verhoeff;
21
22/// The decoded contents of a Matter onboarding payload (QR code or manual
23/// pairing code), as defined in Matter Core Spec §5.1.3.
24///
25/// Roundtrip identities:
26///
27/// ```ignore
28/// // For every valid `p` produced by M6.1:
29/// assert_eq!(parse_qr(&encode_qr(&p)?)?, p);
30/// assert_eq!(parse_manual_code(&encode_manual_code(&p)), p);  // see caveat below
31/// ```
32///
33/// The manual-code roundtrip preserves the *upper four bits* of the
34/// discriminator (the short discriminator) and zero-extends the rest.
35/// A `SetupPayload` decoded from a manual code therefore has a
36/// discriminator whose lower 8 bits are zero, regardless of what the
37/// physical device's long discriminator actually is. Callers matching
38/// against mDNS records should compare on the short discriminator in
39/// that case.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SetupPayload {
42    /// Onboarding payload version. Currently always `0` (Matter Core
43    /// Spec §5.1.3.1 Table 39). Reserved for future use.
44    pub version: u8,
45
46    /// Vendor ID. `None` if the source was an 11-digit manual code,
47    /// which does not carry VID/PID.
48    pub vendor_id: Option<u16>,
49
50    /// Product ID. Pair with `vendor_id` — both are `Some` or both
51    /// `None`.
52    pub product_id: Option<u16>,
53
54    /// Commissioning flow indicator.
55    pub commissioning_flow: CommissioningFlow,
56
57    /// Bitmask of discovery transports the device supports while
58    /// commissionable. Always present in QR codes; manual codes do not
59    /// carry this field and decode it as the empty set.
60    pub discovery_capabilities: DiscoveryCapabilities,
61
62    /// 12-bit Long Discriminator. See the type-level rustdoc for the
63    /// manual-code caveat.
64    pub discriminator: Discriminator,
65
66    /// 27-bit passcode.
67    pub passcode: Passcode,
68}
69
70/// Twelve-bit long discriminator identifying a Matter device while it
71/// is commissionable (Matter Core Spec §5.1.2.2).
72///
73/// Constructors enforce the 12-bit range. The short discriminator (the
74/// upper 4 bits) is what manual pairing codes carry.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub struct Discriminator(u16);
77
78impl Discriminator {
79    /// Construct from a 12-bit value.
80    ///
81    /// # Errors
82    /// Returns [`Error::DiscriminatorOutOfRange`] if `value > 0x0FFF`.
83    pub const fn new(value: u16) -> Result<Self> {
84        if value > 0x0FFF {
85            Err(Error::DiscriminatorOutOfRange(value))
86        } else {
87            Ok(Self(value))
88        }
89    }
90
91    /// The discriminator as a raw `u16` in the range `0..=0x0FFF`.
92    pub const fn as_u16(self) -> u16 {
93        self.0
94    }
95
96    /// Upper 4 bits — the *short* discriminator carried by manual
97    /// pairing codes.
98    pub const fn short(self) -> u8 {
99        ((self.0 >> 8) & 0x0F) as u8
100    }
101}
102
103/// Disallowed-trivial passcode values from Matter Core Spec §5.1.7.1.
104///
105/// All-same-digit values plus the counting-up and counting-down sequences.
106/// The Matter spec rejects these because they offer no protection against
107/// guessing during the commissioning window.
108///
109/// Note: the standard test passcode `20_202_021` is NOT on this list —
110/// the spec carves it out as a permitted test value.
111///
112/// Re-exported so tests and external callers can filter values
113/// generated for synthetic payloads (the proptest roundtrip suite in
114/// `tests/setup_proptest.rs` is the primary in-tree consumer).
115pub const DISALLOWED_PASSCODES: &[u32] = &[
116    0, 11_111_111, 22_222_222, 33_333_333, 44_444_444, 55_555_555, 66_666_666, 77_777_777,
117    88_888_888, 99_999_999, 12_345_678, 87_654_321,
118];
119
120/// Largest valid Matter setup passcode (Core Spec §5.1.7.1: the passcode SHALL
121/// be in `1..=99_999_998`). The QR/manual-code wire field is 27 bits wide, but
122/// values in `99_999_999..=0x07FF_FFFF` are **not** valid passcodes — every
123/// spec-compliant commissioner (chip-tool, Apple/Google Home, …) rejects a
124/// setup code that carries one.
125pub const MAX_PASSCODE: u32 = 99_999_998;
126
127/// 27-bit Matter setup passcode (Matter Core Spec §5.1.7).
128///
129/// Constructors enforce the 27-bit range and exclude the disallowed-trivial
130/// values from spec §5.1.7.1. The standard test passcode `20_202_021` is
131/// permitted.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
133pub struct Passcode(u32);
134
135impl Passcode {
136    /// Construct a setup passcode, enforcing Matter Core Spec §5.1.7.1: the
137    /// value SHALL be in `1..=99_999_998` ([`MAX_PASSCODE`]) and MUST NOT be one
138    /// of the trivial values in [`DISALLOWED_PASSCODES`].
139    ///
140    /// The wire field is 27 bits, but a value above [`MAX_PASSCODE`] (up to
141    /// `2^27 - 1`) is **not** a valid passcode — a setup code carrying one is
142    /// rejected by every spec-compliant commissioner, so we reject it here
143    /// rather than emit or accept an uncommissionable code.
144    ///
145    /// # Errors
146    /// Returns [`Error::PasscodeDisallowedTrivial`] if `value` is one of the
147    /// spec-disallowed values in [`DISALLOWED_PASSCODES`] (this includes `0`).
148    /// Returns [`Error::PasscodeOutOfRange`] if `value > MAX_PASSCODE`.
149    pub fn new(value: u32) -> Result<Self> {
150        // Disallowed-set first, so the trivial values (including 0 and
151        // 99_999_999) report `PasscodeDisallowedTrivial` rather than
152        // `PasscodeOutOfRange`.
153        if DISALLOWED_PASSCODES.contains(&value) {
154            return Err(Error::PasscodeDisallowedTrivial(value));
155        }
156        if value > MAX_PASSCODE {
157            return Err(Error::PasscodeOutOfRange(value));
158        }
159        Ok(Self(value))
160    }
161
162    /// The passcode as a raw `u32` in the range `0..1 << 27`.
163    pub const fn as_u32(self) -> u32 {
164        self.0
165    }
166}
167
168/// Commissioning flow indicator from Matter Core Spec §5.1.3.1 Table 39.
169///
170/// Two bits on the wire. Value `3` is reserved.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
172#[non_exhaustive]
173pub enum CommissioningFlow {
174    /// `0` — Device is fully configured; commissioning works as published.
175    Standard,
176    /// `1` — Device requires user-intent (a button press or similar) before
177    /// it begins advertising commissioning.
178    UserIntent,
179    /// `2` — Custom commissioning flow; commissioner must consult the
180    /// vendor's instructions. Not supported by matter-rust.
181    Custom,
182}
183
184impl CommissioningFlow {
185    /// Decode a wire-format value.
186    ///
187    /// # Errors
188    /// Returns [`Error::CommissioningFlowReserved`] for any input outside
189    /// `0..=2` (including the spec-reserved value `3`).
190    pub const fn from_u8(value: u8) -> Result<Self> {
191        match value {
192            0 => Ok(Self::Standard),
193            1 => Ok(Self::UserIntent),
194            2 => Ok(Self::Custom),
195            other => Err(Error::CommissioningFlowReserved(other)),
196        }
197    }
198
199    /// Encode as the wire-format 2-bit value.
200    pub const fn as_u8(self) -> u8 {
201        match self {
202            Self::Standard => 0,
203            Self::UserIntent => 1,
204            Self::Custom => 2,
205        }
206    }
207}
208
209bitflags::bitflags! {
210    /// Matter Core Spec §5.1.3.1 Table 39 "Discovery Capabilities" — the
211    /// 8-bit bitmask advertising which discovery transports the device
212    /// supports while commissionable.
213    ///
214    /// Bits 3-7 are spec-reserved but preserved on roundtrip — we use
215    /// `from_bits_retain` rather than `from_bits` so unknown future bits
216    /// pass through unchanged.
217    ///
218    /// Bit positions are verified against matter.js's
219    /// `DiscoveryCapabilitiesSchema`. See the file's leading comment.
220    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
221    pub struct DiscoveryCapabilities: u8 {
222        /// Device hosts a Soft-AP for direct connection.
223        const SOFT_AP    = 0b0000_0001;
224        /// Device advertises commissioning over Bluetooth LE.
225        const BLE        = 0b0000_0010;
226        /// Device is reachable via an IP network (Wi-Fi / Ethernet / Thread).
227        const ON_NETWORK = 0b0000_0100;
228    }
229}
230
231/// Errors from setup-payload parsing and encoding.
232///
233/// All variants carry enough context (position, value, expected) for
234/// callers to render useful diagnostics.
235#[derive(Debug, thiserror::Error)]
236#[non_exhaustive]
237pub enum Error {
238    /// QR strings must begin with the four-character `MT:` prefix.
239    #[error("QR string is missing the `MT:` prefix")]
240    MissingMtPrefix,
241
242    /// A character outside Matter's 38-character alphabet appeared in
243    /// the Base38 payload.
244    #[error("invalid Base38 character `{0}` at position {1}")]
245    InvalidBase38Char(char, usize),
246
247    /// The Base38-decoded payload is the wrong size for a Matter QR.
248    #[error("QR payload is the wrong length: {got} bytes, expected exactly {need}")]
249    QrPayloadWrongLength {
250        /// Number of bytes actually decoded.
251        got: usize,
252        /// Number of bytes the spec requires (currently always 11).
253        need: usize,
254    },
255
256    /// The Base38-decoded payload is longer than the fixed 11-byte block;
257    /// M6.1 does not support the optional vendor TLV blob.
258    #[error("QR payload has {extra} byte(s) after the fixed 11-byte block; vendor TLV blobs are not supported in this release")]
259    QrTrailingBytes {
260        /// Number of bytes past the fixed block.
261        extra: usize,
262    },
263
264    /// Manual code must be exactly 11 or 21 digits.
265    #[error("manual code must be 11 or 21 digits; got {0}")]
266    ManualCodeWrongLength(usize),
267
268    /// Manual code contains a non-digit character.
269    #[error("manual code contains non-digit `{0}` at position {1}")]
270    ManualCodeNonDigit(char, usize),
271
272    /// The Verhoeff check digit at the end of the manual code did not
273    /// validate against the preceding digits.
274    #[error("manual code Verhoeff check digit failed")]
275    ManualCodeBadChecksum,
276
277    /// A manual-code VID or PID field decoded to a 5-digit decimal value
278    /// that does not fit in the 16-bit field it maps to (i.e. `> 65535`).
279    ///
280    /// A manual pairing code carries VID and PID as 5-digit decimals, whose
281    /// maximum (`99999`) exceeds `u16::MAX` (`65535`). Rather than silently
282    /// truncating an out-of-range value to `u16`, the parser rejects it.
283    #[error("manual code {field} value {value} exceeds the 16-bit field width")]
284    FieldOutOfRange {
285        /// Which field overflowed: `"vendor_id"` or `"product_id"`.
286        field: &'static str,
287        /// The out-of-range decimal value parsed from the code.
288        value: u32,
289    },
290
291    /// The 12-bit Long Discriminator field is out of range.
292    #[error("discriminator {0} exceeds the 12-bit field width")]
293    DiscriminatorOutOfRange(u16),
294
295    /// The 27-bit Passcode field is out of range.
296    #[error("passcode {0} exceeds the 27-bit field width")]
297    PasscodeOutOfRange(u32),
298
299    /// The passcode value is on the Matter spec's disallowed-trivial list
300    /// (Matter Core Spec §5.1.7.1).
301    #[error("passcode {0} is in the disallowed-trivial list (spec §5.1.7.1)")]
302    PasscodeDisallowedTrivial(u32),
303
304    /// The 2-bit Commissioning Flow field decoded to a reserved value.
305    #[error("commissioning flow value {0} is reserved")]
306    CommissioningFlowReserved(u8),
307
308    /// `encode_qr` was called on a `SetupPayload` whose VID or PID is
309    /// `None` (the manual-code-only case).
310    #[error("QR-form payload requires both vendor_id and product_id to be present")]
311    QrRequiresVidPid,
312
313    /// The Matter spec defines a `Custom` commissioning flow whose
314    /// semantics are vendor-defined and not supported by matter-rust.
315    #[error("commissioning flow `Custom` requires vendor-specific QR fields not supported by matter-rust")]
316    CustomFlowUnsupported,
317}
318
319/// Convenience alias for `Result<T, Error>` inside the setup module.
320pub type Result<T> = core::result::Result<T, Error>;
321
322const QR_PREFIX: &str = "MT:";
323
324/// Encode a `SetupPayload` as a Matter QR string (Matter Core Spec §5.1.3.1).
325///
326/// The returned string always begins with `MT:` followed by Matter Base38.
327///
328/// # Errors
329/// Returns [`Error::QrRequiresVidPid`] if either VID or PID is `None`.
330/// Returns [`Error::CustomFlowUnsupported`] for `CommissioningFlow::Custom`.
331///
332/// # Examples
333///
334/// ```
335/// use matter_commissioning::setup::{
336///     encode_qr, parse_qr,
337///     CommissioningFlow, Discriminator, DiscoveryCapabilities,
338///     Passcode, SetupPayload,
339/// };
340/// let payload = SetupPayload {
341///     version: 0,
342///     vendor_id: Some(0xFFF1),
343///     product_id: Some(0x8000),
344///     commissioning_flow: CommissioningFlow::Standard,
345///     discovery_capabilities: DiscoveryCapabilities::ON_NETWORK,
346///     discriminator: Discriminator::new(0xF00).unwrap(),
347///     passcode: Passcode::new(20_202_021).unwrap(),
348/// };
349/// let qr = encode_qr(&payload).unwrap();
350/// assert!(qr.starts_with("MT:"));
351/// assert_eq!(parse_qr(&qr).unwrap(), payload);
352/// ```
353pub fn encode_qr(payload: &SetupPayload) -> Result<String> {
354    let bytes = qr_packer::pack(payload)?;
355    Ok(format!("{QR_PREFIX}{}", base38::encode(&bytes)))
356}
357
358/// Parse a Matter QR string into a `SetupPayload`.
359///
360/// # Errors
361/// Returns [`Error::MissingMtPrefix`] if the string does not begin with
362/// `MT:`.
363/// Returns [`Error::InvalidBase38Char`] for any character outside Matter's
364/// Base38 alphabet.
365/// Returns [`Error::QrPayloadWrongLength`] or [`Error::QrTrailingBytes`]
366/// for payload-length problems.
367/// Returns per-field range errors (`DiscriminatorOutOfRange`,
368/// `PasscodeOutOfRange`, `PasscodeDisallowedTrivial`,
369/// `CommissioningFlowReserved`) raised by the QR bit unpacker.
370///
371/// # Examples
372///
373/// ```
374/// use matter_commissioning::setup::parse_qr;
375/// // Captured from matter.js for the Matter Core Spec §5.1.3.1 worked
376/// // example (VID 0xFFF1, PID 0x8000, discriminator 0xF00, passcode
377/// // 20_202_021). Source:
378/// // test-vectors/commissioning/setup/qr-spec-example.json
379/// let payload = parse_qr("MT:Y.K90AFN00KA0648G00").unwrap();
380/// assert_eq!(payload.vendor_id, Some(0xFFF1));
381/// assert_eq!(payload.product_id, Some(0x8000));
382/// assert_eq!(payload.passcode.as_u32(), 20_202_021);
383/// ```
384pub fn parse_qr(s: &str) -> Result<SetupPayload> {
385    let payload = s.strip_prefix(QR_PREFIX).ok_or(Error::MissingMtPrefix)?;
386    let bytes = base38::decode(payload)?;
387    let need = qr_packer::FIXED_BYTE_LEN;
388    if bytes.len() < need {
389        return Err(Error::QrPayloadWrongLength {
390            got: bytes.len(),
391            need,
392        });
393    }
394    if bytes.len() > need {
395        return Err(Error::QrTrailingBytes {
396            extra: bytes.len() - need,
397        });
398    }
399    let mut fixed = [0u8; qr_packer::FIXED_BYTE_LEN];
400    fixed.copy_from_slice(&bytes[..need]);
401    qr_packer::unpack(&fixed)
402}
403
404/// Encode a `SetupPayload` as a manual pairing code (Matter Core Spec §5.1.4).
405///
406/// Emits the 21-digit form if `vendor_id` and `product_id` are both
407/// `Some`, otherwise the 11-digit form. The final digit is always the
408/// Verhoeff check digit.
409///
410/// # Examples
411///
412/// ```
413/// use matter_commissioning::setup::{
414///     encode_manual_code, parse_manual_code,
415///     CommissioningFlow, Discriminator, DiscoveryCapabilities,
416///     Passcode, SetupPayload,
417/// };
418/// let payload = SetupPayload {
419///     version: 0,
420///     vendor_id: None,
421///     product_id: None,
422///     commissioning_flow: CommissioningFlow::Standard,
423///     discovery_capabilities: DiscoveryCapabilities::empty(),
424///     discriminator: Discriminator::new(0xF00).unwrap(),
425///     passcode: Passcode::new(20_202_021).unwrap(),
426/// };
427/// let code = encode_manual_code(&payload);
428/// assert_eq!(code.len(), 11);
429/// assert_eq!(parse_manual_code(&code).unwrap(), payload);
430/// ```
431pub fn encode_manual_code(payload: &SetupPayload) -> String {
432    manual_packer::pack(payload)
433}
434
435/// Parse a Matter manual pairing code (11 or 21 digits).
436///
437/// # Errors
438/// Returns [`Error::ManualCodeWrongLength`], [`Error::ManualCodeNonDigit`],
439/// [`Error::ManualCodeBadChecksum`], or any per-field range error.
440pub fn parse_manual_code(s: &str) -> Result<SetupPayload> {
441    manual_packer::unpack(s)
442}
443
444#[cfg(test)]
445#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
446mod error_tests {
447    use super::Error;
448
449    #[test]
450    fn display_missing_mt_prefix() {
451        assert_eq!(
452            Error::MissingMtPrefix.to_string(),
453            "QR string is missing the `MT:` prefix"
454        );
455    }
456
457    #[test]
458    fn display_invalid_base38_char() {
459        assert_eq!(
460            Error::InvalidBase38Char('?', 7).to_string(),
461            "invalid Base38 character `?` at position 7"
462        );
463    }
464
465    #[test]
466    fn display_qr_trailing_bytes() {
467        assert_eq!(
468            Error::QrTrailingBytes { extra: 3 }.to_string(),
469            "QR payload has 3 byte(s) after the fixed 11-byte block; vendor TLV blobs are not supported in this release"
470        );
471    }
472
473    #[test]
474    fn display_manual_bad_checksum() {
475        assert_eq!(
476            Error::ManualCodeBadChecksum.to_string(),
477            "manual code Verhoeff check digit failed"
478        );
479    }
480}
481
482#[cfg(test)]
483#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
484mod discriminator_tests {
485    use super::{Discriminator, Error};
486
487    #[test]
488    fn new_accepts_zero() {
489        let d = Discriminator::new(0).unwrap();
490        assert_eq!(d.as_u16(), 0);
491        assert_eq!(d.short(), 0);
492    }
493
494    #[test]
495    fn new_accepts_max_12_bit() {
496        let d = Discriminator::new(0x0FFF).unwrap();
497        assert_eq!(d.as_u16(), 0x0FFF);
498        assert_eq!(d.short(), 0x0F);
499    }
500
501    #[test]
502    fn new_rejects_13_bit() {
503        let err = Discriminator::new(0x1000).unwrap_err();
504        assert!(matches!(err, Error::DiscriminatorOutOfRange(0x1000)));
505    }
506
507    #[test]
508    fn short_is_upper_4_bits() {
509        // 0xABC = bits 10101011 1100; upper 4 bits = 0xA
510        let d = Discriminator::new(0x0ABC).unwrap();
511        assert_eq!(d.short(), 0xA);
512    }
513}
514
515#[cfg(test)]
516#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
517mod passcode_tests {
518    use super::{Error, Passcode};
519
520    #[test]
521    fn new_accepts_normal_value() {
522        // 20202021 is the standard Matter test passcode. The spec excludes
523        // a handful of trivial all-same-digit and counting-sequence
524        // values, but 20202021 is allowed.
525        let p = Passcode::new(20_202_021).unwrap();
526        assert_eq!(p.as_u32(), 20_202_021);
527    }
528
529    #[test]
530    fn new_rejects_28_bit_value() {
531        let too_large = 1u32 << 27;
532        let err = Passcode::new(too_large).unwrap_err();
533        assert!(matches!(err, Error::PasscodeOutOfRange(v) if v == too_large));
534    }
535
536    #[test]
537    fn new_accepts_high_valid_value() {
538        // A high, non-trivial, in-range passcode (well under MAX_PASSCODE).
539        let p = Passcode::new(99_000_001).unwrap();
540        assert_eq!(p.as_u32(), 99_000_001);
541    }
542
543    #[test]
544    fn new_accepts_max_passcode() {
545        // The spec maximum (§5.1.7.1) is valid.
546        let p = Passcode::new(super::MAX_PASSCODE).unwrap();
547        assert_eq!(p.as_u32(), 99_999_998);
548    }
549
550    #[test]
551    fn new_rejects_values_above_max_but_below_2_27() {
552        // Regression: values in 99_999_999..2^27 are 27-bit-representable but are
553        // NOT valid passcodes (spec §5.1.7.1). Before this guard `Passcode::new`
554        // accepted them, so `open_commissioning_window` could emit a manual code
555        // that every spec-compliant commissioner (chip-tool, Apple/Google Home)
556        // rejects. 102_950_749 is exactly the value the field-observed bad code
557        // `11007762830` decoded to.
558        for &v in &[100_000_000_u32, 102_950_749, (1 << 27) - 1] {
559            let err = Passcode::new(v).unwrap_err();
560            assert!(
561                matches!(err, Error::PasscodeOutOfRange(x) if x == v),
562                "expected PasscodeOutOfRange for {v}, got {err:?}"
563            );
564        }
565    }
566
567    #[test]
568    fn new_rejects_all_zeros() {
569        let err = Passcode::new(0).unwrap_err();
570        assert!(matches!(err, Error::PasscodeDisallowedTrivial(0)));
571    }
572
573    #[test]
574    fn new_rejects_all_ones() {
575        let err = Passcode::new(11_111_111).unwrap_err();
576        assert!(matches!(err, Error::PasscodeDisallowedTrivial(11_111_111)));
577    }
578
579    #[test]
580    fn new_rejects_counting_up() {
581        let err = Passcode::new(12_345_678).unwrap_err();
582        assert!(matches!(err, Error::PasscodeDisallowedTrivial(12_345_678)));
583    }
584
585    #[test]
586    fn new_rejects_counting_down() {
587        let err = Passcode::new(87_654_321).unwrap_err();
588        assert!(matches!(err, Error::PasscodeDisallowedTrivial(87_654_321)));
589    }
590
591    #[test]
592    fn new_rejects_all_disallowed() {
593        for &v in super::DISALLOWED_PASSCODES {
594            let err = Passcode::new(v).unwrap_err();
595            assert!(
596                matches!(err, Error::PasscodeDisallowedTrivial(x) if x == v),
597                "expected DisallowedTrivial for {v}, got {err:?}"
598            );
599        }
600    }
601}
602
603#[cfg(test)]
604#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
605mod commissioning_flow_tests {
606    use super::{CommissioningFlow, Error};
607
608    #[test]
609    fn from_u8_standard() {
610        assert_eq!(
611            CommissioningFlow::from_u8(0).unwrap(),
612            CommissioningFlow::Standard
613        );
614    }
615
616    #[test]
617    fn from_u8_user_intent() {
618        assert_eq!(
619            CommissioningFlow::from_u8(1).unwrap(),
620            CommissioningFlow::UserIntent
621        );
622    }
623
624    #[test]
625    fn from_u8_custom() {
626        assert_eq!(
627            CommissioningFlow::from_u8(2).unwrap(),
628            CommissioningFlow::Custom
629        );
630    }
631
632    #[test]
633    fn from_u8_reserved() {
634        let err = CommissioningFlow::from_u8(3).unwrap_err();
635        assert!(matches!(err, Error::CommissioningFlowReserved(3)));
636    }
637
638    #[test]
639    fn from_u8_out_of_range() {
640        // 4..255 are all invalid; the 2-bit field can only ever yield 0..=3
641        // when read from a real QR, but a programmatic caller could pass
642        // anything.
643        let err = CommissioningFlow::from_u8(99).unwrap_err();
644        assert!(matches!(err, Error::CommissioningFlowReserved(99)));
645    }
646
647    #[test]
648    fn as_u8_roundtrip() {
649        assert_eq!(CommissioningFlow::Standard.as_u8(), 0);
650        assert_eq!(CommissioningFlow::UserIntent.as_u8(), 1);
651        assert_eq!(CommissioningFlow::Custom.as_u8(), 2);
652    }
653}
654
655#[cfg(test)]
656#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
657mod discovery_capabilities_tests {
658    use super::DiscoveryCapabilities;
659
660    #[test]
661    fn empty_set() {
662        let d = DiscoveryCapabilities::empty();
663        assert_eq!(d.bits(), 0);
664        assert!(!d.contains(DiscoveryCapabilities::BLE));
665    }
666
667    #[test]
668    fn ble_only() {
669        let d = DiscoveryCapabilities::BLE;
670        assert_eq!(d.bits(), 0b0000_0010);
671        assert!(d.contains(DiscoveryCapabilities::BLE));
672        assert!(!d.contains(DiscoveryCapabilities::ON_NETWORK));
673    }
674
675    #[test]
676    fn on_network_only() {
677        let d = DiscoveryCapabilities::ON_NETWORK;
678        assert_eq!(d.bits(), 0b0000_0100);
679    }
680
681    #[test]
682    fn combined() {
683        let d = DiscoveryCapabilities::BLE | DiscoveryCapabilities::ON_NETWORK;
684        assert_eq!(d.bits(), 0b0000_0110);
685    }
686
687    #[test]
688    fn from_bits_preserves_reserved() {
689        // bits 3..7 are reserved; we preserve unknown bits on roundtrip
690        // rather than reject them.
691        let d = DiscoveryCapabilities::from_bits_retain(0b1100_0001);
692        assert_eq!(d.bits(), 0b1100_0001);
693        assert!(d.contains(DiscoveryCapabilities::SOFT_AP));
694    }
695}
696
697#[cfg(test)]
698#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
699mod setup_payload_tests {
700    use super::*;
701
702    /// Returns the spec's worked-example payload from Matter Core Spec §5.1.3.1.
703    /// VID 0xFFF1, PID 0x8000, discriminator 0xF00, passcode `20_202_021`,
704    /// flow Standard, discovery `ON_NETWORK` only.
705    pub(super) fn spec_example_payload() -> SetupPayload {
706        SetupPayload {
707            version: 0,
708            vendor_id: Some(0xFFF1),
709            product_id: Some(0x8000),
710            commissioning_flow: CommissioningFlow::Standard,
711            discovery_capabilities: DiscoveryCapabilities::ON_NETWORK,
712            discriminator: Discriminator::new(0xF00).unwrap(),
713            passcode: Passcode::new(20_202_021).unwrap(),
714        }
715    }
716
717    #[test]
718    fn spec_example_round_trips_through_struct() {
719        let p = spec_example_payload();
720        assert_eq!(p.vendor_id, Some(0xFFF1));
721        assert_eq!(p.product_id, Some(0x8000));
722        assert_eq!(p.discriminator.as_u16(), 0xF00);
723        assert_eq!(p.passcode.as_u32(), 20_202_021);
724        assert_eq!(p.commissioning_flow, CommissioningFlow::Standard);
725        assert!(p
726            .discovery_capabilities
727            .contains(DiscoveryCapabilities::ON_NETWORK));
728    }
729
730    #[test]
731    fn manual_only_payload_has_no_vid_pid() {
732        let p = SetupPayload {
733            version: 0,
734            vendor_id: None,
735            product_id: None,
736            commissioning_flow: CommissioningFlow::Standard,
737            discovery_capabilities: DiscoveryCapabilities::empty(),
738            discriminator: Discriminator::new(0xA00).unwrap(),
739            passcode: Passcode::new(20_202_021).unwrap(),
740        };
741        assert!(p.vendor_id.is_none());
742        assert!(p.product_id.is_none());
743    }
744}
745
746#[cfg(test)]
747#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
748mod qr_api_tests {
749    use super::*;
750    use crate::setup::setup_payload_tests::spec_example_payload;
751
752    /// The spec example must encode AND decode without errors. (Exact
753    /// byte parity against matter.js is verified by the integration test
754    /// `tests/setup_byte_parity.rs` once fixtures are captured in
755    /// Task 21.)
756    #[test]
757    fn spec_example_qr_encode_decode_roundtrip() {
758        let p = spec_example_payload();
759        let s = encode_qr(&p).unwrap();
760        assert!(s.starts_with("MT:"), "got {s:?}");
761        let back = parse_qr(&s).unwrap();
762        assert_eq!(back, p);
763    }
764
765    #[test]
766    fn parse_qr_rejects_missing_prefix() {
767        let err = parse_qr("Y.K9042C00KA0648G00").unwrap_err();
768        assert!(matches!(err, Error::MissingMtPrefix));
769    }
770
771    #[test]
772    fn parse_qr_rejects_trailing_bytes() {
773        // The spec-example payload encodes to 19 Base38 chars (3 full
774        // 5-char chunks plus a 4-char tail → 11 bytes). Appending 3 chars
775        // turns the tail into a 5-char chunk (3 bytes) plus a fresh
776        // 2-char chunk (1 byte), decoding to 13 bytes total — 2 bytes
777        // past the fixed block.
778        let p = spec_example_payload();
779        let mut s = encode_qr(&p).unwrap();
780        s.push_str("000");
781        let err = parse_qr(&s).unwrap_err();
782        assert!(
783            matches!(err, Error::QrTrailingBytes { extra: 2 }),
784            "got {err:?}"
785        );
786    }
787
788    #[test]
789    fn parse_qr_rejects_short_payload() {
790        let err = parse_qr("MT:00000").unwrap_err();
791        assert!(
792            matches!(err, Error::QrPayloadWrongLength { .. }),
793            "got {err:?}"
794        );
795    }
796}
797
798#[cfg(test)]
799#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
800mod manual_api_tests {
801    use super::*;
802
803    fn payload_11() -> SetupPayload {
804        SetupPayload {
805            version: 0,
806            vendor_id: None,
807            product_id: None,
808            commissioning_flow: CommissioningFlow::Standard,
809            discovery_capabilities: DiscoveryCapabilities::empty(),
810            discriminator: Discriminator::new(0x0F00).unwrap(),
811            passcode: Passcode::new(20_202_021).unwrap(),
812        }
813    }
814
815    #[test]
816    fn encode_manual_11_then_parse() {
817        let p = payload_11();
818        let s = encode_manual_code(&p);
819        assert_eq!(s.len(), 11);
820        let back = parse_manual_code(&s).unwrap();
821        assert_eq!(back, p);
822    }
823
824    #[test]
825    fn encode_manual_21_then_parse() {
826        let mut p = payload_11();
827        p.vendor_id = Some(0xFFF1);
828        p.product_id = Some(0x8000);
829        let s = encode_manual_code(&p);
830        assert_eq!(s.len(), 21);
831        let back = parse_manual_code(&s).unwrap();
832        assert_eq!(back, p);
833    }
834
835    #[test]
836    fn parse_manual_rejects_wrong_length() {
837        let err = parse_manual_code("12345").unwrap_err();
838        assert!(matches!(err, Error::ManualCodeWrongLength(5)));
839    }
840
841    #[test]
842    fn parse_manual_rejects_non_digit() {
843        let err = parse_manual_code("1234567890A").unwrap_err();
844        assert!(matches!(err, Error::ManualCodeNonDigit('A', 10)));
845    }
846}