221 lines
8.6 KiB
Python
221 lines
8.6 KiB
Python
|
|
#!/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 BitsInteger, BitStruct, Enum
|
||
|
|
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()
|