diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 88c8d1f93b38b9..a5a3977e0bfa83 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -350,6 +350,13 @@ jobs: scripts/run_in_build_env.sh 'pip3 install ./out/controller/python/chip_repl-0.0-py3-none-any.whl' scripts/run_in_build_env.sh '(cd src/controller/python/test/unit_tests/ && python3 -m unittest -v)' + - name: Run Python Setup Payload Generator Test + timeout-minutes: 10 + run: | + scripts/run_in_build_env.sh 'scripts/examples/gn_build_example.sh examples/chip-tool out/' + scripts/run_in_build_env.sh 'pip3 install -r src/setup_payload/python/requirements.txt' + scripts/run_in_build_env.sh 'python3 src/setup_payload/tests/run_python_setup_payload_gen_test.py out/chip-tool' + build_darwin: name: Build on Darwin (clang, python_lib, simulated) timeout-minutes: 200 diff --git a/src/setup_payload/python/Base38.py b/src/setup_payload/python/Base38.py new file mode 100644 index 00000000000000..23113f7ed25c8d --- /dev/null +++ b/src/setup_payload/python/Base38.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2022 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. + +# TODO: Implement the decode method + +CODES = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', + 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', + 'U', 'V', 'W', 'X', 'Y', 'Z', '-', '.'] +RADIX = len(CODES) +BASE38_CHARS_NEEDED_IN_CHUNK = [2, 4, 5] +MAX_BYTES_IN_CHUNK = 3 + + +def encode(bytes): + total_bytes = len(bytes) + qrcode = '' + + for i in range(0, total_bytes, MAX_BYTES_IN_CHUNK): + if (i + MAX_BYTES_IN_CHUNK) > total_bytes: + bytes_in_chunk = total_bytes - i + else: + bytes_in_chunk = MAX_BYTES_IN_CHUNK + + value = 0 + for j in range(i, i + bytes_in_chunk): + value = value + (bytes[j] << (8 * (j - i))) + + base38_chars_needed = BASE38_CHARS_NEEDED_IN_CHUNK[bytes_in_chunk - 1] + while base38_chars_needed > 0: + qrcode += CODES[int(value % RADIX)] + value = int(value / RADIX) + base38_chars_needed -= 1 + + return qrcode diff --git a/src/setup_payload/python/README.md b/src/setup_payload/python/README.md new file mode 100644 index 00000000000000..068bf553fb7e6b --- /dev/null +++ b/src/setup_payload/python/README.md @@ -0,0 +1,24 @@ +## Python tool to generate Matter onboarding codes + +Generates Manual Pairing Code and QR Code + +#### example usage: + +``` +./generate_setup_payload.py -h +./generate_setup_payload.py -d 3840 -p 20202021 -cf 0 -dm 2 -vid 65521 -pid 32768 +``` + +- Output + +``` +Manualcode : 34970112332 +QRCode : MT:Y.K9042C00KA0648G00 +``` + +For more details please refer Matter Specification + +--- + +NOTE: This tool is only capable of generating the payloads and no support to +parse the payloads. diff --git a/src/setup_payload/python/generate_setup_payload.py b/src/setup_payload/python/generate_setup_payload.py new file mode 100755 index 00000000000000..967cc778d2550b --- /dev/null +++ b/src/setup_payload/python/generate_setup_payload.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2022 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 argparse +import enum +import sys + +import Base38 +from bitarray import bitarray +from bitarray.util import ba2int +from stdnum.verhoeff import calc_check_digit + +# See section 5.1.4.1 Manual Pairing Code in the Matter specification v1.0 +MANUAL_DISCRIMINATOR_LEN = 4 +PINCODE_LEN = 27 + +MANUAL_CHUNK1_DISCRIMINATOR_MSBITS_LEN = 2 +MANUAL_CHUNK1_DISCRIMINATOR_MSBITS_POS = 0 +MANUAL_CHUNK1_VID_PID_PRESENT_BIT_POS = MANUAL_CHUNK1_DISCRIMINATOR_MSBITS_POS + MANUAL_CHUNK1_DISCRIMINATOR_MSBITS_LEN +MANUAL_CHUNK1_LEN = 1 + +MANUAL_CHUNK2_DISCRIMINATOR_LSBITS_LEN = 2 +MANUAL_CHUNK2_PINCODE_LSBITS_LEN = 14 +MANUAL_CHUNK2_PINCODE_LSBITS_POS = 0 +MANUAL_CHUNK2_DISCRIMINATOR_LSBITS_POS = MANUAL_CHUNK2_PINCODE_LSBITS_POS + MANUAL_CHUNK2_PINCODE_LSBITS_LEN +MANUAL_CHUNK2_LEN = 5 + +MANUAL_CHUNK3_PINCODE_MSBITS_LEN = 13 +MANUAL_CHUNK3_PINCODE_MSBITS_POS = 0 +MANUAL_CHUNK3_LEN = 4 + +MANUAL_VID_LEN = 5 +MANUAL_PID_LEN = 5 + +# See section 5.1.3. QR Code in the Matter specification v1.0 +QRCODE_VERSION_LEN = 3 +QRCODE_DISCRIMINATOR_LEN = 12 +QRCODE_VID_LEN = 16 +QRCODE_PID_LEN = 16 +QRCODE_COMMISSIONING_FLOW_LEN = 2 +QRCODE_DISCOVERY_CAP_BITMASK_LEN = 8 +QRCODE_PADDING_LEN = 4 +QRCODE_VERSION = 0 +QRCODE_PADDING = 0 + +INVALID_PASSCODES = [00000000, 11111111, 22222222, 33333333, 44444444, 55555555, + 66666666, 77777777, 88888888, 99999999, 12345678, 87654321] + + +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.rendezvous = rendezvous + self.flow = flow + self.vid = vid + self.pid = pid + + def manual_chunk1(self): + discriminator_shift = (MANUAL_DISCRIMINATOR_LEN - MANUAL_CHUNK1_DISCRIMINATOR_MSBITS_LEN) + discriminator_mask = (1 << MANUAL_CHUNK1_DISCRIMINATOR_MSBITS_LEN) - 1 + discriminator_chunk = (self.short_discriminator >> discriminator_shift) & discriminator_mask + vid_pid_present_flag = 0 if self.flow == CommissioningFlow.Standard else 1 + return (discriminator_chunk << MANUAL_CHUNK1_DISCRIMINATOR_MSBITS_POS) | (vid_pid_present_flag << MANUAL_CHUNK1_VID_PID_PRESENT_BIT_POS) + + def manual_chunk2(self): + discriminator_mask = (1 << MANUAL_CHUNK2_DISCRIMINATOR_LSBITS_LEN) - 1 + pincode_mask = (1 << MANUAL_CHUNK2_PINCODE_LSBITS_LEN) - 1 + discriminator_chunk = self.short_discriminator & discriminator_mask + return ((self.pincode & pincode_mask) << MANUAL_CHUNK2_PINCODE_LSBITS_POS) | (discriminator_chunk << MANUAL_CHUNK2_DISCRIMINATOR_LSBITS_POS) + + def manual_chunk3(self): + pincode_shift = PINCODE_LEN - MANUAL_CHUNK3_PINCODE_MSBITS_LEN + pincode_mask = (1 << MANUAL_CHUNK3_PINCODE_MSBITS_LEN) - 1 + return ((self.pincode >> pincode_shift) & pincode_mask) << MANUAL_CHUNK3_PINCODE_MSBITS_POS + + def generate_manualcode(self): + payload = str(self.manual_chunk1()).zfill(MANUAL_CHUNK1_LEN) + payload += str(self.manual_chunk2()).zfill(MANUAL_CHUNK2_LEN) + payload += str(self.manual_chunk3()).zfill(MANUAL_CHUNK3_LEN) + + if self.flow != CommissioningFlow.Standard: + payload += str(self.vid).zfill(MANUAL_VID_LEN) + payload += str(self.pid).zfill(MANUAL_PID_LEN) + + payload += calc_check_digit(payload) + return payload + + def generate_qrcode(self): + qrcode_bit_string = '{0:b}'.format(QRCODE_PADDING).zfill(QRCODE_PADDING_LEN) + qrcode_bit_string += '{0:b}'.format(self.pincode).zfill(PINCODE_LEN) + qrcode_bit_string += '{0:b}'.format(self.long_discriminator).zfill(QRCODE_DISCRIMINATOR_LEN) + qrcode_bit_string += '{0:b}'.format(self.rendezvous).zfill(QRCODE_DISCOVERY_CAP_BITMASK_LEN) + qrcode_bit_string += '{0:b}'.format(int(self.flow)).zfill(QRCODE_COMMISSIONING_FLOW_LEN) + qrcode_bit_string += '{0:b}'.format(self.pid).zfill(QRCODE_PID_LEN) + qrcode_bit_string += '{0:b}'.format(self.vid).zfill(QRCODE_VID_LEN) + qrcode_bit_string += '{0:b}'.format(QRCODE_VERSION).zfill(QRCODE_VERSION_LEN) + + qrcode_bits = bitarray(qrcode_bit_string) + bytes = list(qrcode_bits.tobytes()) + bytes.reverse() + return 'MT:{}'.format(Base38.encode(bytes)) + + +def validate_args(args): + def check_int_range(value, min_value, max_value, name): + if value and ((value < min_value) or (value > max_value)): + print('{} is out of range, should be in range from {} to {}'.format(name, min_value, max_value)) + sys.exit(1) + + if args.passcode is not None: + if ((args.passcode < 0x0000001 and args.passcode > 0x5F5E0FE) or (args.passcode in INVALID_PASSCODES)): + print('Invalid passcode:' + str(args.passcode)) + sys.exit(1) + + check_int_range(args.discriminator, 0x0000, 0x0FFF, 'Discriminator') + check_int_range(args.product_id, 0x0000, 0xFFFF, 'Product id') + check_int_range(args.vendor_id, 0x0000, 0xFFFF, 'Vendor id') + check_int_range(args.discovery_cap_bitmask, 0x0001, 0x0007, 'Discovery Capability Mask') + + +def main(): + def any_base_int(s): return int(s, 0) + parser = argparse.ArgumentParser(description='Matter Manual and QRCode Setup Payload Generator Tool') + parser.add_argument('-d', '--discriminator', type=any_base_int, required=True, + help='The discriminator for pairing, range: 0x00-0x0FFF') + parser.add_argument('-p', '--passcode', type=any_base_int, required=True, + help='The setup passcode for pairing, range: 0x01-0x5F5E0FE') + parser.add_argument('-vid', '--vendor-id', type=any_base_int, default=0, help='Vendor id') + parser.add_argument('-pid', '--product-id', type=any_base_int, default=0, help='Product id') + parser.add_argument('-cf', '--commissioning-flow', type=any_base_int, default=0, + help='Device commissioning flow, 0:Standard, 1:User-Intent, 2:Custom. \ + Default is 0.', choices=[0, 1, 2]) + parser.add_argument('-dm', '--discovery-cap-bitmask', type=any_base_int, default=4, + help='Commissionable device discovery capability bitmask. \ + 0:SoftAP, 1:BLE, 2:OnNetwork. Default: OnNetwork') + args = parser.parse_args() + validate_args(args) + + payloads = SetupPayload(args.discriminator, args.passcode, args.discovery_cap_bitmask, + CommissioningFlow(args.commissioning_flow), args.vendor_id, args.product_id) + manualcode = payloads.generate_manualcode() + qrcode = payloads.generate_qrcode() + + print("Manualcode : {}".format(manualcode)) + print("QRCode : {}".format(qrcode)) + + +if __name__ == '__main__': + main() diff --git a/src/setup_payload/python/requirements.txt b/src/setup_payload/python/requirements.txt new file mode 100644 index 00000000000000..43800d601c7736 --- /dev/null +++ b/src/setup_payload/python/requirements.txt @@ -0,0 +1,2 @@ +bitarray==2.6.0 +python_stdnum==1.18 diff --git a/src/setup_payload/tests/run_python_setup_payload_gen_test.py b/src/setup_payload/tests/run_python_setup_payload_gen_test.py new file mode 100644 index 00000000000000..ae8b3a14e53ca3 --- /dev/null +++ b/src/setup_payload/tests/run_python_setup_payload_gen_test.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2023 Project CHIP Authors +# +# 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 os +import random +import re +import subprocess +import sys + +CHIP_TOPDIR = os.path.dirname(os.path.realpath(__file__))[:-len(os.path.join('src', 'setup_payload', 'tests'))] +sys.path.insert(0, os.path.join(CHIP_TOPDIR, 'src', 'setup_payload', 'python')) +from generate_setup_payload import INVALID_PASSCODES, CommissioningFlow, SetupPayload # noqa: E402 + + +def payload_param_dict(): + return { + 'Version': None, + 'VendorID': None, + 'ProductID': None, + 'Custom flow': None, + 'Discovery Bitmask': None, + 'Short discriminator': None, + 'Long discriminator': None, + 'Passcode': None + } + + +def remove_escape_sequence(data): + ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') + result = ansi_escape.sub('', data) + return result + + +def parse_setup_payload(chip_tool, payload): + cmd_args = [chip_tool, 'payload', 'parse-setup-payload', payload] + data = subprocess.check_output(cmd_args).decode('utf-8') + data = remove_escape_sequence(data) + parsed_params = payload_param_dict() + for key in parsed_params: + k_st = data.find(key) + if k_st == -1: + continue + + # 1 is for ":" + k_end = k_st + len(key) + 1 + + k_nl = data.find('\n', k_end) + parsed_params[key] = data[k_end:k_nl].split()[0] + + return parsed_params + + +def generate_payloads(in_params): + payloads = SetupPayload(in_params['Long discriminator'], in_params['Passcode'], + in_params['Discovery Bitmask'], CommissioningFlow(in_params['Custom flow']), + in_params['VendorID'], in_params['ProductID']) + manualcode = payloads.generate_manualcode() + qrcode = payloads.generate_qrcode() + return manualcode, qrcode + + +def verify_payloads(in_params, manualcode_params, qrcode_params): + assert in_params['Version'] == int(manualcode_params['Version'], 0) + assert in_params['Passcode'] == int(manualcode_params['Passcode'], 0) + assert in_params['Short discriminator'] == int(manualcode_params['Short discriminator'], 0) + if in_params['Custom flow'] != 0: + assert in_params['VendorID'] == int(manualcode_params['VendorID'], 0) + assert in_params['ProductID'] == int(manualcode_params['ProductID'], 0) + + assert in_params['Version'] == int(qrcode_params['Version'], 0) + assert in_params['VendorID'] == int(qrcode_params['VendorID'], 0) + assert in_params['ProductID'] == int(qrcode_params['ProductID'], 0) + assert in_params['Custom flow'] == int(qrcode_params['Custom flow'], 0) + assert in_params['Discovery Bitmask'] == int(qrcode_params['Discovery Bitmask'], 0) + assert in_params['Passcode'] == int(qrcode_params['Passcode'], 0) + assert in_params['Long discriminator'] == int(qrcode_params['Long discriminator'], 0) + + +def get_payload_params(discriminator, passcode, discovery=4, flow=0, vid=0, pid=0, version=0): + p = payload_param_dict() + p['Version'] = version + p['VendorID'] = vid + p['ProductID'] = pid + p['Custom flow'] = flow + p['Discovery Bitmask'] = discovery + p['Long discriminator'] = discriminator + p['Short discriminator'] = discriminator >> 8 + p['Passcode'] = passcode + return p + + +def run_tests(chip_tool): + test_data_set = [ + get_payload_params(3840, 20202021), + get_payload_params(3781, 12349876, flow=1, vid=1, pid=1), + get_payload_params(2310, 23005908, flow=2, vid=0xFFF3, pid=0x8098), + get_payload_params(3091, 43338551, discovery=2, flow=2, vid=0x1123, pid=0x0012), + get_payload_params(80, 54757432, discovery=6, flow=2, vid=0x2345, pid=0x1023), + get_payload_params(174, 81235604, discovery=7, flow=1, vid=0x45, pid=0x10), + ] + + for test_params in test_data_set: + manualcode, qrcode = generate_payloads(test_params) + manualcode_params = parse_setup_payload(chip_tool, manualcode) + qrcode_params = parse_setup_payload(chip_tool, qrcode) + + print("Input parameters:", test_params) + print("Manualcode:", manualcode) + print("QRCode:", qrcode) + print("Manualcode parsed by chip-tool:", manualcode_params) + print("QRCode parsed by chip-tool:", qrcode_params) + print("") + + verify_payloads(test_params, manualcode_params, qrcode_params) + + +def main(): + if len(sys.argv) == 2: + chip_tool = sys.argv[1] + run_tests(chip_tool) + + +if __name__ == '__main__': + main()