Skip to content

Commit 22a9dc3

Browse files
setup_payload: Add support for parsing setup payloads in python impl (#32516)
* setup_payload: Add support for parsing setup payloads in python impl - Base38 decode impl in python - use construct to generate/parse setup payload in python - Add cli to parse and generate using click - unit tests for parsing and verification using chip-tool - removed the older script which only generated the codes - replaced the usage of older utility with newer one * Restyled by isort * fix the requirements * Added some test dataset * always use latest bitarray --------- Co-authored-by: Restyled.io <commits@restyled.io>
1 parent af25f56 commit 22a9dc3

11 files changed

+475
-320
lines changed

.github/workflows/build.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ jobs:
333333
scripts/run_in_build_env.sh 'virtualenv pyenv'
334334
source pyenv/bin/activate
335335
pip3 install -r src/setup_payload/python/requirements.txt
336-
python3 src/setup_payload/tests/run_python_setup_payload_gen_test.py out/chip-tool
336+
python3 src/setup_payload/tests/run_python_setup_payload_test.py out/chip-tool
337337
338338
build_linux_python_lighting_device:
339339
name: Build on Linux (python lighting-app)

scripts/tools/bouffalolab/factory_qrcode.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@
2020

2121
try:
2222
import qrcode
23-
from generate_setup_payload import CommissioningFlow, SetupPayload
23+
from SetupPayload import CommissioningFlow, SetupPayload
2424
except ImportError:
2525
SDK_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))))
2626
sys.path.append(os.path.join(SDK_ROOT, "src/setup_payload/python"))
2727
try:
2828
import qrcode
29-
from generate_setup_payload import CommissioningFlow, SetupPayload
29+
from SetupPayload import CommissioningFlow, SetupPayload
3030
except ModuleNotFoundError or ImportError:
3131
no_onboarding_modules = True
3232
else:

scripts/tools/generate_esp32_chip_factory_bin.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
sys.path.insert(0, os.path.join(CHIP_TOPDIR, 'scripts', 'tools', 'spake2p'))
3131
from spake2p import generate_verifier # noqa: E402 isort:skip
3232
sys.path.insert(0, os.path.join(CHIP_TOPDIR, 'src', 'setup_payload', 'python'))
33-
from generate_setup_payload import CommissioningFlow, SetupPayload # noqa: E402 isort:skip
33+
from SetupPayload import CommissioningFlow, SetupPayload # noqa: E402 isort:skip
3434

3535
if os.getenv('IDF_PATH'):
3636
sys.path.insert(0, os.path.join(os.getenv('IDF_PATH'),

scripts/tools/nrfconnect/generate_nrfconnect_chip_factory_data.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@
3232

3333
try:
3434
import qrcode
35-
from generate_setup_payload import CommissioningFlow, SetupPayload
35+
from SetupPayload import CommissioningFlow, SetupPayload
3636
except ImportError:
3737
SDK_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))))
3838
sys.path.append(os.path.join(SDK_ROOT, "src/setup_payload/python"))
3939
try:
4040
import qrcode
41-
from generate_setup_payload import CommissioningFlow, SetupPayload
41+
from SetupPayload import CommissioningFlow, SetupPayload
4242
except ModuleNotFoundError or ImportError:
4343
no_onboarding_modules = True
4444
else:

src/setup_payload/python/Base38.py

+23
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
RADIX = len(CODES)
2525
BASE38_CHARS_NEEDED_IN_CHUNK = [2, 4, 5]
2626
MAX_BYTES_IN_CHUNK = 3
27+
MAX_ENCODED_BYTES_IN_CHUNK = 5
2728

2829

2930
def encode(bytes):
@@ -47,3 +48,25 @@ def encode(bytes):
4748
base38_chars_needed -= 1
4849

4950
return qrcode
51+
52+
53+
def decode(qrcode):
54+
total_chars = len(qrcode)
55+
decoded_bytes = bytearray()
56+
57+
for i in range(0, total_chars, MAX_ENCODED_BYTES_IN_CHUNK):
58+
if (i + MAX_ENCODED_BYTES_IN_CHUNK) > total_chars:
59+
chars_in_chunk = total_chars - i
60+
else:
61+
chars_in_chunk = MAX_ENCODED_BYTES_IN_CHUNK
62+
63+
value = 0
64+
for j in range(i + chars_in_chunk - 1, i - 1, -1):
65+
value = value * RADIX + CODES.index(qrcode[j])
66+
67+
bytes_in_chunk = BASE38_CHARS_NEEDED_IN_CHUNK.index(chars_in_chunk) + 1
68+
for k in range(0, bytes_in_chunk):
69+
decoded_bytes.append(value & 0xFF)
70+
value = value >> 8
71+
72+
return decoded_bytes

src/setup_payload/python/README.md

+10-7
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
1-
## Python tool to generate Matter onboarding codes
1+
## Python tool to generate and parse Matter onboarding codes
22

3-
Generates Manual Pairing Code and QR Code
3+
Generates and parses Manual Pairing Code and QR Code
44

55
#### example usage:
66

7+
- Parse
8+
79
```
8-
./generate_setup_payload.py -h
9-
./generate_setup_payload.py -d 3840 -p 20202021 -cf 0 -dm 2 -vid 65521 -pid 32768
10+
./SetupPayload.py parse MT:U9VJ0OMV172PX813210
11+
./SetupPayload.py parse 34970112332
1012
```
1113

12-
- Output
14+
- Generate
1315

1416
```
15-
Manualcode : 34970112332
16-
QRCode : MT:Y.K9042C00KA0648G00
17+
./SetupPayload.py generate --help
18+
./SetupPayload.py generate -d 3840 -p 20202021
19+
./SetupPayload.py generate -d 3840 -p 20202021 --vendor-id 65521 --product-id 32768 -cf 0 -dm 2
1720
```
1821

1922
For more details please refer Matter Specification
+220
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Copyright (c) 2024 Project CHIP Authors
4+
# All rights reserved.
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
18+
import enum
19+
20+
import Base38
21+
import click
22+
from bitarray import bitarray
23+
from bitarray.util import int2ba, zeros
24+
from construct import BitsInteger, BitStruct, Enum
25+
from stdnum.verhoeff import calc_check_digit
26+
27+
# Format for constructing manualcode
28+
manualcode_format = BitStruct(
29+
'version' / BitsInteger(1),
30+
'vid_pid_present' / BitsInteger(1),
31+
'discriminator' / BitsInteger(4),
32+
'pincode_lsb' / BitsInteger(14),
33+
'pincode_msb' / BitsInteger(13),
34+
'vid' / BitsInteger(16),
35+
'pid' / BitsInteger(16),
36+
'padding' / BitsInteger(7), # this is intentional as BitStruct only takes 8-bit aligned data
37+
)
38+
39+
# Format for constructing qrcode
40+
# qrcode bytes are packed as lsb....msb, hence the order is reversed
41+
qrcode_format = BitStruct(
42+
'padding' / BitsInteger(4),
43+
'pincode' / BitsInteger(27),
44+
'discriminator' / BitsInteger(12),
45+
'discovery' / BitsInteger(8),
46+
'flow' / Enum(BitsInteger(2),
47+
Standard=0, UserIntent=1, Custom=2),
48+
'pid' / BitsInteger(16),
49+
'vid' / BitsInteger(16),
50+
'version' / BitsInteger(3),
51+
)
52+
53+
54+
class CommissioningFlow(enum.IntEnum):
55+
Standard = 0,
56+
UserIntent = 1,
57+
Custom = 2
58+
59+
60+
class SetupPayload:
61+
def __init__(self, discriminator, pincode, rendezvous=4, flow=CommissioningFlow.Standard, vid=0, pid=0):
62+
self.long_discriminator = discriminator
63+
self.short_discriminator = discriminator >> 8
64+
self.pincode = pincode
65+
self.discovery = rendezvous
66+
self.flow = flow
67+
self.vid = vid
68+
self.pid = pid
69+
70+
def p_print(self):
71+
print('{:<{}} :{}'.format('Flow', 24, self.flow))
72+
print('{:<{}} :{}'.format('Pincode', 24, self.pincode))
73+
print('{:<{}} :{}'.format('Short Discriminator', 24, self.short_discriminator))
74+
if self.long_discriminator:
75+
print('{:<{}} :{}'.format('Long Discriminator', 24, self.long_discriminator))
76+
if self.discovery:
77+
print('{:<{}} :{}'.format('Discovery Capabilities', 24, self.discovery))
78+
if self.vid is not None and self.pid is not None:
79+
print('{:<{}} :{:<{}} (0x{:04x})'.format('Vendor Id', 24, self.vid, 6, self.vid))
80+
print('{:<{}} :{:<{}} (0x{:04x})'.format('Product Id', 24, self.pid, 6, self.pid))
81+
82+
def qrcode_dict(self):
83+
return {
84+
'version': 0,
85+
'vid': self.vid,
86+
'pid': self.pid,
87+
'flow': int(self.flow),
88+
'discovery': self.discovery,
89+
'discriminator': self.long_discriminator,
90+
'pincode': self.pincode,
91+
'padding': 0,
92+
}
93+
94+
def manualcode_dict(self):
95+
return {
96+
'version': 0,
97+
'vid_pid_present': 0 if self.flow == CommissioningFlow.Standard else 1,
98+
'discriminator': self.short_discriminator,
99+
'pincode_lsb': self.pincode & 0x3FFF, # 14 ls-bits
100+
'pincode_msb': self.pincode >> 14, # 13 ms-bits
101+
'vid': 0 if self.flow == CommissioningFlow.Standard else self.vid,
102+
'pid': 0 if self.flow == CommissioningFlow.Standard else self.pid,
103+
'padding': 0,
104+
}
105+
106+
def generate_qrcode(self):
107+
data = qrcode_format.build(self.qrcode_dict())
108+
b38_encoded = Base38.encode(data[::-1]) # reversing
109+
return 'MT:{}'.format(b38_encoded)
110+
111+
def generate_manualcode(self):
112+
CHUNK1_START = 0
113+
CHUNK1_LEN = 4
114+
CHUNK2_START = CHUNK1_START + CHUNK1_LEN
115+
CHUNK2_LEN = 16
116+
CHUNK3_START = CHUNK2_START + CHUNK2_LEN
117+
CHUNK3_LEN = 13
118+
119+
bytes = manualcode_format.build(self.manualcode_dict())
120+
bits = bitarray()
121+
bits.frombytes(bytes)
122+
123+
chunk1 = str(int(bits[CHUNK1_START:CHUNK1_START + CHUNK1_LEN].to01(), 2)).zfill(1)
124+
chunk2 = str(int(bits[CHUNK2_START:CHUNK2_START + CHUNK2_LEN].to01(), 2)).zfill(5)
125+
chunk3 = str(int(bits[CHUNK3_START:CHUNK3_START + CHUNK3_LEN].to01(), 2)).zfill(4)
126+
chunk4 = str(self.vid).zfill(5) if self.flow != CommissioningFlow.Standard else ''
127+
chunk5 = str(self.pid).zfill(5) if self.flow != CommissioningFlow.Standard else ''
128+
payload = '{}{}{}{}{}'.format(chunk1, chunk2, chunk3, chunk4, chunk5)
129+
return '{}{}'.format(payload, calc_check_digit(payload))
130+
131+
@staticmethod
132+
def from_container(container, is_qrcode):
133+
payload = None
134+
if is_qrcode:
135+
payload = SetupPayload(container['discriminator'], container['pincode'],
136+
container['discovery'], CommissioningFlow(container['flow'].__int__()),
137+
container['vid'], container['pid'])
138+
else:
139+
payload = SetupPayload(discriminator=container['discriminator'],
140+
pincode=(container['pincode_msb'] << 14) | container['pincode_lsb'],
141+
vid=container['vid'] if container['vid_pid_present'] else None,
142+
pid=container['pid'] if container['vid_pid_present'] else None)
143+
payload.short_discriminator = container['discriminator']
144+
payload.long_discriminator = None
145+
payload.discovery = None
146+
payload.flow = 2 if container['vid_pid_present'] else 0
147+
148+
return payload
149+
150+
@staticmethod
151+
def parse_qrcode(payload):
152+
payload = payload[3:] # remove 'MT:'
153+
b38_decoded = Base38.decode(payload)[::-1]
154+
container = qrcode_format.parse(b38_decoded)
155+
return SetupPayload.from_container(container, is_qrcode=True)
156+
157+
@staticmethod
158+
def parse_manualcode(payload):
159+
payload_len = len(payload)
160+
if payload_len != 11 and payload_len != 21:
161+
print('Invalid length')
162+
return None
163+
164+
# if first digit is greater than 7 the its not v1
165+
if int(str(payload)[0]) > 7:
166+
print('incorrect first digit')
167+
return None
168+
169+
if calc_check_digit(payload[:-1]) != str(payload)[-1]:
170+
print('check digit mismatch')
171+
return None
172+
173+
# vid_pid_present bit position
174+
is_long = int(str(payload)[0]) & (1 << 2)
175+
176+
bits = int2ba(int(payload[0]), length=4)
177+
bits += int2ba(int(payload[1:6]), length=16)
178+
bits += int2ba(int(payload[6:10]), length=13)
179+
bits += int2ba(int(payload[10:15]), length=16) if is_long else zeros(16)
180+
bits += int2ba(int(payload[15:20]), length=16) if is_long else zeros(16)
181+
bits += zeros(7) # padding
182+
183+
container = manualcode_format.parse(bits.tobytes())
184+
return SetupPayload.from_container(container, is_qrcode=False)
185+
186+
@staticmethod
187+
def parse(payload):
188+
if payload.startswith('MT:'):
189+
return SetupPayload.parse_qrcode(payload)
190+
else:
191+
return SetupPayload.parse_manualcode(payload)
192+
193+
194+
@click.group()
195+
def cli():
196+
pass
197+
198+
199+
@cli.command()
200+
@click.argument('payload')
201+
def parse(payload):
202+
click.echo(f'Parsing payload: {payload}')
203+
SetupPayload.parse(payload).p_print()
204+
205+
206+
@cli.command()
207+
@click.option('--discriminator', '-d', required=True, type=click.IntRange(0, 0xFFF), help='Discriminator')
208+
@click.option('--passcode', '-p', required=True, type=click.IntRange(1, 0x5F5E0FE), help='setup pincode')
209+
@click.option('--vendor-id', '-vid', type=click.IntRange(0, 0xFFFF), default=0, help='Vendor ID')
210+
@click.option('--product-id', '-pid', type=click.IntRange(0, 0xFFFF), default=0, help='Product ID')
211+
@click.option('--discovery-cap-bitmask', '-dm', type=click.IntRange(0, 7), default=4, help='Commissionable device discovery capability bitmask. 0:SoftAP, 1:BLE, 2:OnNetwork. Default: OnNetwork')
212+
@click.option('--commissioning-flow', '-cf', type=click.IntRange(0, 2), default=0, help='Commissioning flow, 0:Standard, 1:User-Intent, 2:Custom')
213+
def generate(passcode, discriminator, vendor_id, product_id, discovery_cap_bitmask, commissioning_flow):
214+
payload = SetupPayload(discriminator, passcode, discovery_cap_bitmask, commissioning_flow, vendor_id, product_id)
215+
print("Manualcode : {}".format(payload.generate_manualcode()))
216+
print("QRCode : {}".format(payload.generate_qrcode()))
217+
218+
219+
if __name__ == '__main__':
220+
cli()

0 commit comments

Comments
 (0)