matc/
onboarding.rs

1use anyhow::{bail, Context, Result};
2
3/// Bitfield flags for discovery capabilities returned from QR code.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct DiscoveryCapabilities(pub u8);
6
7impl DiscoveryCapabilities {
8    pub fn has_soft_ap(self) -> bool { self.0 & 0x01 != 0 }
9    pub fn has_ble(self) -> bool { self.0 & 0x02 != 0 }
10    pub fn has_on_network(self) -> bool { self.0 & 0x04 != 0 }
11}
12
13#[derive(Debug)]
14pub struct OnboardingInfo {
15    pub discriminator: u16,
16    pub passcode: u32,
17    /// True when decoded from a manual pairing code (only top 4 bits of discriminator are valid).
18    pub is_short_discriminator: bool,
19    /// Present only when decoded from a QR code payload.
20    pub vendor_id: Option<u16>,
21    /// Present only when decoded from a QR code payload.
22    pub product_id: Option<u16>,
23    /// Present only when decoded from a QR code payload.
24    pub discovery_capabilities: Option<DiscoveryCapabilities>,
25}
26
27/// Base38 alphabet used in Matter QR codes (no space; ends with `-` and `.`).
28const BASE38_CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-.";
29
30fn base38_val(ch: char) -> Result<u32> {
31    BASE38_CHARS
32        .iter()
33        .position(|&c| c == ch as u8)
34        .map(|p| p as u32)
35        .ok_or_else(|| anyhow::anyhow!("invalid Base38 character '{}'", ch))
36}
37
38/// Decode a Base38 string into bytes.
39///
40/// Matter uses groups of 3 chars for every 2 bytes (little-endian u16), and 2
41/// chars for a trailing single byte.
42fn base38_decode(s: &str) -> Result<Vec<u8>> {
43    let chars: Vec<char> = s.chars().collect();
44    let mut out = Vec::with_capacity(chars.len() * 2 / 3 + 1);
45    let mut i = 0;
46    while i + 2 < chars.len() {
47        let v = base38_val(chars[i])? * 38 * 38
48            + base38_val(chars[i + 1])? * 38
49            + base38_val(chars[i + 2])?;
50        out.push((v & 0xff) as u8);
51        out.push(((v >> 8) & 0xff) as u8);
52        i += 3;
53    }
54    if i + 1 < chars.len() {
55        let v = base38_val(chars[i])? * 38 + base38_val(chars[i + 1])?;
56        out.push((v & 0xff) as u8);
57        i += 2;
58    }
59    if i < chars.len() {
60        bail!("unexpected Base38 input length");
61    }
62    Ok(out)
63}
64
65/// Decode a Matter QR code payload (the `MT:...` string, with or without the `MT:` prefix).
66///
67/// The payload is a Base38-encoded 88-bit integer with the following layout (LSB first):
68/// * bits  0- 2 : version (3 bits)
69/// * bits  3-18 : vendor ID (16 bits)
70/// * bits 19-34 : product ID (16 bits)
71/// * bits 35-36 : custom flow (2 bits)
72/// * bits 37-43 : discovery capabilities (7 bits, we use low 3)
73/// * bits 44-55 : discriminator (12 bits)
74/// * bits 56-82 : passcode (27 bits)
75/// * bits 83-87 : padding (5 bits, must be zero)
76pub fn decode_qr_payload(qr: &str) -> Result<OnboardingInfo> {
77    let payload = qr.trim().strip_prefix("MT:").unwrap_or(qr.trim());
78    let bytes = base38_decode(payload).context("base38 decode")?;
79    if bytes.len() < 11 {
80        bail!("QR payload too short: {} bytes", bytes.len());
81    }
82
83    // Pack into a 88-bit little-endian integer (11 bytes)
84    let mut bits: u128 = 0;
85    for (i, &b) in bytes.iter().take(11).enumerate() {
86        bits |= (b as u128) << (i * 8);
87    }
88
89    let _version          = (bits & 0x7) as u8;
90    let vendor_id         = ((bits >> 3) & 0xffff) as u16;
91    let product_id        = ((bits >> 19) & 0xffff) as u16;
92    let _custom_flow      = ((bits >> 35) & 0x3) as u8;
93    let disc_caps         = ((bits >> 37) & 0x7f) as u8;
94    let discriminator     = ((bits >> 44) & 0xfff) as u16;
95    let passcode          = ((bits >> 56) & 0x7ff_ffff) as u32;
96
97    Ok(OnboardingInfo {
98        discriminator,
99        passcode,
100        is_short_discriminator: false,
101        vendor_id: Some(vendor_id),
102        product_id: Some(product_id),
103        discovery_capabilities: Some(DiscoveryCapabilities(disc_caps)),
104    })
105}
106
107pub fn decode_manual_pairing_code(code: &str) -> Result<OnboardingInfo> {
108    let norm = code.replace("-", "");
109    let first_grp = &norm[0..1];
110    let second_grp = &norm[1..6];
111    let third_grp = &norm[6..10];
112    let first = first_grp.parse::<u32>()?;
113    let second = second_grp.parse::<u32>()?;
114    let third = third_grp.parse::<u32>()?;
115    let passcode = second & 0x3fff | (third << 14);
116    let discriminator = (((first & 3) << 10) | (second >> 6) & 0x300) as u16;
117    Ok(OnboardingInfo {
118        discriminator,
119        passcode,
120        is_short_discriminator: true,
121        vendor_id: None,
122        product_id: None,
123        discovery_capabilities: None,
124    })
125}
126
127static D: [[u8; 10]; 10] = [
128    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
129    [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
130    [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
131    [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
132    [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
133    [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
134    [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
135    [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
136    [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
137    [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
138];
139
140/// The permutation table.
141static P: [[u8; 10]; 8] = [
142    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
143    [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
144    [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
145    [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
146    [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
147    [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
148    [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
149    [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
150];
151
152/// Inverse table for Verhoeff's dihedral group D5.
153static INV: [u8; 10] = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9];
154
155fn verhoeff_checksum(num: &str) -> u8 {
156    let mut c: usize = 0;
157    for (i, ch) in num.chars().rev().enumerate() {
158        let digit = ch.to_digit(10).unwrap() as usize;
159        c = D[c][P[(i + 1) % 8][digit] as usize] as usize;
160    }
161    INV[c]
162}
163
164pub fn encode_manual_pairing_code(info: &OnboardingInfo) -> String {
165    let first = (info.discriminator as u32 >> 10) as u8;
166    let second = ((info.discriminator & 0x300) << 6) as u32 | (info.passcode & 0x3fff);
167    let third = info.passcode >> 14;
168    let digits = format!("{:01}{:05}{:04}", first, second, third);
169    let check = verhoeff_checksum(&digits);
170    let num = format!("{}{:05}{:04}{}", first, second, third, check);
171    // Insert dashes after each 4th digit
172    let mut formatted = String::new();
173    for (i, ch) in num.chars().enumerate() {
174        if i > 0 && i % 4 == 0 {
175            formatted.push('-');
176        }
177        formatted.push(ch);
178    }
179    formatted
180}
181
182#[cfg(test)]
183mod tests {
184    use crate::onboarding::OnboardingInfo;
185
186    use super::decode_manual_pairing_code;
187    use super::encode_manual_pairing_code;
188
189    #[test]
190    pub fn test_1() {
191        let res = decode_manual_pairing_code("2585-103-3238").unwrap();
192        assert_eq!(res.discriminator, 2816);
193        assert_eq!(res.passcode, 54453390);
194        let encoded = encode_manual_pairing_code(&res);
195        assert_eq!(encoded.replace("-", ""), "25851033238");
196    }
197
198    #[test]
199    pub fn test_2() {
200        let res = decode_manual_pairing_code("34970112332").unwrap();
201        assert_eq!(res.discriminator, 3840);
202        assert_eq!(res.passcode, 20202021);
203        let encoded = encode_manual_pairing_code(&res);
204        assert_eq!(encoded.replace("-", ""), "34970112332");
205    }
206    #[test]
207    pub fn test_3() {
208        let oi = OnboardingInfo {
209            discriminator: 3840,
210            passcode: 123456,
211            is_short_discriminator: false,
212            vendor_id: None,
213            product_id: None,
214            discovery_capabilities: None,
215        };
216        let encoded = encode_manual_pairing_code(&oi);
217        println!("Encoded: {}", encoded);
218    }
219
220    #[test]
221    pub fn test_qr_decode() {
222        let info = super::decode_qr_payload("MT:00000003E6RM9A201").unwrap();
223        assert_eq!(info.passcode, 20202021, "passcode mismatch");
224        assert_eq!(info.discriminator, 3840, "discriminator mismatch");
225        assert_eq!(info.vendor_id, Some(0));
226        assert_eq!(info.product_id, Some(0));
227        let dc = info.discovery_capabilities.unwrap();
228        assert!(dc.has_on_network());
229    }
230}