Some files had been saved with CRLF on Windows, so a one-line change showed as a whole-file diff. .gitattributes now stores text as LF (.bat stays CRLF for cmd, .command/.sh LF for macOS). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
4.8 KiB
Python
114 lines
4.8 KiB
Python
"""Matter onboarding payloads: the "MT:..." QR string and the 11-digit manual code.
|
|
|
|
Implements the Matter spec (5.1.3 QR payload, 5.1.4 manual pairing code):
|
|
bit fields packed LSB-first, Base38 in 3-byte chunks, Verhoeff check digit.
|
|
|
|
The sensorbox firmware uses the Matter SDK example commissionable data
|
|
(CONFIG_EXAMPLE_COMMISSIONABLE_DATA_PROVIDER): every v1.0 box advertises
|
|
VID 0xFFF1 / PID 0x8000 / discriminator 3840 with passcode 20202021, which is
|
|
exactly the sticker (QR "MT:Y.K9042C00KA0648G00", manual 3497-011-2332).
|
|
VID/PID/discriminator are taken from the box's own log when available; the
|
|
passcode is never logged, so the known default is used.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
|
|
BASE38 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-."
|
|
RENDEZVOUS_BLE = 1 << 1
|
|
DEFAULT_PASSCODE = 20202021 # Matter SDK test passcode used by the v1.0 firmware
|
|
DEFAULT = dict(vendor_id=0xFFF1, product_id=0x8000, discriminator=3840)
|
|
|
|
# chip[DIS]: Advertise commission parameter vendorID=65521 productID=32768 discriminator=3840/15 cm=1 cp=0
|
|
_ADVERTISE = re.compile(r"vendorID=(\d+) productID=(\d+) discriminator=(\d+)")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SetupPayload:
|
|
vendor_id: int = DEFAULT["vendor_id"]
|
|
product_id: int = DEFAULT["product_id"]
|
|
discriminator: int = DEFAULT["discriminator"]
|
|
passcode: int = DEFAULT_PASSCODE
|
|
rendezvous: int = RENDEZVOUS_BLE
|
|
flow: int = 0 # standard commissioning flow
|
|
version: int = 0
|
|
from_log: bool = False # VID/PID/discriminator read from this box's log
|
|
qr: str | None = None # exact payload reported by the box ("KOTA: matter qr=MT:...")
|
|
manual: str | None = None # exact 11-digit manual code reported by the box
|
|
|
|
@property
|
|
def from_box(self) -> bool:
|
|
return self.qr is not None
|
|
|
|
def qr_text(self) -> str:
|
|
if self.qr:
|
|
return self.qr
|
|
fields = [(self.version, 3), (self.vendor_id, 16), (self.product_id, 16), (self.flow, 2),
|
|
(self.rendezvous, 8), (self.discriminator, 12), (self.passcode, 27), (0, 4)]
|
|
value, shift = 0, 0
|
|
for v, bits in fields:
|
|
if not 0 <= v < (1 << bits):
|
|
raise ValueError(f"field value {v} does not fit in {bits} bits")
|
|
value |= v << shift
|
|
shift += bits
|
|
return "MT:" + _base38(value.to_bytes(shift // 8, "little"))
|
|
|
|
def manual_code(self) -> str:
|
|
if self.manual:
|
|
return self.manual
|
|
short = self.discriminator >> 8 # upper 4 bits
|
|
chunk1 = short >> 2 # vid_pid_present = 0
|
|
chunk2 = ((short & 0x3) << 14) | (self.passcode & 0x3FFF)
|
|
chunk3 = self.passcode >> 14
|
|
digits = f"{chunk1}{chunk2:05d}{chunk3:04d}"
|
|
return digits + str(verhoeff_check(digits))
|
|
|
|
def manual_code_pretty(self) -> str:
|
|
c = self.manual_code()
|
|
return f"{c[:4]}-{c[4:7]}-{c[7:]}"
|
|
|
|
|
|
def _base38(data: bytes) -> str:
|
|
out = []
|
|
for i in range(0, len(data), 3):
|
|
chunk = data[i:i + 3]
|
|
value = int.from_bytes(chunk, "little")
|
|
for _ in range({3: 5, 2: 4, 1: 2}[len(chunk)]):
|
|
value, rem = divmod(value, 38)
|
|
out.append(BASE38[rem])
|
|
return "".join(out)
|
|
|
|
|
|
_D = [[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]]
|
|
_P = [[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]]
|
|
_INV = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9]
|
|
|
|
|
|
def verhoeff_check(digits: str) -> int:
|
|
c = 0
|
|
for i, d in enumerate(reversed(digits)):
|
|
c = _D[c][_P[(i + 1) % 8][int(d)]]
|
|
return _INV[c]
|
|
|
|
|
|
def payload_from_log(lines: list[str], qr: str | None = None, manual: str | None = None) -> SetupPayload:
|
|
"""Firmware 1.0.16+ reports its exact QR payload and manual code
|
|
("KOTA: matter ... qr=MT:... manual=..."); older firmware: use the
|
|
advertised VID/PID/discriminator if seen; defaults otherwise."""
|
|
base = SetupPayload()
|
|
for text in reversed(lines):
|
|
if m := _ADVERTISE.search(text):
|
|
base = SetupPayload(int(m.group(1)), int(m.group(2)), int(m.group(3)), from_log=True)
|
|
break
|
|
if qr:
|
|
return SetupPayload(base.vendor_id, base.product_id, base.discriminator, from_log=True,
|
|
qr=qr, manual=manual if manual and manual.isdigit() else None)
|
|
return base
|