src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/pairing.rs
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-07-28 16:58:20 -0500
committerLuke Hoersten <[email protected]>2026-07-28 16:58:20 -0500
commit07d4713f554b2ae2ccf4871f6be0590c129342b0 (patch)
treee9675e5e8a7cfce544927339c8f93ee7f412a71b /src/pairing.rs
Implement mattertimesync: one-shot Matter time synchronization CLI
A standalone CLI Matter controller that joins Matter devices as a secondary administrator (multi-admin) and sets their clocks via the standard Time Synchronization cluster. Built on rs-matter 0.2.0, the official CSA Rust Matter stack: its PASE/CASE initiators, Commissioner flow, builtin mDNS, and generated cluster clients. One-shot runs from a systemd timer; there is no daemon. Hardware-validated end to end (commission, inspect, sync, decommission) against an IKEA ALPSTUGA air quality monitor over Thread, commissioned alongside Apple Home. Design and operation are documented in the README. 34 unit tests, clippy clean.
Diffstat (limited to 'src/pairing.rs')
-rw-r--r--src/pairing.rs129
1 files changed, 129 insertions, 0 deletions
diff --git a/src/pairing.rs b/src/pairing.rs
new file mode 100644
index 0000000..715d7a8
--- /dev/null
+++ b/src/pairing.rs
@@ -0,0 +1,129 @@
+//! Manual pairing code decoding (Matter Core spec 5.1.4.1).
+//!
+//! An 11-digit (no VID/PID) or 21-digit (with VID/PID) decimal code packs a
+//! 4-bit short discriminator and the 27-bit setup passcode, guarded by a
+//! trailing Verhoeff check digit. QR payloads ("MT:...") are not accepted;
+//! the primary ecosystem always shows the numeric code.
+
+#[derive(Debug, thiserror::Error, PartialEq, Eq)]
+pub enum PairingCodeError {
+ #[error(
+ "QR payloads are not supported; enter the numeric pairing code shown by the primary ecosystem"
+ )]
+ QrPayload,
+ #[error("pairing code must be 11 or 21 digits (got {0})")]
+ BadLength(usize),
+ #[error("pairing code check digit is wrong; re-check the digits")]
+ BadCheckDigit,
+ #[error("pairing code decodes to an invalid setup passcode")]
+ BadPasscode,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Onboarding {
+ pub passcode: u32,
+ /// Upper 4 bits of the 12-bit discriminator, as carried in manual codes.
+ pub short_discriminator: u8,
+}
+
+/// Setup passcodes the spec forbids (trivial/sequential values).
+const INVALID_PASSCODES: [u32; 12] = [
+ 0, 11111111, 22222222, 33333333, 44444444, 55555555, 66666666, 77777777, 88888888, 99999999,
+ 12345678, 87654321,
+];
+
+pub fn parse_manual_code(raw: &str) -> Result<Onboarding, PairingCodeError> {
+ let trimmed = raw.trim();
+ if trimmed.starts_with("MT:") {
+ return Err(PairingCodeError::QrPayload);
+ }
+ let digits: Vec<u8> = trimmed
+ .chars()
+ .filter(|c| !c.is_whitespace() && *c != '-')
+ .map(|c| {
+ c.to_digit(10)
+ .map(|d| d as u8)
+ .ok_or(PairingCodeError::BadLength(0))
+ })
+ .collect::<Result<_, _>>()
+ .map_err(|_| PairingCodeError::BadLength(trimmed.len()))?;
+
+ if digits.len() != 11 && digits.len() != 21 {
+ return Err(PairingCodeError::BadLength(digits.len()));
+ }
+
+ let payload: String = digits[..digits.len() - 1]
+ .iter()
+ .map(|d| (b'0' + d) as char)
+ .collect();
+ if verhoeff::calculate(payload.as_str()) != digits[digits.len() - 1] {
+ return Err(PairingCodeError::BadCheckDigit);
+ }
+
+ let d1 = u32::from(digits[0]);
+ let chunk2 = digits[1..6]
+ .iter()
+ .fold(0u32, |acc, &d| acc * 10 + u32::from(d));
+ let chunk3 = digits[6..10]
+ .iter()
+ .fold(0u32, |acc, &d| acc * 10 + u32::from(d));
+
+ // digit 1 = (VID_PID_PRESENT << 2) | (discriminator >> 10)
+ // digits 2-6 = ((discriminator & 0x300) << 6) | (passcode & 0x3FFF)
+ // digits 7-10 = passcode >> 14
+ let short_discriminator = (((d1 & 0x3) << 2) | ((chunk2 >> 14) & 0x3)) as u8;
+ let passcode = (chunk3 << 14) | (chunk2 & 0x3FFF);
+
+ if passcode >= 1 << 27 || INVALID_PASSCODES.contains(&passcode) {
+ return Err(PairingCodeError::BadPasscode);
+ }
+ Ok(Onboarding {
+ passcode,
+ short_discriminator,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn decodes_the_canonical_test_code() {
+ // The Matter SDK test onboarding payload: passcode 20202021,
+ // discriminator 3840 (0xF00) => short discriminator 15, manual code
+ // 34970112332.
+ let onboarding = parse_manual_code("34970112332").unwrap();
+ assert_eq!(
+ onboarding,
+ Onboarding {
+ passcode: 20202021,
+ short_discriminator: 15,
+ }
+ );
+ }
+
+ #[test]
+ fn accepts_hyphenated_input() {
+ assert!(parse_manual_code("3497-011-2332").is_ok());
+ }
+
+ #[test]
+ fn rejects_qr_payloads_with_guidance() {
+ assert_eq!(
+ parse_manual_code("MT:Y.K90SO527JA0648G00"),
+ Err(PairingCodeError::QrPayload)
+ );
+ }
+
+ #[test]
+ fn rejects_wrong_lengths_and_check_digits() {
+ assert!(matches!(
+ parse_manual_code("1234"),
+ Err(PairingCodeError::BadLength(_))
+ ));
+ assert_eq!(
+ parse_manual_code("34970112331"),
+ Err(PairingCodeError::BadCheckDigit)
+ );
+ }
+}