Skip to content

Commit

Permalink
setup_payload: Add support for parsing setup payloads in python impl
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
shubhamdp committed Mar 9, 2024
1 parent b278978 commit 4618708
Show file tree
Hide file tree
Showing 10 changed files with 323 additions and 198 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ jobs:
scripts/run_in_build_env.sh 'virtualenv pyenv'
source pyenv/bin/activate
pip3 install -r src/setup_payload/python/requirements.txt
python3 src/setup_payload/tests/run_python_setup_payload_gen_test.py out/chip-tool
python3 src/setup_payload/tests/run_python_setup_payload_test.py out/chip-tool
build_linux_python_lighting_device:
name: Build on Linux (python lighting-app)
Expand Down
4 changes: 2 additions & 2 deletions scripts/tools/bouffalolab/factory_qrcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@

try:
import qrcode
from generate_setup_payload import CommissioningFlow, SetupPayload
from SetupPayload import CommissioningFlow, SetupPayload
except ImportError:
SDK_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))))
sys.path.append(os.path.join(SDK_ROOT, "src/setup_payload/python"))
try:
import qrcode
from generate_setup_payload import CommissioningFlow, SetupPayload
from SetupPayload import CommissioningFlow, SetupPayload
except ModuleNotFoundError or ImportError:
no_onboarding_modules = True
else:
Expand Down
2 changes: 1 addition & 1 deletion scripts/tools/generate_esp32_chip_factory_bin.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
sys.path.insert(0, os.path.join(CHIP_TOPDIR, 'scripts', 'tools', 'spake2p'))
from spake2p import generate_verifier # noqa: E402 isort:skip
sys.path.insert(0, os.path.join(CHIP_TOPDIR, 'src', 'setup_payload', 'python'))
from generate_setup_payload import CommissioningFlow, SetupPayload # noqa: E402 isort:skip
from SetupPayload import CommissioningFlow, SetupPayload # noqa: E402 isort:skip

if os.getenv('IDF_PATH'):
sys.path.insert(0, os.path.join(os.getenv('IDF_PATH'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@

try:
import qrcode
from generate_setup_payload import CommissioningFlow, SetupPayload
from SetupPayload import CommissioningFlow, SetupPayload
except ImportError:
SDK_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))))
sys.path.append(os.path.join(SDK_ROOT, "src/setup_payload/python"))
try:
import qrcode
from generate_setup_payload import CommissioningFlow, SetupPayload
from SetupPayload import CommissioningFlow, SetupPayload
except ModuleNotFoundError or ImportError:
no_onboarding_modules = True
else:
Expand Down
23 changes: 23 additions & 0 deletions src/setup_payload/python/Base38.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
RADIX = len(CODES)
BASE38_CHARS_NEEDED_IN_CHUNK = [2, 4, 5]
MAX_BYTES_IN_CHUNK = 3
MAX_ENCODED_BYTES_IN_CHUNK = 5


def encode(bytes):
Expand All @@ -47,3 +48,25 @@ def encode(bytes):
base38_chars_needed -= 1

return qrcode


def decode(qrcode):
total_chars = len(qrcode)
decoded_bytes = bytearray()

for i in range(0, total_chars, MAX_ENCODED_BYTES_IN_CHUNK):
if (i + MAX_ENCODED_BYTES_IN_CHUNK) > total_chars:
chars_in_chunk = total_chars - i
else:
chars_in_chunk = MAX_ENCODED_BYTES_IN_CHUNK

value = 0
for j in range(i + chars_in_chunk - 1, i - 1, -1):
value = value * RADIX + CODES.index(qrcode[j])

bytes_in_chunk = BASE38_CHARS_NEEDED_IN_CHUNK.index(chars_in_chunk) + 1
for k in range(0, bytes_in_chunk):
decoded_bytes.append(value & 0xFF)
value = value >> 8

return decoded_bytes
17 changes: 10 additions & 7 deletions src/setup_payload/python/README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
## Python tool to generate Matter onboarding codes
## Python tool to generate and parse Matter onboarding codes

Generates Manual Pairing Code and QR Code
Generates and parses Manual Pairing Code and QR Code

#### example usage:

- Parse

```
./generate_setup_payload.py -h
./generate_setup_payload.py -d 3840 -p 20202021 -cf 0 -dm 2 -vid 65521 -pid 32768
./SetupPayload.py parse MT:U9VJ0OMV172PX813210
./SetupPayload.py parse 34970112332
```

- Output
- Generate

```
Manualcode : 34970112332
QRCode : MT:Y.K9042C00KA0648G00
./SetupPayload.py generate --help
./SetupPayload.py generate -d 3840 -p 20202021
./SetupPayload.py generate -d 3840 -p 20202021 --vendor-id 65521 --product-id 32768 -cf 0 -dm 2
```

For more details please refer Matter Specification
Expand Down
220 changes: 220 additions & 0 deletions src/setup_payload/python/SetupPayload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Project CHIP Authors
# All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import enum

import Base38
import click
from bitarray import bitarray
from bitarray.util import int2ba, zeros
from construct import *
from stdnum.verhoeff import calc_check_digit

# Format for constructing manualcode
manualcode_format = BitStruct(
'version' / BitsInteger(1),
'vid_pid_present' / BitsInteger(1),
'discriminator' / BitsInteger(4),
'pincode_lsb' / BitsInteger(14),
'pincode_msb' / BitsInteger(13),
'vid' / BitsInteger(16),
'pid' / BitsInteger(16),
'padding' / BitsInteger(7), # this is intentional as BitStruct only takes 8-bit aligned data
)

# Format for constructing qrcode
# qrcode bytes are packed as lsb....msb, hence the order is reversed
qrcode_format = BitStruct(
'padding' / BitsInteger(4),
'pincode' / BitsInteger(27),
'discriminator' / BitsInteger(12),
'discovery' / BitsInteger(8),
'flow' / Enum(BitsInteger(2),
Standard=0, UserIntent=1, Custom=2),
'pid' / BitsInteger(16),
'vid' / BitsInteger(16),
'version' / BitsInteger(3),
)


class CommissioningFlow(enum.IntEnum):
Standard = 0,
UserIntent = 1,
Custom = 2


class SetupPayload:
def __init__(self, discriminator, pincode, rendezvous=4, flow=CommissioningFlow.Standard, vid=0, pid=0):
self.long_discriminator = discriminator
self.short_discriminator = discriminator >> 8
self.pincode = pincode
self.discovery = rendezvous
self.flow = flow
self.vid = vid
self.pid = pid

def p_print(self):
print('{:<{}} :{}'.format('Flow', 24, self.flow))
print('{:<{}} :{}'.format('Pincode', 24, self.pincode))
print('{:<{}} :{}'.format('Short Discriminator', 24, self.short_discriminator))
if self.long_discriminator:
print('{:<{}} :{}'.format('Long Discriminator', 24, self.long_discriminator))
if self.discovery:
print('{:<{}} :{}'.format('Discovery Capabilities', 24, self.discovery))
if self.vid is not None and self.pid is not None:
print('{:<{}} :{:<{}} (0x{:04x})'.format('Vendor Id', 24, self.vid, 6, self.vid))
print('{:<{}} :{:<{}} (0x{:04x})'.format('Product Id', 24, self.pid, 6, self.pid))

def qrcode_dict(self):
return {
'version': 0,
'vid': self.vid,
'pid': self.pid,
'flow': int(self.flow),
'discovery': self.discovery,
'discriminator': self.long_discriminator,
'pincode': self.pincode,
'padding': 0,
}

def manualcode_dict(self):
return {
'version': 0,
'vid_pid_present': 0 if self.flow == CommissioningFlow.Standard else 1,
'discriminator': self.short_discriminator,
'pincode_lsb': self.pincode & 0x3FFF, # 14 ls-bits
'pincode_msb': self.pincode >> 14, # 13 ms-bits
'vid': 0 if self.flow == CommissioningFlow.Standard else self.vid,
'pid': 0 if self.flow == CommissioningFlow.Standard else self.pid,
'padding': 0,
}

def generate_qrcode(self):
data = qrcode_format.build(self.qrcode_dict())
b38_encoded = Base38.encode(data[::-1]) # reversing
return 'MT:{}'.format(b38_encoded)

def generate_manualcode(self):
CHUNK1_START = 0
CHUNK1_LEN = 4
CHUNK2_START = CHUNK1_START + CHUNK1_LEN
CHUNK2_LEN = 16
CHUNK3_START = CHUNK2_START + CHUNK2_LEN
CHUNK3_LEN = 13

bytes = manualcode_format.build(self.manualcode_dict())
bits = bitarray()
bits.frombytes(bytes)

chunk1 = str(int(bits[CHUNK1_START:CHUNK1_START + CHUNK1_LEN].to01(), 2)).zfill(1)
chunk2 = str(int(bits[CHUNK2_START:CHUNK2_START + CHUNK2_LEN].to01(), 2)).zfill(5)
chunk3 = str(int(bits[CHUNK3_START:CHUNK3_START + CHUNK3_LEN].to01(), 2)).zfill(4)
chunk4 = str(self.vid).zfill(5) if self.flow != CommissioningFlow.Standard else ''
chunk5 = str(self.pid).zfill(5) if self.flow != CommissioningFlow.Standard else ''
payload = '{}{}{}{}{}'.format(chunk1, chunk2, chunk3, chunk4, chunk5)
return '{}{}'.format(payload, calc_check_digit(payload))

@staticmethod
def from_container(container, is_qrcode):
payload = None
if is_qrcode:
payload = SetupPayload(container['discriminator'], container['pincode'],
container['discovery'], CommissioningFlow(container['flow'].__int__()),
container['vid'], container['pid'])
else:
payload = SetupPayload(discriminator=container['discriminator'],
pincode=(container['pincode_msb'] << 14) | container['pincode_lsb'],
vid=container['vid'] if container['vid_pid_present'] else None,
pid=container['pid'] if container['vid_pid_present'] else None)
payload.short_discriminator = container['discriminator']
payload.long_discriminator = None
payload.discovery = None
payload.flow = 2 if container['vid_pid_present'] else 0

return payload

@staticmethod
def parse_qrcode(payload):
payload = payload[3:] # remove 'MT:'
b38_decoded = Base38.decode(payload)[::-1]
container = qrcode_format.parse(b38_decoded)
return SetupPayload.from_container(container, is_qrcode=True)

@staticmethod
def parse_manualcode(payload):
payload_len = len(payload)
if payload_len != 11 and payload_len != 21:
print('Invalid length')
return None

# if first digit is greater than 7 the its not v1
if int(str(payload)[0]) > 7:
print('incorrect first digit')
return None

if calc_check_digit(payload[:-1]) != str(payload)[-1]:
print('check digit mismatch')
return None

# vid_pid_present bit position
is_long = int(str(payload)[0]) & (1 << 2)

bits = int2ba(int(payload[0]), length=4)
bits += int2ba(int(payload[1:6]), length=16)
bits += int2ba(int(payload[6:10]), length=13)
bits += int2ba(int(payload[10:15]), length=16) if is_long else zeros(16)
bits += int2ba(int(payload[15:20]), length=16) if is_long else zeros(16)
bits += zeros(7) # padding

container = manualcode_format.parse(bits.tobytes())
return SetupPayload.from_container(container, is_qrcode=False)

@staticmethod
def parse(payload):
if payload.startswith('MT:'):
return SetupPayload.parse_qrcode(payload)
else:
return SetupPayload.parse_manualcode(payload)


@click.group()
def cli():
pass


@cli.command()
@click.argument('payload')
def parse(payload):
click.echo(f'Parsing payload: {payload}')
SetupPayload.parse(payload).p_print()


@cli.command()
@click.option('--discriminator', '-d', required=True, type=click.IntRange(0, 0xFFF), help='Discriminator')
@click.option('--passcode', '-p', required=True, type=click.IntRange(1, 0x5F5E0FE), help='setup pincode')
@click.option('--vendor-id', '-vid', type=click.IntRange(0, 0xFFFF), default=0, help='Vendor ID')
@click.option('--product-id', '-pid', type=click.IntRange(0, 0xFFFF), default=0, help='Product ID')
@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')
@click.option('--commissioning-flow', '-cf', type=click.IntRange(0, 2), default=0, help='Commissioning flow, 0:Standard, 1:User-Intent, 2:Custom')
def generate(passcode, discriminator, vendor_id, product_id, discovery_cap_bitmask, commissioning_flow):
payload = SetupPayload(discriminator, passcode, discovery_cap_bitmask, commissioning_flow, vendor_id, product_id)
print("Manualcode : {}".format(payload.generate_manualcode()))
print("QRCode : {}".format(payload.generate_qrcode()))


if __name__ == '__main__':
cli()
Loading

0 comments on commit 4618708

Please sign in to comment.