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    /// A Base38 chunk decoded to a value too large for the number of bytes
248    /// it produces (e.g. a 2-char chunk `> 0xFF`), so the encoding is not
249    /// injective. chip rejects these (`Base38Decode.cpp:152`); silently
250    /// truncating them would map distinct QR strings to the same bytes.
251    #[error("Base38 chunk at position {position} decodes to an out-of-range value")]
252    Base38ChunkOutOfRange {
253        /// Byte offset of the offending chunk within the Base38 string.
254        position: usize,
255    },
256
257    /// The Base38-decoded payload is the wrong size for a Matter QR.
258    #[error("QR payload is the wrong length: {got} bytes, expected exactly {need}")]
259    QrPayloadWrongLength {
260        /// Number of bytes actually decoded.
261        got: usize,
262        /// Number of bytes the spec requires (currently always 11).
263        need: usize,
264    },
265
266    /// The Base38-decoded payload is longer than the fixed 11-byte block;
267    /// M6.1 does not support the optional vendor TLV blob.
268    #[error("QR payload has {extra} byte(s) after the fixed 11-byte block; vendor TLV blobs are not supported in this release")]
269    QrTrailingBytes {
270        /// Number of bytes past the fixed block.
271        extra: usize,
272    },
273
274    /// Manual code must be exactly 11 or 21 digits.
275    #[error("manual code must be 11 or 21 digits; got {0}")]
276    ManualCodeWrongLength(usize),
277
278    /// Manual code contains a non-digit character.
279    #[error("manual code contains non-digit `{0}` at position {1}")]
280    ManualCodeNonDigit(char, usize),
281
282    /// The Verhoeff check digit at the end of the manual code did not
283    /// validate against the preceding digits.
284    #[error("manual code Verhoeff check digit failed")]
285    ManualCodeBadChecksum,
286
287    /// A manual-code VID or PID field decoded to a 5-digit decimal value
288    /// that does not fit in the 16-bit field it maps to (i.e. `> 65535`).
289    ///
290    /// A manual pairing code carries VID and PID as 5-digit decimals, whose
291    /// maximum (`99999`) exceeds `u16::MAX` (`65535`). Rather than silently
292    /// truncating an out-of-range value to `u16`, the parser rejects it.
293    #[error("manual code {field} value {value} exceeds the 16-bit field width")]
294    FieldOutOfRange {
295        /// Which field overflowed: `"vendor_id"` or `"product_id"`.
296        field: &'static str,
297        /// The out-of-range decimal value parsed from the code.
298        value: u32,
299    },
300
301    /// The 12-bit Long Discriminator field is out of range.
302    #[error("discriminator {0} exceeds the 12-bit field width")]
303    DiscriminatorOutOfRange(u16),
304
305    /// The 27-bit Passcode field is out of range.
306    #[error("passcode {0} exceeds the 27-bit field width")]
307    PasscodeOutOfRange(u32),
308
309    /// The passcode value is on the Matter spec's disallowed-trivial list
310    /// (Matter Core Spec §5.1.7.1).
311    #[error("passcode {0} is in the disallowed-trivial list (spec §5.1.7.1)")]
312    PasscodeDisallowedTrivial(u32),
313
314    /// The 2-bit Commissioning Flow field decoded to a reserved value.
315    #[error("commissioning flow value {0} is reserved")]
316    CommissioningFlowReserved(u8),
317
318    /// `encode_qr` was called on a `SetupPayload` whose VID or PID is
319    /// `None` (the manual-code-only case).
320    #[error("QR-form payload requires both vendor_id and product_id to be present")]
321    QrRequiresVidPid,
322
323    /// The Matter spec defines a `Custom` commissioning flow whose
324    /// semantics are vendor-defined and not supported by matter-rust.
325    #[error("commissioning flow `Custom` requires vendor-specific QR fields not supported by matter-rust")]
326    CustomFlowUnsupported,
327}
328
329/// Convenience alias for `Result<T, Error>` inside the setup module.
330pub type Result<T> = core::result::Result<T, Error>;
331
332const QR_PREFIX: &str = "MT:";
333
334/// Encode a `SetupPayload` as a Matter QR string (Matter Core Spec §5.1.3.1).
335///
336/// The returned string always begins with `MT:` followed by Matter Base38.
337///
338/// # Errors
339/// Returns [`Error::QrRequiresVidPid`] if either VID or PID is `None`.
340/// Returns [`Error::CustomFlowUnsupported`] for `CommissioningFlow::Custom`.
341///
342/// # Examples
343///
344/// ```
345/// use matter_commissioning::setup::{
346///     encode_qr, parse_qr,
347///     CommissioningFlow, Discriminator, DiscoveryCapabilities,
348///     Passcode, SetupPayload,
349/// };
350/// let payload = SetupPayload {
351///     version: 0,
352///     vendor_id: Some(0xFFF1),
353///     product_id: Some(0x8000),
354///     commissioning_flow: CommissioningFlow::Standard,
355///     discovery_capabilities: DiscoveryCapabilities::ON_NETWORK,
356///     discriminator: Discriminator::new(0xF00).unwrap(),
357///     passcode: Passcode::new(20_202_021).unwrap(),
358/// };
359/// let qr = encode_qr(&payload).unwrap();
360/// assert!(qr.starts_with("MT:"));
361/// assert_eq!(parse_qr(&qr).unwrap(), payload);
362/// ```
363pub fn encode_qr(payload: &SetupPayload) -> Result<String> {
364    let bytes = qr_packer::pack(payload)?;
365    Ok(format!("{QR_PREFIX}{}", base38::encode(&bytes)))
366}
367
368/// Parse a Matter QR string into a `SetupPayload`.
369///
370/// # Errors
371/// Returns [`Error::MissingMtPrefix`] if the string does not begin with
372/// `MT:`.
373/// Returns [`Error::InvalidBase38Char`] for any character outside Matter's
374/// Base38 alphabet.
375/// Returns [`Error::QrPayloadWrongLength`] or [`Error::QrTrailingBytes`]
376/// for payload-length problems.
377/// Returns per-field range errors (`DiscriminatorOutOfRange`,
378/// `PasscodeOutOfRange`, `PasscodeDisallowedTrivial`,
379/// `CommissioningFlowReserved`) raised by the QR bit unpacker.
380///
381/// # Examples
382///
383/// ```
384/// use matter_commissioning::setup::parse_qr;
385/// // Captured from matter.js for the Matter Core Spec §5.1.3.1 worked
386/// // example (VID 0xFFF1, PID 0x8000, discriminator 0xF00, passcode
387/// // 20_202_021). Source:
388/// // test-vectors/commissioning/setup/qr-spec-example.json
389/// let payload = parse_qr("MT:Y.K90AFN00KA0648G00").unwrap();
390/// assert_eq!(payload.vendor_id, Some(0xFFF1));
391/// assert_eq!(payload.product_id, Some(0x8000));
392/// assert_eq!(payload.passcode.as_u32(), 20_202_021);
393/// ```
394pub fn parse_qr(s: &str) -> Result<SetupPayload> {
395    let payload = s.strip_prefix(QR_PREFIX).ok_or(Error::MissingMtPrefix)?;
396    let bytes = base38::decode(payload)?;
397    let need = qr_packer::FIXED_BYTE_LEN;
398    if bytes.len() < need {
399        return Err(Error::QrPayloadWrongLength {
400            got: bytes.len(),
401            need,
402        });
403    }
404    if bytes.len() > need {
405        return Err(Error::QrTrailingBytes {
406            extra: bytes.len() - need,
407        });
408    }
409    let mut fixed = [0u8; qr_packer::FIXED_BYTE_LEN];
410    fixed.copy_from_slice(&bytes[..need]);
411    qr_packer::unpack(&fixed)
412}
413
414/// Encode a `SetupPayload` as a manual pairing code (Matter Core Spec §5.1.4).
415///
416/// Emits the 21-digit form if `vendor_id` and `product_id` are both
417/// `Some`, otherwise the 11-digit form. The final digit is always the
418/// Verhoeff check digit.
419///
420/// # Examples
421///
422/// ```
423/// use matter_commissioning::setup::{
424///     encode_manual_code, parse_manual_code,
425///     CommissioningFlow, Discriminator, DiscoveryCapabilities,
426///     Passcode, SetupPayload,
427/// };
428/// let payload = SetupPayload {
429///     version: 0,
430///     vendor_id: None,
431///     product_id: None,
432///     commissioning_flow: CommissioningFlow::Standard,
433///     discovery_capabilities: DiscoveryCapabilities::empty(),
434///     discriminator: Discriminator::new(0xF00).unwrap(),
435///     passcode: Passcode::new(20_202_021).unwrap(),
436/// };
437/// let code = encode_manual_code(&payload);
438/// assert_eq!(code.len(), 11);
439/// assert_eq!(parse_manual_code(&code).unwrap(), payload);
440/// ```
441pub fn encode_manual_code(payload: &SetupPayload) -> String {
442    manual_packer::pack(payload)
443}
444
445/// Parse a Matter manual pairing code (11 or 21 digits).
446///
447/// # Errors
448/// Returns [`Error::ManualCodeWrongLength`], [`Error::ManualCodeNonDigit`],
449/// [`Error::ManualCodeBadChecksum`], or any per-field range error.
450pub fn parse_manual_code(s: &str) -> Result<SetupPayload> {
451    manual_packer::unpack(s)
452}
453
454#[cfg(test)]
455#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
456mod error_tests {
457    use super::Error;
458
459    #[test]
460    fn display_missing_mt_prefix() {
461        assert_eq!(
462            Error::MissingMtPrefix.to_string(),
463            "QR string is missing the `MT:` prefix"
464        );
465    }
466
467    #[test]
468    fn display_invalid_base38_char() {
469        assert_eq!(
470            Error::InvalidBase38Char('?', 7).to_string(),
471            "invalid Base38 character `?` at position 7"
472        );
473    }
474
475    #[test]
476    fn display_qr_trailing_bytes() {
477        assert_eq!(
478            Error::QrTrailingBytes { extra: 3 }.to_string(),
479            "QR payload has 3 byte(s) after the fixed 11-byte block; vendor TLV blobs are not supported in this release"
480        );
481    }
482
483    #[test]
484    fn display_manual_bad_checksum() {
485        assert_eq!(
486            Error::ManualCodeBadChecksum.to_string(),
487            "manual code Verhoeff check digit failed"
488        );
489    }
490}
491
492#[cfg(test)]
493#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
494mod discriminator_tests {
495    use super::{Discriminator, Error};
496
497    #[test]
498    fn new_accepts_zero() {
499        let d = Discriminator::new(0).unwrap();
500        assert_eq!(d.as_u16(), 0);
501        assert_eq!(d.short(), 0);
502    }
503
504    #[test]
505    fn new_accepts_max_12_bit() {
506        let d = Discriminator::new(0x0FFF).unwrap();
507        assert_eq!(d.as_u16(), 0x0FFF);
508        assert_eq!(d.short(), 0x0F);
509    }
510
511    #[test]
512    fn new_rejects_13_bit() {
513        let err = Discriminator::new(0x1000).unwrap_err();
514        assert!(matches!(err, Error::DiscriminatorOutOfRange(0x1000)));
515    }
516
517    #[test]
518    fn short_is_upper_4_bits() {
519        // 0xABC = bits 10101011 1100; upper 4 bits = 0xA
520        let d = Discriminator::new(0x0ABC).unwrap();
521        assert_eq!(d.short(), 0xA);
522    }
523}
524
525#[cfg(test)]
526#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
527mod passcode_tests {
528    use super::{Error, Passcode};
529
530    #[test]
531    fn new_accepts_normal_value() {
532        // 20202021 is the standard Matter test passcode. The spec excludes
533        // a handful of trivial all-same-digit and counting-sequence
534        // values, but 20202021 is allowed.
535        let p = Passcode::new(20_202_021).unwrap();
536        assert_eq!(p.as_u32(), 20_202_021);
537    }
538
539    #[test]
540    fn new_rejects_28_bit_value() {
541        let too_large = 1u32 << 27;
542        let err = Passcode::new(too_large).unwrap_err();
543        assert!(matches!(err, Error::PasscodeOutOfRange(v) if v == too_large));
544    }
545
546    #[test]
547    fn new_accepts_high_valid_value() {
548        // A high, non-trivial, in-range passcode (well under MAX_PASSCODE).
549        let p = Passcode::new(99_000_001).unwrap();
550        assert_eq!(p.as_u32(), 99_000_001);
551    }
552
553    #[test]
554    fn new_accepts_max_passcode() {
555        // The spec maximum (§5.1.7.1) is valid.
556        let p = Passcode::new(super::MAX_PASSCODE).unwrap();
557        assert_eq!(p.as_u32(), 99_999_998);
558    }
559
560    #[test]
561    fn new_rejects_values_above_max_but_below_2_27() {
562        // Regression: values in 99_999_999..2^27 are 27-bit-representable but are
563        // NOT valid passcodes (spec §5.1.7.1). Before this guard `Passcode::new`
564        // accepted them, so `open_commissioning_window` could emit a manual code
565        // that every spec-compliant commissioner (chip-tool, Apple/Google Home)
566        // rejects. 102_950_749 is exactly the value the field-observed bad code
567        // `11007762830` decoded to.
568        for &v in &[100_000_000_u32, 102_950_749, (1 << 27) - 1] {
569            let err = Passcode::new(v).unwrap_err();
570            assert!(
571                matches!(err, Error::PasscodeOutOfRange(x) if x == v),
572                "expected PasscodeOutOfRange for {v}, got {err:?}"
573            );
574        }
575    }
576
577    #[test]
578    fn new_rejects_all_zeros() {
579        let err = Passcode::new(0).unwrap_err();
580        assert!(matches!(err, Error::PasscodeDisallowedTrivial(0)));
581    }
582
583    #[test]
584    fn new_rejects_all_ones() {
585        let err = Passcode::new(11_111_111).unwrap_err();
586        assert!(matches!(err, Error::PasscodeDisallowedTrivial(11_111_111)));
587    }
588
589    #[test]
590    fn new_rejects_counting_up() {
591        let err = Passcode::new(12_345_678).unwrap_err();
592        assert!(matches!(err, Error::PasscodeDisallowedTrivial(12_345_678)));
593    }
594
595    #[test]
596    fn new_rejects_counting_down() {
597        let err = Passcode::new(87_654_321).unwrap_err();
598        assert!(matches!(err, Error::PasscodeDisallowedTrivial(87_654_321)));
599    }
600
601    #[test]
602    fn new_rejects_all_disallowed() {
603        for &v in super::DISALLOWED_PASSCODES {
604            let err = Passcode::new(v).unwrap_err();
605            assert!(
606                matches!(err, Error::PasscodeDisallowedTrivial(x) if x == v),
607                "expected DisallowedTrivial for {v}, got {err:?}"
608            );
609        }
610    }
611}
612
613#[cfg(test)]
614#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
615mod commissioning_flow_tests {
616    use super::{CommissioningFlow, Error};
617
618    #[test]
619    fn from_u8_standard() {
620        assert_eq!(
621            CommissioningFlow::from_u8(0).unwrap(),
622            CommissioningFlow::Standard
623        );
624    }
625
626    #[test]
627    fn from_u8_user_intent() {
628        assert_eq!(
629            CommissioningFlow::from_u8(1).unwrap(),
630            CommissioningFlow::UserIntent
631        );
632    }
633
634    #[test]
635    fn from_u8_custom() {
636        assert_eq!(
637            CommissioningFlow::from_u8(2).unwrap(),
638            CommissioningFlow::Custom
639        );
640    }
641
642    #[test]
643    fn from_u8_reserved() {
644        let err = CommissioningFlow::from_u8(3).unwrap_err();
645        assert!(matches!(err, Error::CommissioningFlowReserved(3)));
646    }
647
648    #[test]
649    fn from_u8_out_of_range() {
650        // 4..255 are all invalid; the 2-bit field can only ever yield 0..=3
651        // when read from a real QR, but a programmatic caller could pass
652        // anything.
653        let err = CommissioningFlow::from_u8(99).unwrap_err();
654        assert!(matches!(err, Error::CommissioningFlowReserved(99)));
655    }
656
657    #[test]
658    fn as_u8_roundtrip() {
659        assert_eq!(CommissioningFlow::Standard.as_u8(), 0);
660        assert_eq!(CommissioningFlow::UserIntent.as_u8(), 1);
661        assert_eq!(CommissioningFlow::Custom.as_u8(), 2);
662    }
663}
664
665#[cfg(test)]
666#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
667mod discovery_capabilities_tests {
668    use super::DiscoveryCapabilities;
669
670    #[test]
671    fn empty_set() {
672        let d = DiscoveryCapabilities::empty();
673        assert_eq!(d.bits(), 0);
674        assert!(!d.contains(DiscoveryCapabilities::BLE));
675    }
676
677    #[test]
678    fn ble_only() {
679        let d = DiscoveryCapabilities::BLE;
680        assert_eq!(d.bits(), 0b0000_0010);
681        assert!(d.contains(DiscoveryCapabilities::BLE));
682        assert!(!d.contains(DiscoveryCapabilities::ON_NETWORK));
683    }
684
685    #[test]
686    fn on_network_only() {
687        let d = DiscoveryCapabilities::ON_NETWORK;
688        assert_eq!(d.bits(), 0b0000_0100);
689    }
690
691    #[test]
692    fn combined() {
693        let d = DiscoveryCapabilities::BLE | DiscoveryCapabilities::ON_NETWORK;
694        assert_eq!(d.bits(), 0b0000_0110);
695    }
696
697    #[test]
698    fn from_bits_preserves_reserved() {
699        // bits 3..7 are reserved; we preserve unknown bits on roundtrip
700        // rather than reject them.
701        let d = DiscoveryCapabilities::from_bits_retain(0b1100_0001);
702        assert_eq!(d.bits(), 0b1100_0001);
703        assert!(d.contains(DiscoveryCapabilities::SOFT_AP));
704    }
705}
706
707#[cfg(test)]
708#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
709mod setup_payload_tests {
710    use super::*;
711
712    /// Returns the spec's worked-example payload from Matter Core Spec §5.1.3.1.
713    /// VID 0xFFF1, PID 0x8000, discriminator 0xF00, passcode `20_202_021`,
714    /// flow Standard, discovery `ON_NETWORK` only.
715    pub(super) fn spec_example_payload() -> SetupPayload {
716        SetupPayload {
717            version: 0,
718            vendor_id: Some(0xFFF1),
719            product_id: Some(0x8000),
720            commissioning_flow: CommissioningFlow::Standard,
721            discovery_capabilities: DiscoveryCapabilities::ON_NETWORK,
722            discriminator: Discriminator::new(0xF00).unwrap(),
723            passcode: Passcode::new(20_202_021).unwrap(),
724        }
725    }
726
727    #[test]
728    fn spec_example_round_trips_through_struct() {
729        let p = spec_example_payload();
730        assert_eq!(p.vendor_id, Some(0xFFF1));
731        assert_eq!(p.product_id, Some(0x8000));
732        assert_eq!(p.discriminator.as_u16(), 0xF00);
733        assert_eq!(p.passcode.as_u32(), 20_202_021);
734        assert_eq!(p.commissioning_flow, CommissioningFlow::Standard);
735        assert!(p
736            .discovery_capabilities
737            .contains(DiscoveryCapabilities::ON_NETWORK));
738    }
739
740    #[test]
741    fn manual_only_payload_has_no_vid_pid() {
742        let p = SetupPayload {
743            version: 0,
744            vendor_id: None,
745            product_id: None,
746            commissioning_flow: CommissioningFlow::Standard,
747            discovery_capabilities: DiscoveryCapabilities::empty(),
748            discriminator: Discriminator::new(0xA00).unwrap(),
749            passcode: Passcode::new(20_202_021).unwrap(),
750        };
751        assert!(p.vendor_id.is_none());
752        assert!(p.product_id.is_none());
753    }
754}
755
756#[cfg(test)]
757#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
758mod qr_api_tests {
759    use super::*;
760    use crate::setup::setup_payload_tests::spec_example_payload;
761
762    /// The spec example must encode AND decode without errors. (Exact
763    /// byte parity against matter.js is verified by the integration test
764    /// `tests/setup_byte_parity.rs` once fixtures are captured in
765    /// Task 21.)
766    #[test]
767    fn spec_example_qr_encode_decode_roundtrip() {
768        let p = spec_example_payload();
769        let s = encode_qr(&p).unwrap();
770        assert!(s.starts_with("MT:"), "got {s:?}");
771        let back = parse_qr(&s).unwrap();
772        assert_eq!(back, p);
773    }
774
775    #[test]
776    fn parse_qr_rejects_missing_prefix() {
777        let err = parse_qr("Y.K9042C00KA0648G00").unwrap_err();
778        assert!(matches!(err, Error::MissingMtPrefix));
779    }
780
781    #[test]
782    fn parse_qr_rejects_trailing_bytes() {
783        // The spec-example payload encodes to 19 Base38 chars (3 full
784        // 5-char chunks plus a 4-char tail → 11 bytes). Appending 3 chars
785        // turns the tail into a 5-char chunk (3 bytes) plus a fresh
786        // 2-char chunk (1 byte), decoding to 13 bytes total — 2 bytes
787        // past the fixed block.
788        let p = spec_example_payload();
789        let mut s = encode_qr(&p).unwrap();
790        s.push_str("000");
791        let err = parse_qr(&s).unwrap_err();
792        assert!(
793            matches!(err, Error::QrTrailingBytes { extra: 2 }),
794            "got {err:?}"
795        );
796    }
797
798    #[test]
799    fn parse_qr_rejects_short_payload() {
800        let err = parse_qr("MT:00000").unwrap_err();
801        assert!(
802            matches!(err, Error::QrPayloadWrongLength { .. }),
803            "got {err:?}"
804        );
805    }
806}
807
808#[cfg(test)]
809#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
810mod manual_api_tests {
811    use super::*;
812
813    fn payload_11() -> SetupPayload {
814        SetupPayload {
815            version: 0,
816            vendor_id: None,
817            product_id: None,
818            commissioning_flow: CommissioningFlow::Standard,
819            discovery_capabilities: DiscoveryCapabilities::empty(),
820            discriminator: Discriminator::new(0x0F00).unwrap(),
821            passcode: Passcode::new(20_202_021).unwrap(),
822        }
823    }
824
825    #[test]
826    fn encode_manual_11_then_parse() {
827        let p = payload_11();
828        let s = encode_manual_code(&p);
829        assert_eq!(s.len(), 11);
830        let back = parse_manual_code(&s).unwrap();
831        assert_eq!(back, p);
832    }
833
834    #[test]
835    fn encode_manual_21_then_parse() {
836        let mut p = payload_11();
837        p.vendor_id = Some(0xFFF1);
838        p.product_id = Some(0x8000);
839        let s = encode_manual_code(&p);
840        assert_eq!(s.len(), 21);
841        let back = parse_manual_code(&s).unwrap();
842        assert_eq!(back, p);
843    }
844
845    #[test]
846    fn parse_manual_rejects_wrong_length() {
847        let err = parse_manual_code("12345").unwrap_err();
848        assert!(matches!(err, Error::ManualCodeWrongLength(5)));
849    }
850
851    #[test]
852    fn parse_manual_rejects_non_digit() {
853        let err = parse_manual_code("1234567890A").unwrap_err();
854        assert!(matches!(err, Error::ManualCodeNonDigit('A', 10)));
855    }
856}