use anyhow::{bail, Context, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DiscoveryCapabilities(pub u8);
impl DiscoveryCapabilities {
pub fn has_soft_ap(self) -> bool { self.0 & 0x01 != 0 }
pub fn has_ble(self) -> bool { self.0 & 0x02 != 0 }
pub fn has_on_network(self) -> bool { self.0 & 0x04 != 0 }
}
#[derive(Debug)]
pub struct OnboardingInfo {
pub discriminator: u16,
pub passcode: u32,
pub is_short_discriminator: bool,
pub vendor_id: Option<u16>,
pub product_id: Option<u16>,
pub discovery_capabilities: Option<DiscoveryCapabilities>,
}
const BASE38_CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-.";
fn base38_val(ch: char) -> Result<u32> {
BASE38_CHARS
.iter()
.position(|&c| c == ch as u8)
.map(|p| p as u32)
.ok_or_else(|| anyhow::anyhow!("invalid Base38 character '{}'", ch))
}
fn base38_decode(s: &str) -> Result<Vec<u8>> {
const CHARS_PER_CHUNK: usize = 5;
const BYTES_PER_CHUNK: usize = 3;
let chars: Vec<char> = s.chars().collect();
let n = chars.len();
let trailing = n % CHARS_PER_CHUNK;
let trailing_bytes = match trailing {
0 => 0,
2 => 1,
4 => 2,
_ => bail!(
"invalid Base38 length {}: trailing chars must be 0, 2, or 4 (mod 5)",
n
),
};
let full_chunks = n / CHARS_PER_CHUNK;
let mut out = Vec::with_capacity(full_chunks * BYTES_PER_CHUNK + trailing_bytes);
let decode_group = |chars_slice: &[char]| -> Result<u32> {
let mut v: u32 = 0;
for c in chars_slice.iter().rev() {
v = v * 38 + base38_val(*c)?;
}
Ok(v)
};
for chunk in 0..full_chunks {
let i = chunk * CHARS_PER_CHUNK;
let v = decode_group(&chars[i..i + CHARS_PER_CHUNK])?;
out.push((v & 0xff) as u8);
out.push(((v >> 8) & 0xff) as u8);
out.push(((v >> 16) & 0xff) as u8);
}
if trailing > 0 {
let i = full_chunks * CHARS_PER_CHUNK;
let v = decode_group(&chars[i..i + trailing])?;
for b in 0..trailing_bytes {
out.push(((v >> (b * 8)) & 0xff) as u8);
}
}
Ok(out)
}
pub fn decode_qr_payload(qr: &str) -> Result<OnboardingInfo> {
let payload = qr.trim().strip_prefix("MT:").unwrap_or(qr.trim());
let bytes = base38_decode(payload).context("base38 decode")?;
if bytes.len() < 11 {
bail!("QR payload too short: {} bytes", bytes.len());
}
let mut bits: u128 = 0;
for (i, &b) in bytes.iter().take(11).enumerate() {
bits |= (b as u128) << (i * 8);
}
let _version = (bits & 0x7) as u8;
let vendor_id = ((bits >> 3) & 0xffff) as u16;
let product_id = ((bits >> 19) & 0xffff) as u16;
let _custom_flow = ((bits >> 35) & 0x3) as u8;
let disc_caps = ((bits >> 37) & 0xff) as u8;
let discriminator = ((bits >> 45) & 0xfff) as u16;
let passcode = ((bits >> 57) & 0x7ff_ffff) as u32;
Ok(OnboardingInfo {
discriminator,
passcode,
is_short_discriminator: false,
vendor_id: Some(vendor_id),
product_id: Some(product_id),
discovery_capabilities: Some(DiscoveryCapabilities(disc_caps)),
})
}
pub fn decode_manual_pairing_code(code: &str) -> Result<OnboardingInfo> {
let norm = code.replace("-", "");
let first_grp = &norm[0..1];
let second_grp = &norm[1..6];
let third_grp = &norm[6..10];
let first = first_grp.parse::<u32>()?;
let second = second_grp.parse::<u32>()?;
let third = third_grp.parse::<u32>()?;
let passcode = second & 0x3fff | (third << 14);
let discriminator = (((first & 3) << 10) | (second >> 6) & 0x300) as u16;
Ok(OnboardingInfo {
discriminator,
passcode,
is_short_discriminator: true,
vendor_id: None,
product_id: None,
discovery_capabilities: None,
})
}
static D: [[u8; 10]; 10] = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
[2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
[4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
[5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
[7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
[8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
];
static P: [[u8; 10]; 8] = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
[5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
[9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
[4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
[7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
];
static INV: [u8; 10] = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9];
fn verhoeff_checksum(num: &str) -> u8 {
let mut c: usize = 0;
for (i, ch) in num.chars().rev().enumerate() {
let digit = ch.to_digit(10).unwrap() as usize;
c = D[c][P[(i + 1) % 8][digit] as usize] as usize;
}
INV[c]
}
pub fn encode_manual_pairing_code(info: &OnboardingInfo) -> String {
let first = (info.discriminator as u32 >> 10) as u8;
let second = ((info.discriminator & 0x300) << 6) as u32 | (info.passcode & 0x3fff);
let third = info.passcode >> 14;
let digits = format!("{:01}{:05}{:04}", first, second, third);
let check = verhoeff_checksum(&digits);
let num = format!("{}{:05}{:04}{}", first, second, third, check);
let mut formatted = String::new();
for (i, ch) in num.chars().enumerate() {
if i > 0 && i % 4 == 0 {
formatted.push('-');
}
formatted.push(ch);
}
formatted
}
#[cfg(test)]
mod tests {
use crate::onboarding::OnboardingInfo;
use super::decode_manual_pairing_code;
use super::encode_manual_pairing_code;
#[test]
pub fn test_1() {
let res = decode_manual_pairing_code("2585-103-3238").unwrap();
assert_eq!(res.discriminator, 2816);
assert_eq!(res.passcode, 54453390);
let encoded = encode_manual_pairing_code(&res);
assert_eq!(encoded.replace("-", ""), "25851033238");
}
#[test]
pub fn test_2() {
let res = decode_manual_pairing_code("34970112332").unwrap();
assert_eq!(res.discriminator, 3840);
assert_eq!(res.passcode, 20202021);
let encoded = encode_manual_pairing_code(&res);
assert_eq!(encoded.replace("-", ""), "34970112332");
}
#[test]
pub fn test_3() {
let oi = OnboardingInfo {
discriminator: 3840,
passcode: 123456,
is_short_discriminator: false,
vendor_id: None,
product_id: None,
discovery_capabilities: None,
};
let encoded = encode_manual_pairing_code(&oi);
println!("Encoded: {}", encoded);
}
#[test]
pub fn test_qr_decode() {
let info = super::decode_qr_payload("MT:-24J04QI14G6Q663000").unwrap();
assert_eq!(info.passcode, 123456, "passcode mismatch");
assert_eq!(info.discriminator, 100, "discriminator mismatch");
assert_eq!(info.vendor_id, Some(0xFFF1));
assert_eq!(info.product_id, Some(0x8001));
let dc = info.discovery_capabilities.unwrap();
assert!(dc.has_on_network());
}
#[test]
pub fn test_qr_decode2() {
let info = super::decode_qr_payload("MT:-24J0SO527LJQ663000").unwrap();
assert_eq!(info.passcode, 123456, "passcode mismatch");
assert_eq!(info.discriminator, 4095, "discriminator mismatch");
assert_eq!(info.vendor_id, Some(0xFFF1));
assert_eq!(info.product_id, Some(0x8001));
let dc = info.discovery_capabilities.unwrap();
assert!(dc.has_on_network());
}
}