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}