├── .coveragerc ├── .flake8 ├── .githooks └── pre-commit ├── .github └── workflows │ ├── lint.yaml │ ├── release.yaml │ ├── test-docs.yaml │ └── test.yaml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── docs ├── _generate_requests_docstrings.py ├── file_management.md ├── image_management.md ├── index.md ├── mcuboot.md ├── os_management.md ├── requests.md ├── settings_management.md ├── shell_management.md ├── statistics_management.md ├── stylesheets │ └── extra.css ├── transport │ ├── ble.md │ ├── serial.md │ ├── transport.md │ └── udp.md ├── user │ └── intercreate.md └── zephyr_management.md ├── dutfirmware ├── .gitignore ├── README.md ├── ble_a_smp_dut.conf ├── ble_b_smp_dut.conf ├── envr-default ├── envr.ps1 ├── image_check.conf ├── mcuboot_serial.conf ├── mcuboot_usb.conf ├── mcuboot_usb.overlay ├── stm32f4_disco_flash_overlay.dts ├── stm32f4_disco_serial_overlay.dts ├── stm32f4_disco_serial_recovery_button_overlay.dts ├── udp_a_smp_dut.conf ├── udp_b_smp_dut.conf ├── usb_a_smp_dut.conf ├── usb_b_smp_dut.conf ├── usb_smp_dut.conf ├── usb_smp_dut_1024_1_1024.conf ├── usb_smp_dut_512_8_4096.conf └── usb_smp_dut_8192_1_8192.conf ├── envr-default ├── envr.ps1 ├── examples ├── README.md ├── __init__.py ├── ble │ ├── README.md │ ├── __init__.py │ ├── helloworld.py │ ├── imagestate.py │ ├── mcumgrparameters.py │ ├── upgrade.py │ └── upload.py ├── duts │ ├── adafruit_feather_nrf52840 │ │ └── ble │ │ │ ├── a_smp_dut.bin │ │ │ ├── a_smp_dut.merged.hex │ │ │ └── b_smp_dut.bin │ ├── mimxrt1060_evkb │ │ └── usb │ │ │ ├── a_smp_dut_8192_1_8192.bin │ │ │ ├── a_smp_dut_8192_1_8192.hex │ │ │ ├── b_smp_dut.bin │ │ │ └── mcuboot.hex │ ├── nrf52840dk_nrf52840 │ │ ├── ble │ │ │ ├── a_smp_dut.bin │ │ │ ├── a_smp_dut.merged.hex │ │ │ ├── a_smp_dut_image_check.merged.hex │ │ │ └── b_smp_dut.bin │ │ └── usb │ │ │ ├── a_smp_dut_1024_1_1024.bin │ │ │ ├── a_smp_dut_1024_1_1024.merged.hex │ │ │ ├── a_smp_dut_128_2_256.bin │ │ │ ├── a_smp_dut_128_2_256.merged.hex │ │ │ ├── a_smp_dut_512_8_4096.bin │ │ │ ├── a_smp_dut_512_8_4096.merged.hex │ │ │ ├── a_smp_dut_8192_1_8192.bin │ │ │ ├── a_smp_dut_8192_1_8192.merged.hex │ │ │ ├── b_smp_dut.bin │ │ │ ├── mcuboot_a_128_8_1024.bin │ │ │ ├── mcuboot_a_128_8_1024.merged.hex │ │ │ └── mcuboot_b_smp_dut.bin │ └── nrf52dk_nrf52832 │ │ └── ble │ │ ├── a_smp_dut.bin │ │ ├── a_smp_dut.merged.hex │ │ └── b_smp_dut.bin ├── udp │ └── helloworld.py └── usb │ ├── __init__.py │ ├── download_file.py │ ├── helloworld.py │ ├── upgrade.py │ └── upload_file.py ├── mkdocs.yaml ├── poetry.lock ├── poetry.toml ├── pyproject.toml ├── smpclient ├── __init__.py ├── exceptions.py ├── extensions │ ├── __init__.py │ └── intercreate.py ├── generics.py ├── mcuboot.py ├── py.typed ├── requests │ ├── __init__.py │ ├── file_management.py │ ├── image_management.py │ ├── os_management.py │ ├── settings_management.py │ ├── shell_management.py │ ├── statistics_management.py │ ├── user │ │ ├── __init__.py │ │ └── intercreate.py │ └── zephyr_management.py └── transport │ ├── __init__.py │ ├── _udp_client.py │ ├── ble.py │ ├── serial.py │ └── udp.py └── tests ├── __init__.py ├── extensions ├── __init__.py └── test_intercreate.py ├── fixtures ├── __init__.py ├── analyze-mcuboot-img.py ├── file_system │ ├── 255_bytes.txt │ └── test.txt └── zephyr-v3.5.0-2795-g28ff83515d │ ├── hello_world.bin │ ├── hello_world.hex │ ├── hello_world.signed.bin │ └── hello_world.signed.hex ├── test_base64.py ├── test_mcuboot_tools.py ├── test_requests.py ├── test_smp_ble_transport.py ├── test_smp_client.py ├── test_smp_serial_transport.py ├── test_smp_udp_transport.py └── test_udp_client.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | */tests/* 4 | 5 | [paths] 6 | source = 7 | smpclient/ 8 | 9 | [report] 10 | fail_under = 84.0 11 | show_missing = True 12 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | exclude = 4 | .venv 5 | .tox 6 | dutfirmware 7 | extend-ignore = 8 | E203 9 | DOC301 -------------------------------------------------------------------------------- /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eo pipefail 4 | 5 | # lint 6 | poetry run black --check . 7 | poetry run isort --check-only . 8 | poetry run flake8 . 9 | poetry run mypy . 10 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Install poetry 18 | run: pipx install poetry 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | cache: "poetry" 23 | - run: pipx inject poetry poetry-dynamic-versioning # https://github.com/python-poetry/poetry/issues/10028 24 | - run: poetry install 25 | 26 | - name: lint 27 | run: | 28 | shopt -s expand_aliases 29 | . ./envr.ps1 30 | lint 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | env: 4 | name: smpclient 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | build: 12 | name: Build distribution 📦 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - run: pipx install poetry 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: "3.x" 24 | cache: "poetry" 25 | 26 | - run: pipx inject poetry poetry-dynamic-versioning # https://github.com/python-poetry/poetry/issues/10028 27 | 28 | - run: poetry install 29 | 30 | - name: Check for dirty state 31 | run: git status --porcelain 32 | 33 | - name: Show pyproject.toml diff 34 | run: git diff pyproject.toml 35 | 36 | - name: Undo any pyproject.toml changes 37 | run: git restore pyproject.toml 38 | 39 | - run: poetry build 40 | 41 | - name: Store the distribution packages 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: python-package-distributions 45 | path: dist/ 46 | 47 | publish-to-pypi: 48 | name: Publish Python 🐍 distribution 📦 to PyPI 49 | needs: 50 | - build 51 | 52 | runs-on: ubuntu-latest 53 | environment: 54 | name: pypi 55 | url: https://pypi.org/p/${{ env.name }} 56 | permissions: 57 | id-token: write # IMPORTANT: mandatory for trusted publishing 58 | 59 | steps: 60 | - name: Download all the dists 61 | uses: actions/download-artifact@v4 62 | with: 63 | name: python-package-distributions 64 | path: dist/ 65 | - name: Publish distribution 📦 to PyPI 66 | uses: pypa/gh-action-pypi-publish@release/v1 67 | 68 | github-release: 69 | name: >- 70 | Sign the Python 🐍 distribution 📦 with Sigstore 71 | and upload them to GitHub Release 72 | needs: 73 | - publish-to-pypi 74 | runs-on: ubuntu-latest 75 | 76 | permissions: 77 | contents: write # IMPORTANT: mandatory for making GitHub Releases 78 | id-token: write # IMPORTANT: mandatory for sigstore 79 | 80 | steps: 81 | - name: Download all the dists 82 | uses: actions/download-artifact@v4 83 | with: 84 | name: python-package-distributions 85 | path: dist/ 86 | - name: Sign the dists with Sigstore 87 | uses: sigstore/gh-action-sigstore-python@v3.0.0 88 | with: 89 | inputs: >- 90 | ./dist/*.tar.gz 91 | ./dist/*.whl 92 | 93 | publish-docs: 94 | name: Publish documentation 📚 to GitHub Pages 95 | needs: 96 | - github-release 97 | runs-on: ubuntu-latest 98 | 99 | permissions: 100 | contents: write # IMPORTANT: mandatory for deploying to GitHub Pages 101 | 102 | steps: 103 | - uses: actions/checkout@v4 104 | 105 | - run: pipx install poetry 106 | 107 | - name: Set up Python 108 | uses: actions/setup-python@v5 109 | with: 110 | python-version: "3.x" 111 | cache: "poetry" 112 | 113 | - run: poetry install --only doc 114 | 115 | - name: Configure git for gh-pages 116 | run: | 117 | git config --global user.name "SMP Docs Bot" 118 | git config --global user.email "docs@dummy.bot.com" 119 | 120 | - name: Set release version 121 | run: echo "GIT_TAG=${{ github.event.release.tag_name }}" >> $GITHUB_ENV 122 | 123 | - name: Build and deploy documentation 124 | run: | 125 | poetry run python docs/_generate_requests_docstrings.py 126 | poetry run mike deploy --push --update-aliases ${GIT_TAG} latest 127 | -------------------------------------------------------------------------------- /.github/workflows/test-docs.yaml: -------------------------------------------------------------------------------- 1 | name: Test Docs Build 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | build-docs: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Install poetry 15 | run: pipx install poetry 16 | 17 | - uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.x" 20 | cache: "poetry" 21 | 22 | - run: poetry install --only doc 23 | 24 | - name: Build docs 25 | run: | 26 | poetry run python docs/_generate_requests_docstrings.py 27 | poetry run mkdocs build 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | tests: 10 | strategy: 11 | matrix: 12 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 13 | os: [ubuntu-latest, windows-latest, macos-latest] 14 | runs-on: ${{ matrix.os }} 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - run: pipx install poetry 20 | 21 | - name: Setup Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | cache: "poetry" 26 | 27 | - run: pipx inject poetry poetry-dynamic-versioning # https://github.com/python-poetry/poetry/issues/10028 28 | 29 | - run: poetry install 30 | 31 | - name: Test (Linux or MacOS) 32 | if: matrix.os == 'ubuntu-latest' || matrix.os == 'macos-latest' 33 | run: | 34 | shopt -s expand_aliases 35 | . ./envr.ps1 36 | test 37 | 38 | - name: Test (Windows) 39 | if: matrix.os == 'windows-latest' 40 | run: | 41 | . ./envr.ps1 42 | test 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | __pycache__ 3 | .coverage 4 | .mypy_cache 5 | .pytest_cache 6 | dist 7 | .tox 8 | site 9 | .poetry 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-python.black-formatter", 4 | "ms-python.isort", 5 | "ms-python.flake8", 6 | "ms-python.mypy-type-checker", 7 | "ms-python.python" 8 | ] 9 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "flake8.args": ["--config=.flake8"], 3 | "[python]": { 4 | "diffEditor.ignoreTrimWhitespace": false, 5 | "editor.formatOnType": true, 6 | "editor.wordBasedSuggestions": "off", 7 | "editor.defaultFormatter": "ms-python.black-formatter", 8 | "editor.formatOnSave": true, 9 | "editor.codeActionsOnSave": { 10 | "source.organizeImports": "explicit" 11 | }, 12 | }, 13 | "python.testing.pytestArgs": [ 14 | "tests" 15 | ], 16 | "python.testing.unittestEnabled": false, 17 | "python.testing.pytestEnabled": true, 18 | } 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 J.P. Hutchins 190 | Copyright 2023 Intercreate, Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Management Protocol (SMP) Client 2 | 3 | `smpclient` implements the transport layer of the Simple Management Protocol. This library can be 4 | used as a dependency in applications that use SMP over **serial (UART or USB)**, **Bluetooth (BLE)**, 5 | or **UDP** connections. Some abstractions are provided for common routines like upgrading device 6 | firmware. 7 | 8 | If you don't need a library with the transport layer implemented, then you might prefer to use 9 | [smp](https://github.com/JPHutchins/smp) instead. The SMP specification can be found 10 | [here](https://docs.zephyrproject.org/latest/services/device_mgmt/smp_protocol.html). 11 | 12 | If you'd like an SMP CLI application instead of a library, then you should try 13 | [smpmgr](https://github.com/intercreate/smpmgr). 14 | 15 | ## Install 16 | 17 | `smpclient` is [distributed by PyPI](https://pypi.org/project/smpclient/) and can be installed with `poetry`, `pip`, and other dependency managers. 18 | 19 | ## User Documentation 20 | 21 | Documentation is in the source code so that it is available to your editor. 22 | An online version is generated and available [here](https://intercreate.github.io/smpclient/). 23 | 24 | ## Development Quickstart 25 | 26 | > Assumes that you've already [setup your development environment](#development-environment-setup). 27 | 28 | 1. activate [envr](https://github.com/JPhutchins/envr), the environment manager for **bash**, **zsh**, and **PS**: 29 | ``` 30 | . ./envr.ps1 31 | ``` 32 | 2. run `poetry install` when pulling in new changes 33 | 3. run `lint` after making changes 34 | 4. run `test` after making changes 35 | 5. add library dependencies with `poetry`: 36 | ``` 37 | poetry add 38 | ``` 39 | 6. add test or other development dependencies using [poetry groups](https://python-poetry.org/docs/managing-dependencies#dependency-groups): 40 | ``` 41 | poetry add -G dev 42 | ``` 43 | 7. run tests for all supported python versions: 44 | ``` 45 | tox 46 | ``` 47 | 48 | ## Development Environment Setup 49 | 50 | ### Install Dependencies 51 | 52 | - poetry: https://python-poetry.org/docs/#installation 53 | 54 | ### Create the venv 55 | 56 | ``` 57 | poetry install 58 | ``` 59 | 60 | The `venv` should be installed to `.venv`. 61 | 62 | ### Activate envr 63 | 64 | > [envr](https://github.com/JPhutchins/envr) supports **bash**, **zsh**, and **PS** in Linux, MacOS, and Windows. If you are using an unsupported shell, you can activate the `.venv` environment manually, use `poetry run` and `poetry shell`, and refer to `envr-default` for useful aliases. 65 | 66 | ``` 67 | . ./envr.ps1 68 | ``` 69 | 70 | ### Verify Your Setup 71 | 72 | To verify the installation, make sure that all of the tests are passing using these envr aliases: 73 | 74 | ``` 75 | lint 76 | test 77 | ``` 78 | 79 | ### Enable the githooks 80 | 81 | > The pre-commit hook will run the linters but not the unit tests. 82 | 83 | ``` 84 | git config core.hooksPath .githooks 85 | ``` 86 | -------------------------------------------------------------------------------- /docs/_generate_requests_docstrings.py: -------------------------------------------------------------------------------- 1 | """This file iterates through all of the python files in the smpclient/requests 2 | directory and changes them. It will import the classes and check if they have 3 | a docstring or not. If they do not have a docstring, it will get the docstring 4 | from the parent class, add it to the class, and rewrite the file. 5 | 6 | This is solely for generating documentation with mkdocs. 7 | 8 | It is wrangled LLM code and should be replaced ASAP. 9 | """ 10 | from __future__ import annotations 11 | 12 | import ast 13 | import importlib.util 14 | import inspect 15 | import os 16 | from typing import Any, List, Optional, Type 17 | 18 | from pydantic import BaseModel 19 | 20 | 21 | class ClassInfo: 22 | def __init__(self, name: str, lineno: int, col_offset: int, original_text: str): 23 | self.name = name 24 | self.lineno = lineno 25 | self.col_offset = col_offset 26 | self.original_text = original_text 27 | self.docstring: Optional[str] = None 28 | 29 | def add_docstring(self, docstring: str) -> None: 30 | """Add a docstring to the class.""" 31 | indent = ' ' * (self.col_offset + 4) 32 | formatted_docstring = format_docstring(docstring, indent) 33 | self.docstring = formatted_docstring 34 | 35 | def get_updated_text(self) -> str: 36 | """Get the updated class text with the new docstring.""" 37 | if self.docstring: 38 | lines = self.original_text.split('\n') 39 | lines.insert(1, self.docstring) 40 | return '\n'.join(lines) 41 | return self.original_text 42 | 43 | 44 | def format_docstring(docstring: str, indent: str) -> str: 45 | """Format the docstring with the correct indentation.""" 46 | lines = docstring.split('\n') 47 | indented_lines = [f'{indent}"""{lines[0]}\n'] 48 | indented_lines += [f'{line}' for line in lines[1:]] 49 | indented_lines.append(f'{indent}"""') 50 | return '\n'.join(indented_lines) 51 | 52 | 53 | def get_docstring_from_parent(cls: type) -> Optional[str]: 54 | """Get the docstring from the parent class.""" 55 | for base in cls.__bases__: 56 | if base.__doc__: 57 | return base.__doc__ 58 | return None 59 | 60 | 61 | def get_field_docstring(cls: Type[BaseModel], field_name: str) -> str: 62 | """Get the docstring of a field from the class.""" 63 | for name, obj in inspect.getmembers(cls): 64 | if name == field_name: 65 | return obj.__doc__ or "No docstring provided." 66 | return "No docstring found." 67 | 68 | 69 | def format_type(annotation: Type[Any] | None) -> str: 70 | """Format the type to show module and class name.""" 71 | if annotation is None: 72 | raise ValueError("Annotation cannot be None") 73 | if hasattr(annotation, '__name__'): # Handles regular types like `int`, `str`, etc. 74 | # get the annotations like List[str] for example 75 | if hasattr(annotation, '__args__'): 76 | return f"{annotation.__name__}[{format_type(annotation.__args__[0])}]" 77 | return f"{annotation.__name__}" 78 | elif hasattr(annotation, '__origin__'): # Handles generic types like List[str], Optional[int] 79 | return f"{annotation.__origin__.__module__}.{annotation.__origin__.__name__}" 80 | return str(annotation) # Fallback for other types 81 | 82 | 83 | def get_pydantic_fields(cls: Type[BaseModel]) -> str: 84 | """Get the fields of a Pydantic model and format them as Google-style Args.""" 85 | if not issubclass(cls, BaseModel): 86 | return "" 87 | 88 | fields = cls.model_fields 89 | args = "\n Args:\n" 90 | for field_name, field_info in fields.items(): 91 | if field_name in ("header, version, sequence, smp_data"): 92 | continue 93 | field_type = format_type(field_info.annotation) 94 | 95 | # split the field_info.description by newlines and join them with a newline 96 | # and 12 spaces, removing blank lines 97 | description = ( 98 | "\n ".join( 99 | filter(lambda x: x.strip() != "", field_info.description.split("\n")) 100 | ) 101 | if field_info.description 102 | else "" 103 | ) 104 | 105 | args += f" {field_name} ({field_type}): {description}\n" 106 | if args.endswith("Args:\n"): 107 | return "" 108 | return args 109 | 110 | 111 | def parse_file(file_path: str) -> List[ClassInfo]: 112 | """Parse the file and extract class definitions.""" 113 | with open(file_path, 'r') as file: 114 | lines = file.readlines() 115 | tree = ast.parse(''.join(lines)) 116 | 117 | classes = [] 118 | for node in ast.walk(tree): 119 | if isinstance(node, ast.ClassDef): 120 | class_name = node.name 121 | lineno = node.lineno - 1 122 | col_offset = node.col_offset 123 | class_body = lines[lineno : lineno + len(node.body) + 1] 124 | original_text = ''.join(class_body) 125 | classes.append(ClassInfo(class_name, lineno, col_offset, original_text)) 126 | return classes 127 | 128 | 129 | def update_class_docstrings(file_path: str) -> None: 130 | """Update class docstrings in a given file.""" 131 | classes = parse_file(file_path) 132 | module_name = file_path.replace('/', '.').replace('.py', '') 133 | spec = importlib.util.spec_from_file_location(module_name, file_path) 134 | if spec is None: 135 | raise ValueError(f"Could not find spec for {module_name}") 136 | module = importlib.util.module_from_spec(spec) 137 | if spec.loader is None: 138 | raise ValueError(f"Could not find loader for {module_name}") 139 | spec.loader.exec_module(module) 140 | 141 | for class_info in classes: 142 | cls = getattr(module, class_info.name) 143 | if not cls.__doc__: 144 | parent_docstring = get_docstring_from_parent(cls) 145 | if parent_docstring: 146 | args_section = get_pydantic_fields(cls) 147 | full_docstring = parent_docstring + args_section 148 | class_info.add_docstring(full_docstring) 149 | 150 | with open(file_path, 'r', encoding="utf-8") as file: 151 | lines = file.readlines() 152 | 153 | updated_lines = [] 154 | class_index = 0 155 | for i, line in enumerate(lines): 156 | if class_index < len(classes) and i == classes[class_index].lineno: 157 | updated_lines.append(classes[class_index].get_updated_text()) 158 | class_index += 1 159 | else: 160 | updated_lines.append(line) 161 | 162 | with open(file_path, 'w', encoding="utf-8") as file: 163 | file.writelines(updated_lines) 164 | 165 | 166 | def main() -> None: 167 | directory = 'smpclient/requests' 168 | for root, _, files in os.walk(directory): 169 | for file in files: 170 | if file.endswith('.py'): 171 | file_path = os.path.join(root, file) 172 | update_class_docstrings(file_path) 173 | 174 | 175 | if __name__ == '__main__': 176 | main() 177 | -------------------------------------------------------------------------------- /docs/file_management.md: -------------------------------------------------------------------------------- 1 | # File Management 2 | 3 | Refer to the [smp File Management documentation](https://jphutchins.github.io/smp/latest/file_management/) 4 | for a complete description of each Request and Response. 5 | 6 | ::: smpclient.requests.file_management -------------------------------------------------------------------------------- /docs/image_management.md: -------------------------------------------------------------------------------- 1 | # Image Management 2 | 3 | Refer to the [smp Image Management documentation](https://jphutchins.github.io/smp/latest/image_management/) 4 | for a complete description of each Request and Response. 5 | 6 | ::: smpclient.requests.image_management -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Simple Management Protocol (SMP) Client 2 | 3 | ::: smpclient -------------------------------------------------------------------------------- /docs/mcuboot.md: -------------------------------------------------------------------------------- 1 | # MCUBoot 2 | 3 | ::: smpclient.mcuboot -------------------------------------------------------------------------------- /docs/os_management.md: -------------------------------------------------------------------------------- 1 | # OS Management 2 | 3 | Refer to the [smp OS Management documentation](https://jphutchins.github.io/smp/latest/os_management/) 4 | for a complete description of each Request and Response. 5 | 6 | ::: smpclient.requests.os_management -------------------------------------------------------------------------------- /docs/requests.md: -------------------------------------------------------------------------------- 1 | # Requests Helpers 2 | 3 | Refer to the [smp documentation](https://jphutchins.github.io/smp/latest/) for a 4 | complete description of each SMP Request and Response. 5 | 6 | ::: smpclient.generics 7 | -------------------------------------------------------------------------------- /docs/settings_management.md: -------------------------------------------------------------------------------- 1 | # Settings Management 2 | 3 | Refer to the [smp Settings Management documentation](https://jphutchins.github.io/smp/latest/settings_management/) 4 | for a complete description of each Request and Response. 5 | 6 | ::: smpclient.requests.settings_management -------------------------------------------------------------------------------- /docs/shell_management.md: -------------------------------------------------------------------------------- 1 | # Shell Management 2 | 3 | Refer to the [smp Shell Management documentation](https://jphutchins.github.io/smp/latest/shell_management/) 4 | for a complete description of each Request and Response. 5 | 6 | ::: smpclient.requests.shell_management -------------------------------------------------------------------------------- /docs/statistics_management.md: -------------------------------------------------------------------------------- 1 | # Statistics Management 2 | 3 | Refer to the [smp Statistics Management documentation](https://jphutchins.github.io/smp/latest/statistics_management/) 4 | for a complete description of each Request and Response. 5 | 6 | ::: smpclient.requests.statistics_management -------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap'); 2 | 3 | :root { 4 | --md-default-bg-color: #f6e8d9; 5 | --md-primary-fg-color: #80A69C; 6 | --md-accent-fg-color: #80A69C; 7 | --ic-dark-teal: #80A69C; 8 | --ic-dark-lime: #BAC452; 9 | --ic-light-lime: #b8c440; 10 | --ic-extra-dark-lime: #939C04; 11 | --ic-light-teal: #80A69C; 12 | --ic-beige: #F6E8D9; 13 | --cream: #f6e8d9; 14 | --black: black; 15 | --white: white; 16 | --grey: grey; 17 | --marin: #93bcb3; 18 | --blue: #9bbdd5; 19 | --purple: #c2aebe; 20 | --yelow: #b8c440; 21 | --pink: #d9b6a3; 22 | } 23 | 24 | .md-header { 25 | color: #000000; 26 | background-color: var(--marin); 27 | font-family: 'Montserrat', sans-serif; 28 | } 29 | 30 | .md-header .md-header__topic { 31 | color: #000000; 32 | font-weight: 700; 33 | } 34 | 35 | .md-typeset h1 { 36 | color: var(--ic-extra-dark-lime); 37 | font-family: 'DM Serif Display', serif; 38 | font-weight: 400; 39 | font-size: 42px; 40 | } 41 | 42 | .md-nav__title { 43 | color: #000000; 44 | } 45 | -------------------------------------------------------------------------------- /docs/transport/ble.md: -------------------------------------------------------------------------------- 1 | # Bluetooth Low Energy (BLE) 2 | 3 | ::: smpclient.transport.ble -------------------------------------------------------------------------------- /docs/transport/serial.md: -------------------------------------------------------------------------------- 1 | # Serial 2 | 3 | ::: smpclient.transport.serial -------------------------------------------------------------------------------- /docs/transport/transport.md: -------------------------------------------------------------------------------- 1 | # SMP Transport 2 | 3 | ::: smpclient.transport -------------------------------------------------------------------------------- /docs/transport/udp.md: -------------------------------------------------------------------------------- 1 | # UDP 2 | 3 | ::: smpclient.transport.udp -------------------------------------------------------------------------------- /docs/user/intercreate.md: -------------------------------------------------------------------------------- 1 | # Intercreate 2 | 3 | Refer to the [smp Intercreate documentation](https://jphutchins.github.io/smp/latest/user/intercreate) 4 | for a complete description of each Request and Response. 5 | 6 | ::: smpclient.requests.user.intercreate -------------------------------------------------------------------------------- /docs/zephyr_management.md: -------------------------------------------------------------------------------- 1 | # Zephyr Management 2 | 3 | Refer to the [smp Zephyr Management documentation](https://jphutchins.github.io/smp/latest/zephyr_management/) 4 | for a complete description of each Request and Response. 5 | 6 | ::: smpclient.requests.zephyr_management -------------------------------------------------------------------------------- /dutfirmware/.gitignore: -------------------------------------------------------------------------------- 1 | nrf 2 | zephyr 3 | bootloader 4 | modules 5 | nrfxlib 6 | test 7 | tools 8 | build 9 | .west 10 | envr-local 11 | -------------------------------------------------------------------------------- /dutfirmware/README.md: -------------------------------------------------------------------------------- 1 | # Generate DUT Firmware for testing SMP 2 | 3 | All commands should be run from this folder. 4 | 5 | ## Setup 6 | 7 | > This is not a tutorial on Zephyr environments or build systems! 8 | 9 | Create the `venv`: 10 | ``` 11 | python -m venv .venv 12 | ``` 13 | 14 | Activate the environment (in `dutfirmware/`): 15 | ``` 16 | . ./envr.ps1 17 | ``` 18 | 19 | Install `west`: 20 | ``` 21 | pip install west 22 | ``` 23 | 24 | Initialize `west`: 25 | * Zephyr main: 26 | ``` 27 | west init . 28 | ``` 29 | * Or use the NRF SDK fork and manifest, for example: 30 | ``` 31 | west init -m https://github.com/nrfconnect/sdk-nrf --mr v2.6.0 32 | ``` 33 | 34 | Install Zephyr dependencies: 35 | ``` 36 | west update 37 | ``` 38 | 39 | Install Python dependencies: 40 | ``` 41 | pip install -r zephyr/scripts/requirements.txt 42 | ``` 43 | 44 | Configure west to create new build folders for each board: 45 | ``` 46 | west config build.dir-fmt "build/{board}" 47 | ``` 48 | 49 | ## Usage 50 | 51 | Activate the environment (in `dutfirmware/`): 52 | ``` 53 | . ./envr.ps1 54 | ``` 55 | 56 | ### Nordic 57 | 58 | > Note: documented from NRF Connect v2.6.0 which is pre Zephyr 3.7.0 59 | 60 | Build some FW, for example: 61 | ``` 62 | west build -b nrf52dk_nrf52832 zephyr/samples/subsys/mgmt/mcumgr/smp_svr -- -DEXTRA_CONF_FILE="overlay-bt.conf;${ENVR_ROOT}/ble_a_smp_dut.conf" 63 | ``` 64 | 65 | Flash that FW, for example: 66 | ``` 67 | west flash -d build/nrf52dk_nrf52832 --recover 68 | ``` 69 | 70 | Or, for USB CDC ACM: 71 | ``` 72 | west build -b adafruit_feather_nrf52840 zephyr/samples/subsys/mgmt/mcumgr/smp_svr -- -DEXTRA_CONF_FILE="overlay-cdc.conf" -DEXTRA_DTC_OVERLAY_FILE="usb.overlay" 73 | ``` 74 | 75 | Fast USB CDC ACM: 76 | ``` 77 | west build -b nrf52840dk_nrf52840 zephyr/samples/subsys/mgmt/mcumgr/smp_svr -- -DEXTRA_CONF_FILE="overlay-cdc.conf;${ENVR_ROOT}/usb_a_smp_dut.conf;${ENVR_ROOT}/usb_smp_dut_512_8_4096.conf" -DEXTRA_DTC_OVERLAY_FILE="usb.overlay" 78 | ``` 79 | 80 | MCUBoot configuration with SMP USB DFU. USB PID will be 0x000C in bootloader. 81 | ``` 82 | west build -b nrf52840dk_nrf52840 zephyr/samples/subsys/mgmt/mcumgr/smp_svr -- -DEXTRA_CONF_FILE="overlay-bt.conf;overlay-cdc.conf;${ENVR_ROOT}/usb_a_smp_dut.conf" -DEXTRA_DTC_OVERLAY_FILE="usb.overlay" -Dmcuboot_CONF_FILE="../../../../mcuboot_usb.conf" -Dmcuboot_DTS_FILE="../../../../mcuboot_usb.overlay" 83 | ``` 84 | 85 | ### NXP 86 | 87 | #### MIMXRT1060-EVKB 88 | 89 | > Note: documented on Zephyr v3.7.0-rc3, commit `52a9e7014a70916041ffef4a3549448907578343` 90 | 91 | > Note: I installed LinkServer but would rather use JLink. Also, NXP is silly 92 | > AF and installs to C: 🙄 93 | 94 | Create bootloader: 95 | ``` 96 | west build -b mimxrt1060_evkb -d build/mimxrt1060_evkb_mcuboot bootloader/mcuboot/boot/zephyr -- -DCONFIG_BUILD_OUTPUT_HEX=y 97 | ``` 98 | 99 | Flash bootloader: 100 | > Note: your board will be erased 101 | ``` 102 | west flash --runner=linkserver -d build/mimxrt1060_evkb_mcuboot 103 | ``` 104 | 105 | Create FW for USB CDC ACM SMP server: 106 | > Note: this generates the firmware "A" found in `examples/duts/mimxrt1060_evkb/usb/a_smp_dut_8192_1_8192.hex 107 | ``` 108 | west build -b mimxrt1060_evkb zephyr/samples/subsys/mgmt/mcumgr/smp_svr -- -DEXTRA_CONF_FILE="overlay-cdc.conf;${ENVR_ROOT}/usb_a_smp_dut.conf;${ENVR_ROOT}/usb_smp_dut_8192_1_8192.conf" -DEXTRA_DTC_OVERLAY_FILE="usb.overlay" -DCONFIG_BUILD_OUTPUT_HEX=y 109 | ``` 110 | 111 | Create FW for UDP (ethernet) SMP server: 112 | ``` 113 | west build -b mimxrt1060_evkb zephyr/samples/subsys/mgmt/mcumgr/smp_svr -- -DEXTRA_CONF_FILE="overlay-udp.conf;${ENVR_ROOT}/udp_a_smp_dut.conf" -DCONFIG_BUILD_OUTPUT_HEX=y 114 | ``` 115 | 116 | Flash signed app: 117 | ``` 118 | west flash --runner=linkserver -d build/mimxrt1060_evkb 119 | ``` 120 | 121 | For convenience, you could merge the bootloader and app: 122 | > Note: this merged.hex isn't working and I don't see why not! 123 | ``` 124 | python zephyr/scripts/build/mergehex.py --output a_smp_dut_8192_1_8192.merged.hex build/mimxrt1060_evkb_mcuboot/zephyr/zephyr.hex build/mimxrt1060_evkb/zephyr/zephyr.signed.hex 125 | ``` 126 | And then you only have to flash once: 127 | ``` 128 | west flash --runner=linkserver -d build/mimxrt1060_evkb --hex-file a_smp_dut_8192_1_8192.merged.hex 129 | ``` 130 | 131 | ### ST 132 | 133 | #### stm32f4_disco 134 | 135 | > Note: documented on Zephyr `v3.7.0-1987-g1540bd7d` 136 | 137 | Create bootloader with a serial recovery button that can update FW over serial: 138 | 139 | ``` 140 | west build -b stm32f4_disco -d build/stm32f4_disco_mcuboot bootloader/mcuboot/boot/zephyr -- -DCONFIG_BUILD_OUTPUT_HEX=y -DEXTRA_DTC_OVERLAY_FILE="${ENVR_ROOT}/stm32f4_disco_flash_overlay.dts;${ENVR_ROOT}/stm32f4_disco_serial_overlay.dts;${ENVR_ROOT}/stm32f4_disco_serial_recovery_button_overlay.dts" -DEXTRA_CONF_FILE="${ENVR_ROOT}/mcuboot_serial.conf" 141 | ``` 142 | 143 | -------------------------------------------------------------------------------- /dutfirmware/ble_a_smp_dut.conf: -------------------------------------------------------------------------------- 1 | CONFIG_BT_DEVICE_NAME="A SMP DUT" 2 | -------------------------------------------------------------------------------- /dutfirmware/ble_b_smp_dut.conf: -------------------------------------------------------------------------------- 1 | CONFIG_BT_DEVICE_NAME="B SMP DUT" 2 | -------------------------------------------------------------------------------- /dutfirmware/envr-default: -------------------------------------------------------------------------------- 1 | [PROJECT_OPTIONS] 2 | PROJECT_NAME=smpdut 3 | PYTHON_VENV=.venv 4 | 5 | [VARIABLES] 6 | 7 | [ADD_TO_PATH] 8 | # override here or in envr-local 9 | JLINK=C:/Program Files/SEGGER/JLink 10 | LINK_SERVER=C:/NXP/LinkServer_1.6.133 11 | OPENOCD=$HOME/.local/xpack-openocd-0.12.0-4/bin 12 | 13 | [ALIASES] -------------------------------------------------------------------------------- /dutfirmware/image_check.conf: -------------------------------------------------------------------------------- 1 | CONFIG_IMG_ENABLE_IMAGE_CHECK=y 2 | -------------------------------------------------------------------------------- /dutfirmware/mcuboot_serial.conf: -------------------------------------------------------------------------------- 1 | CONFIG_MAIN_STACK_SIZE=32768 2 | 3 | # Enable SMP serial DFU 4 | CONFIG_CONSOLE=n 5 | CONFIG_LOG=n 6 | CONFIG_UART_CONSOLE=n 7 | CONFIG_SERIAL=y 8 | CONFIG_UART_LINE_CTRL=y 9 | CONFIG_MCUBOOT_SERIAL=y 10 | CONFIG_BOOT_MGMT_ECHO=y 11 | CONFIG_BOOT_SERIAL_IMG_GRP_HASH=y 12 | CONFIG_BOOT_SERIAL_IMG_GRP_IMAGE_STATE=y 13 | 14 | # Allow upload to the primary slot 15 | CONFIG_MCUBOOT_SERIAL_DIRECT_IMAGE_UPLOAD=y 16 | 17 | # Serial Recovery 18 | CONFIG_BOOT_SERIAL_NO_APPLICATION=y 19 | 20 | # # Measured ~7.1 kB/s on 115200 baud (STM32F407 Discovery) 21 | CONFIG_BOOT_MAX_LINE_INPUT_LEN=4096 22 | CONFIG_BOOT_LINE_BUFS=2 23 | CONFIG_BOOT_SERIAL_MAX_RECEIVE_SIZE=4096 24 | -------------------------------------------------------------------------------- /dutfirmware/mcuboot_usb.conf: -------------------------------------------------------------------------------- 1 | # More RAM 2 | CONFIG_MAIN_STACK_SIZE=20480 3 | CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=20480 4 | 5 | # Enable flash operations 6 | CONFIG_FLASH=y 7 | CONFIG_NORDIC_QSPI_NOR=n 8 | CONFIG_SPI_NOR_FLASH_LAYOUT_PAGE_SIZE=4096 9 | 10 | # This must be increased to accommodate the bigger images. 11 | CONFIG_BOOT_MAX_IMG_SECTORS=256 12 | 13 | # Should be default for Nordic, set anyway 14 | CONFIG_BOOT_PREFER_SWAP_MOVE=y 15 | 16 | # Disable console (you could reenable it on a different UART interface) 17 | CONFIG_CONSOLE=n 18 | 19 | # Flash footprint savings 20 | CONFIG_ASSERT=n 21 | CONFIG_BOOT_BANNER=n 22 | CONFIG_LOG=n 23 | CONFIG_UART_CONSOLE=n 24 | CONFIG_STDOUT_CONSOLE=n 25 | CONFIG_PRINTK=n 26 | CONFIG_EARLY_CONSOLE=n 27 | CONFIG_SIZE_OPTIMIZATIONS=y 28 | 29 | # Bigger MCUBoot partition to fit USB stack, SMP, crypto, etc... 30 | # better to use DTS (or pm_static.yaml) 31 | CONFIG_PM_PARTITION_SIZE_MCUBOOT=0x10800 32 | 33 | # Enable SMP USB CDC ACM DFU 34 | CONFIG_MCUBOOT_SERIAL=y 35 | CONFIG_BOOT_MGMT_ECHO=y 36 | CONFIG_BOOT_SERIAL_IMG_GRP_HASH=y 37 | CONFIG_BOOT_SERIAL_IMG_GRP_IMAGE_STATE=y 38 | CONFIG_BOOT_SERIAL_CDC_ACM=y 39 | 40 | # Allow upload to the primary slot 41 | CONFIG_MCUBOOT_SERIAL_DIRECT_IMAGE_UPLOAD=y 42 | 43 | # Fast USB CDC ACM 44 | # CONFIG_BOOT_MAX_LINE_INPUT_LEN=4096 45 | # CONFIG_BOOT_LINE_BUFS=2 46 | # CONFIG_BOOT_SERIAL_MAX_RECEIVE_SIZE=8192 47 | 48 | # Enable USB 49 | CONFIG_USB_DEVICE_STACK=y 50 | CONFIG_SERIAL=y 51 | CONFIG_UART_LINE_CTRL=y 52 | CONFIG_USB_DEVICE_INITIALIZE_AT_BOOT=n 53 | 54 | # Serial Recovery 55 | CONFIG_BOOT_SERIAL_NO_APPLICATION=y 56 | 57 | # Enter DFU mode on every boot, with timeout 58 | CONFIG_BOOT_SERIAL_WAIT_FOR_DFU=y 59 | # USB must enumerate! 60 | CONFIG_BOOT_SERIAL_WAIT_FOR_DFU_TIMEOUT=2000 61 | 62 | # PID 0x000C to make it easy for us to find device in MCUBoot 63 | CONFIG_USB_DEVICE_PID=0x000C 64 | -------------------------------------------------------------------------------- /dutfirmware/mcuboot_usb.overlay: -------------------------------------------------------------------------------- 1 | / { 2 | chosen { 3 | zephyr,uart-mcumgr = &cdc_acm_uart0; 4 | }; 5 | }; 6 | 7 | &zephyr_udc0 { 8 | cdc_acm_uart0: cdc_acm_uart0 { 9 | compatible = "zephyr,cdc-acm-uart"; 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /dutfirmware/stm32f4_disco_flash_overlay.dts: -------------------------------------------------------------------------------- 1 | &flash0 { 2 | partitions { 3 | compatible = "fixed-partitions"; 4 | #address-cells = <1>; 5 | #size-cells = <1>; 6 | 7 | boot_partition: partition@0 { 8 | label = "mcuboot"; 9 | reg = <0x00000000 0x10000>; 10 | }; 11 | slot0_partition: partition@10000 { 12 | label = "image-0"; 13 | reg = <0x00010000 0x40000>; 14 | }; 15 | slot1_partition: partition@50000 { 16 | label = "image-1"; 17 | reg = <0x00050000 0x40000>; 18 | }; 19 | storage_partition: partition@7e000 { 20 | label = "storage"; 21 | reg = <0x0007e000 DT_SIZE_K(8)>; 22 | }; 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /dutfirmware/stm32f4_disco_serial_overlay.dts: -------------------------------------------------------------------------------- 1 | / { 2 | chosen { 3 | zephyr,uart-mcumgr = &usart2; 4 | }; 5 | }; 6 | -------------------------------------------------------------------------------- /dutfirmware/stm32f4_disco_serial_recovery_button_overlay.dts: -------------------------------------------------------------------------------- 1 | / { 2 | aliases { 3 | mcuboot-button0 = &user_button; 4 | }; 5 | }; 6 | -------------------------------------------------------------------------------- /dutfirmware/udp_a_smp_dut.conf: -------------------------------------------------------------------------------- 1 | CONFIG_NET_CONFIG_MY_IPV4_ADDR="10.0.1.210" 2 | CONFIG_MCUBOOT_SIGNATURE_KEY_FILE="bootloader/mcuboot/root-rsa-2048.pem" 3 | CONFIG_MCUMGR_GRP_OS_MCUMGR_PARAMS=y 4 | 5 | -------------------------------------------------------------------------------- /dutfirmware/udp_b_smp_dut.conf: -------------------------------------------------------------------------------- 1 | CONFIG_NET_CONFIG_MY_IPV4_ADDR="10.0.1.211" 2 | CONFIG_MCUBOOT_SIGNATURE_KEY_FILE="bootloader/mcuboot/root-rsa-2048.pem" 3 | -------------------------------------------------------------------------------- /dutfirmware/usb_a_smp_dut.conf: -------------------------------------------------------------------------------- 1 | CONFIG_USB_DEVICE_PID=0x000A 2 | CONFIG_MCUBOOT_SIGNATURE_KEY_FILE="bootloader/mcuboot/root-rsa-2048.pem" 3 | -------------------------------------------------------------------------------- /dutfirmware/usb_b_smp_dut.conf: -------------------------------------------------------------------------------- 1 | CONFIG_USB_DEVICE_PID=0x000B 2 | CONFIG_MCUBOOT_SIGNATURE_KEY_FILE="bootloader/mcuboot/root-rsa-2048.pem" 3 | -------------------------------------------------------------------------------- /dutfirmware/usb_smp_dut.conf: -------------------------------------------------------------------------------- 1 | # 9.13 KB/s (using defaults) 2 | # CONFIG_UART_MCUMGR_RX_BUF_SIZE=128 3 | # CONFIG_UART_MCUMGR_RX_BUF_COUNT=2 4 | # CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE=256 5 | -------------------------------------------------------------------------------- /dutfirmware/usb_smp_dut_1024_1_1024.conf: -------------------------------------------------------------------------------- 1 | # ~31.61 KB/s 2 | CONFIG_UART_MCUMGR_RX_BUF_SIZE=1024 3 | CONFIG_UART_MCUMGR_RX_BUF_COUNT=1 4 | CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE=1024 5 | -------------------------------------------------------------------------------- /dutfirmware/usb_smp_dut_512_8_4096.conf: -------------------------------------------------------------------------------- 1 | # ~45.50 KB/s 2 | CONFIG_UART_MCUMGR_RX_BUF_SIZE=512 3 | CONFIG_UART_MCUMGR_RX_BUF_COUNT=8 4 | CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE=4096 5 | -------------------------------------------------------------------------------- /dutfirmware/usb_smp_dut_8192_1_8192.conf: -------------------------------------------------------------------------------- 1 | # ~51.19 KB/s 2 | CONFIG_UART_MCUMGR_RX_BUF_SIZE=8192 3 | CONFIG_UART_MCUMGR_RX_BUF_COUNT=1 4 | CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE=8192 5 | -------------------------------------------------------------------------------- /envr-default: -------------------------------------------------------------------------------- 1 | [PROJECT_OPTIONS] 2 | PROJECT_NAME=smpclient 3 | PYTHON_VENV=.venv 4 | 5 | [VARIABLES] 6 | 7 | [ADD_TO_PATH] 8 | 9 | [ALIASES] 10 | lint=black --check . && isort --check-only . && flake8 . && pydoclint smpclient && mypy . 11 | test=coverage erase && pytest --cov --maxfail=1 -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Example Usage of SMPClient APIs 2 | 3 | Many of the examples assume that there is an SMP server running, so there are 4 | some DUT FW files in the [duts/](/examples/duts/) folder. 5 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/__init__.py -------------------------------------------------------------------------------- /examples/ble/README.md: -------------------------------------------------------------------------------- 1 | # SMPBLETransport examples 2 | 3 | Each of these can be run from the root of the repository: 4 | ``` 5 | python -m examples.ble. 6 | ``` 7 | e.g. 8 | ``` 9 | python -m examples.ble.helloworld 10 | ``` 11 | 12 | If the example scripts require arguments then they should use the `argparse` 13 | module. 14 | 15 | For example, `examples.ble.upgrade` needs to know what board is being upgraded 16 | so that it can get the matching DUT FW. 17 | 18 | ``` 19 | python -m examples.ble.upgrade --help 20 | ``` 21 | 22 | ``` 23 | usage: upgrade.py [-h] board 24 | 25 | Do an SMP DFU test 26 | 27 | positional arguments: 28 | board Name of the board; the "BUT" 29 | 30 | options: 31 | -h, --help show this help message and exit 32 | ``` 33 | ## Upgrade Test 34 | 35 | 1. The `upgrade` script uses the programmer to flash the `merged.hex` (MCUBoot + 36 | app) of the "A" DUT FW. 37 | 2. `smpclient` connects to the DUT and reads the state of images. 38 | 3. `smpclient` uploads the "B" DUT FW, marks it for test, and resets the DUT 39 | 4. `smpclient` waits for swap to completed then confirms that the "B" DUT is 40 | loaded. 41 | 42 | For the existing SMP BLE DUT examples, the only difference between "A" and "B" 43 | is the advertised name. See [dutfirmware/](/dutfirmware/) for DUT FW 44 | configuration. 45 | 46 | When multiple transports are tested, new configurations will be required. 47 | 48 | ### Adafruit Feather nRF52840 49 | 50 | Product: https://www.adafruit.com/product/4062 51 | 52 | > Uses [`nrfjprog`](https://www.nordicsemi.com/Products/Development-tools/nRF-Command-Line-Tools/) 53 | > as flash runner and assumes that it is in PATH 54 | 55 | 1. Power the board via the micro USB port (with or without data, doesn't matter) 56 | 2. Connect a JLink to the board's header 57 | 3. Connect the JLink to your host PC's USB port 58 | 59 | ``` 60 | python -m examples.ble.upgrade adafruit_feather_nrf52840 61 | ``` 62 | 63 | ### nRF52 DK (nRF52832) 64 | 65 | Product: https://www.nordicsemi.com/Products/Development-hardware/nRF52-DK 66 | 67 | > Uses [`nrfjprog`](https://www.nordicsemi.com/Products/Development-tools/nRF-Command-Line-Tools/) 68 | > as flash runner and assumes that it is in PATH 69 | 70 | 1. Connect the board to your PC via the micro USB port (J2) 71 | 2. Set the Power switch to ON 72 | 73 | ``` 74 | python -m examples.ble.upgrade nrf52dk_nrf52832 75 | ``` -------------------------------------------------------------------------------- /examples/ble/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/ble/__init__.py -------------------------------------------------------------------------------- /examples/ble/helloworld.py: -------------------------------------------------------------------------------- 1 | """Echo "Hello, World!" from an SMP server.""" 2 | 3 | import asyncio 4 | from typing import Final 5 | 6 | from smpclient import SMPClient 7 | from smpclient.generics import error, success 8 | from smpclient.requests.os_management import EchoWrite 9 | from smpclient.transport.ble import SMPBLETransport 10 | 11 | 12 | async def main() -> None: 13 | print("Scanning for SMP servers...", end="", flush=True) 14 | smp_servers: Final = await SMPBLETransport.scan() 15 | print("OK") 16 | print(f"Found {len(smp_servers)} SMP servers: {smp_servers}") 17 | 18 | print("Connecting to the first SMP server...", end="", flush=True) 19 | async with SMPClient(SMPBLETransport(), smp_servers[0].address) as client: 20 | print("OK") 21 | 22 | print("Sending request...", end="", flush=True) 23 | response: Final = await client.request(EchoWrite(d="Hello, World!")) 24 | print("OK") 25 | 26 | if success(response): 27 | print(f"Received response: {response}") 28 | elif error(response): 29 | print(f"Received error: {response}") 30 | else: 31 | raise Exception(f"Unknown response: {response}") 32 | 33 | 34 | if __name__ == "__main__": 35 | asyncio.run(main()) 36 | -------------------------------------------------------------------------------- /examples/ble/imagestate.py: -------------------------------------------------------------------------------- 1 | """Echo "Hello, World!" from an SMP server.""" 2 | 3 | import asyncio 4 | from typing import Final 5 | 6 | from smpclient import SMPClient 7 | from smpclient.generics import error, success 8 | from smpclient.requests.image_management import ImageStatesRead 9 | from smpclient.transport.ble import SMPBLETransport 10 | 11 | 12 | async def main() -> None: 13 | print("Scanning for SMP servers...", end="", flush=True) 14 | smp_servers: Final = await SMPBLETransport.scan() 15 | print("OK") 16 | print(f"Found {len(smp_servers)} SMP servers: {smp_servers}") 17 | 18 | print("Connecting to the first SMP server...", end="", flush=True) 19 | async with SMPClient(SMPBLETransport(), smp_servers[0].address) as client: 20 | print("OK") 21 | 22 | print("Sending request...", end="", flush=True) 23 | response: Final = await client.request(ImageStatesRead()) 24 | print("OK") 25 | 26 | if success(response): 27 | print(f"Received response: {response}") 28 | elif error(response): 29 | print(f"Received error: {response}") 30 | else: 31 | raise Exception(f"Unknown response: {response}") 32 | 33 | 34 | if __name__ == "__main__": 35 | asyncio.run(main()) 36 | -------------------------------------------------------------------------------- /examples/ble/mcumgrparameters.py: -------------------------------------------------------------------------------- 1 | """Get the "MCUmgr" (AKA SMP) parameters.""" 2 | 3 | import asyncio 4 | from typing import Final 5 | 6 | from smpclient import SMPClient 7 | from smpclient.generics import error, success 8 | from smpclient.requests.os_management import MCUMgrParametersRead 9 | from smpclient.transport.ble import SMPBLETransport 10 | 11 | 12 | async def main() -> None: 13 | print("Scanning for SMP servers...", end="", flush=True) 14 | smp_servers: Final = await SMPBLETransport.scan() 15 | print("OK") 16 | print(f"Found {len(smp_servers)} SMP servers: {smp_servers}") 17 | 18 | print("Connecting to the first SMP server...", end="", flush=True) 19 | async with SMPClient(SMPBLETransport(), smp_servers[0].address) as client: 20 | print("OK") 21 | print(f"Client MTU is {client._transport.mtu}B") 22 | print(f"Client max unencoded size is {client._transport.max_unencoded_size}B") 23 | 24 | print("Sending request...", end="", flush=True) 25 | response: Final = await client.request(MCUMgrParametersRead()) 26 | print("OK") 27 | 28 | if success(response): 29 | print(f"Received response: {response}") 30 | elif error(response): 31 | print(f"Received error: {response}") 32 | else: 33 | raise Exception(f"Unknown response: {response}") 34 | 35 | 36 | if __name__ == "__main__": 37 | asyncio.run(main()) 38 | -------------------------------------------------------------------------------- /examples/ble/upgrade.py: -------------------------------------------------------------------------------- 1 | """Perform a full DFU routine.""" 2 | 3 | import argparse 4 | import asyncio 5 | import logging 6 | import subprocess 7 | import time 8 | from pathlib import Path 9 | from typing import Final, cast 10 | 11 | from bleak import BleakScanner 12 | from bleak.backends.device import BLEDevice 13 | 14 | from smpclient import SMPClient 15 | from smpclient.generics import SMPRequest, TEr1, TEr2, TRep, error, success 16 | from smpclient.mcuboot import IMAGE_TLV, ImageInfo 17 | from smpclient.requests.image_management import ImageStatesRead, ImageStatesWrite 18 | from smpclient.requests.os_management import ResetWrite 19 | from smpclient.transport.ble import SMPBLETransport 20 | 21 | logging.basicConfig( 22 | format="%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s", 23 | level=logging.INFO, 24 | datefmt="%Y-%m-%d %H:%M:%S", 25 | ) 26 | 27 | 28 | async def main() -> None: 29 | parser = argparse.ArgumentParser(description="Do an SMP DFU test") 30 | parser.add_argument("board", help='Name of the board; the "BUT"') 31 | 32 | dut_folder: Final = Path(__file__).parent.parent / "duts" / parser.parse_args().board / "ble" 33 | print(f"Using DUT folder: {dut_folder}") 34 | merged_hex_path: Final = dut_folder / "a_smp_dut.merged.hex" 35 | print(f"Using merged.hex: {merged_hex_path}") 36 | 37 | print("Flashing the merged.hex...") 38 | assert ( 39 | subprocess.run( 40 | ("nrfjprog", "--recover", "--reset", "--verify", "--program", merged_hex_path) 41 | ).returncode 42 | == 0 43 | ) 44 | 45 | a_smp_dut_hash: Final = ImageInfo.load_file(str(dut_folder / "a_smp_dut.bin")).get_tlv( 46 | IMAGE_TLV.SHA256 47 | ) 48 | print(f"A SMP DUT hash: {a_smp_dut_hash}") 49 | b_smp_dut_hash: Final = ImageInfo.load_file(str(dut_folder / "b_smp_dut.bin")).get_tlv( 50 | IMAGE_TLV.SHA256 51 | ) 52 | print(f"B SMP DUT hash: {b_smp_dut_hash}") 53 | 54 | with open(dut_folder / "b_smp_dut.bin", "rb") as f: 55 | b_smp_dut_bin: Final = f.read() 56 | 57 | print("Searching for A SMP DUT...", end="", flush=True) 58 | a_smp_dut = await BleakScanner.find_device_by_name("A SMP DUT") # type: ignore 59 | if a_smp_dut is None: 60 | print("FAILED") 61 | raise SystemExit("A SMP DUT not found") 62 | a_smp_dut = cast(BLEDevice, a_smp_dut) 63 | 64 | print("OK") 65 | 66 | print("Connecting to A SMP DUT...", end="", flush=True) 67 | async with SMPClient(SMPBLETransport(), a_smp_dut.name or a_smp_dut.address) as client: 68 | print("OK") 69 | 70 | async def ensure_request(request: SMPRequest[TRep, TEr1, TEr2]) -> TRep: 71 | print("Sending request...", end="", flush=True) 72 | response = await client.request(request) 73 | print("OK") 74 | 75 | if success(response): 76 | print(f"Received response: {response}") 77 | return response 78 | elif error(response): 79 | raise Exception(f"Received error: {response}") 80 | else: 81 | raise Exception(f"Unknown response: {response}") 82 | 83 | response = await ensure_request(ImageStatesRead()) 84 | assert response.images[0].hash == a_smp_dut_hash.value 85 | assert response.images[0].slot == 0 86 | 87 | print() 88 | start_s = time.time() 89 | async for offset in client.upload(b_smp_dut_bin): 90 | print( 91 | f"\rUploaded {offset:,} / {len(b_smp_dut_bin):,} Bytes | " 92 | f"{offset / (time.time() - start_s) / 1000:.2f} KB/s ", 93 | end="", 94 | flush=True, 95 | ) 96 | 97 | print() 98 | 99 | response = await ensure_request(ImageStatesRead()) 100 | assert response.images[1].hash == b_smp_dut_hash.value 101 | assert response.images[1].slot == 1 102 | print("Confirmed the upload") 103 | 104 | print() 105 | print("Marking B SMP DUT for test...") 106 | await ensure_request(ImageStatesWrite(hash=response.images[1].hash)) 107 | 108 | print() 109 | print("Resetting for swap...") 110 | await ensure_request(ResetWrite()) 111 | 112 | print() 113 | print("Searching for B SMP DUT...", end="", flush=True) 114 | b_smp_dut = await BleakScanner.find_device_by_name("B SMP DUT", timeout=30) # type: ignore 115 | if b_smp_dut is None: 116 | print("FAIL") 117 | raise SystemExit("A SMP DUT not found") 118 | print("OK") 119 | b_smp_dut = cast(BLEDevice, b_smp_dut) 120 | 121 | print("Connecting to B SMP DUT...", end="", flush=True) 122 | async with SMPClient(SMPBLETransport(), b_smp_dut.name or b_smp_dut.address) as client: 123 | print("OK") 124 | 125 | print() 126 | print("Sending request...", end="", flush=True) 127 | images = await client.request(ImageStatesRead()) 128 | print("OK") 129 | 130 | if success(images): 131 | print(f"Received response: {images}") 132 | # assert the swap - B is in primary, A has been swapped to secondary 133 | assert images.images[0].hash == b_smp_dut_hash.value 134 | assert images.images[0].slot == 0 135 | assert images.images[1].hash == a_smp_dut_hash.value 136 | assert images.images[1].slot == 1 137 | print() 138 | print("Confirmed the swap") 139 | elif error(images): 140 | raise SystemExit(f"Received error: {images}") 141 | else: 142 | raise SystemExit(f"Unknown response: {images}") 143 | 144 | 145 | if __name__ == "__main__": 146 | asyncio.run(main()) 147 | -------------------------------------------------------------------------------- /examples/ble/upload.py: -------------------------------------------------------------------------------- 1 | """Upload some FW.""" 2 | 3 | import argparse 4 | import asyncio 5 | import logging 6 | import time 7 | from typing import Final 8 | 9 | from smpclient import SMPClient 10 | from smpclient.generics import error, success 11 | from smpclient.requests.image_management import ImageStatesRead 12 | from smpclient.transport.ble import SMPBLETransport 13 | 14 | logging.basicConfig( 15 | format="%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s", 16 | level=logging.INFO, 17 | datefmt="%Y-%m-%d %H:%M:%S", 18 | ) 19 | 20 | 21 | async def main() -> None: 22 | parser = argparse.ArgumentParser(description="Upload some FW.") 23 | parser.add_argument("path", help="Path to the FW.") 24 | with open(parser.parse_args().path, "rb") as f: 25 | fw_file: Final = f.read() 26 | 27 | print("Scanning for SMP servers...", end="", flush=True) 28 | smp_servers: Final = await SMPBLETransport.scan() 29 | print("OK") 30 | print(f"Found {len(smp_servers)} SMP servers: {smp_servers}") 31 | 32 | print("Connecting to the first SMP server...", end="", flush=True) 33 | async with SMPClient( 34 | SMPBLETransport(), smp_servers[0].name or smp_servers[0].address 35 | ) as client: 36 | print("OK") 37 | 38 | print("Sending request...", end="", flush=True) 39 | response = await client.request(ImageStatesRead()) 40 | print("OK") 41 | 42 | if success(response): 43 | print(f"Received response: {response}") 44 | elif error(response): 45 | print(f"Received error: {response}") 46 | else: 47 | raise Exception(f"Unknown response: {response}") 48 | 49 | print() 50 | start_s = time.time() 51 | async for offset in client.upload(fw_file, 2): 52 | print( 53 | f"\rUploaded {offset:,} / {len(fw_file):,} Bytes | " 54 | f"{offset / (time.time() - start_s) / 1000:.2f} KB/s ", 55 | end="", 56 | flush=True, 57 | ) 58 | 59 | print() 60 | print("Sending request...", end="", flush=True) 61 | response = await client.request(ImageStatesRead()) 62 | print("OK") 63 | 64 | if success(response): 65 | print(f"Received response: {response}") 66 | elif error(response): 67 | print(f"Received error: {response}") 68 | else: 69 | raise Exception(f"Unknown response: {response}") 70 | 71 | 72 | if __name__ == "__main__": 73 | asyncio.run(main()) 74 | -------------------------------------------------------------------------------- /examples/duts/adafruit_feather_nrf52840/ble/a_smp_dut.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/duts/adafruit_feather_nrf52840/ble/a_smp_dut.bin -------------------------------------------------------------------------------- /examples/duts/adafruit_feather_nrf52840/ble/b_smp_dut.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/duts/adafruit_feather_nrf52840/ble/b_smp_dut.bin -------------------------------------------------------------------------------- /examples/duts/mimxrt1060_evkb/usb/a_smp_dut_8192_1_8192.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/duts/mimxrt1060_evkb/usb/a_smp_dut_8192_1_8192.bin -------------------------------------------------------------------------------- /examples/duts/mimxrt1060_evkb/usb/b_smp_dut.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/duts/mimxrt1060_evkb/usb/b_smp_dut.bin -------------------------------------------------------------------------------- /examples/duts/nrf52840dk_nrf52840/ble/a_smp_dut.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/duts/nrf52840dk_nrf52840/ble/a_smp_dut.bin -------------------------------------------------------------------------------- /examples/duts/nrf52840dk_nrf52840/ble/b_smp_dut.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/duts/nrf52840dk_nrf52840/ble/b_smp_dut.bin -------------------------------------------------------------------------------- /examples/duts/nrf52840dk_nrf52840/usb/a_smp_dut_1024_1_1024.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/duts/nrf52840dk_nrf52840/usb/a_smp_dut_1024_1_1024.bin -------------------------------------------------------------------------------- /examples/duts/nrf52840dk_nrf52840/usb/a_smp_dut_128_2_256.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/duts/nrf52840dk_nrf52840/usb/a_smp_dut_128_2_256.bin -------------------------------------------------------------------------------- /examples/duts/nrf52840dk_nrf52840/usb/a_smp_dut_512_8_4096.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/duts/nrf52840dk_nrf52840/usb/a_smp_dut_512_8_4096.bin -------------------------------------------------------------------------------- /examples/duts/nrf52840dk_nrf52840/usb/a_smp_dut_8192_1_8192.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/duts/nrf52840dk_nrf52840/usb/a_smp_dut_8192_1_8192.bin -------------------------------------------------------------------------------- /examples/duts/nrf52840dk_nrf52840/usb/b_smp_dut.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/duts/nrf52840dk_nrf52840/usb/b_smp_dut.bin -------------------------------------------------------------------------------- /examples/duts/nrf52840dk_nrf52840/usb/mcuboot_a_128_8_1024.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/duts/nrf52840dk_nrf52840/usb/mcuboot_a_128_8_1024.bin -------------------------------------------------------------------------------- /examples/duts/nrf52840dk_nrf52840/usb/mcuboot_b_smp_dut.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/duts/nrf52840dk_nrf52840/usb/mcuboot_b_smp_dut.bin -------------------------------------------------------------------------------- /examples/duts/nrf52dk_nrf52832/ble/a_smp_dut.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/duts/nrf52dk_nrf52832/ble/a_smp_dut.bin -------------------------------------------------------------------------------- /examples/duts/nrf52dk_nrf52832/ble/b_smp_dut.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/duts/nrf52dk_nrf52832/ble/b_smp_dut.bin -------------------------------------------------------------------------------- /examples/udp/helloworld.py: -------------------------------------------------------------------------------- 1 | """Echo "Hello, World!" from an SMP server.""" 2 | 3 | import argparse 4 | import asyncio 5 | import logging 6 | from typing import Final 7 | 8 | from smpclient import SMPClient 9 | from smpclient.generics import error, success 10 | from smpclient.requests.os_management import EchoWrite 11 | from smpclient.transport.udp import SMPUDPTransport 12 | 13 | logging.basicConfig(level=logging.DEBUG) 14 | 15 | 16 | async def main() -> None: 17 | parser = argparse.ArgumentParser(description="Echo 'Hello, World!' from an SMP server") 18 | parser.add_argument("address", help="The IP address to connect to") 19 | address = parser.parse_args().address 20 | 21 | async with SMPClient(SMPUDPTransport(), address) as client: 22 | print("OK") 23 | 24 | print("Sending request...", end="", flush=True) 25 | response: Final = await client.request(EchoWrite(d="Hello, World!")) 26 | print("OK") 27 | 28 | if success(response): 29 | print(f"Received response: {response}") 30 | elif error(response): 31 | print(f"Received error: {response}") 32 | else: 33 | raise Exception(f"Unknown response: {response}") 34 | 35 | 36 | if __name__ == "__main__": 37 | asyncio.run(main()) 38 | -------------------------------------------------------------------------------- /examples/usb/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/examples/usb/__init__.py -------------------------------------------------------------------------------- /examples/usb/download_file.py: -------------------------------------------------------------------------------- 1 | """Downloading a file.""" 2 | 3 | import argparse 4 | import asyncio 5 | import time 6 | 7 | from smpclient import SMPClient 8 | from smpclient.transport.serial import SMPSerialTransport 9 | 10 | 11 | async def main() -> None: 12 | parser = argparse.ArgumentParser(description="Downloading an file from a smp server") 13 | parser.add_argument("port", help="The serial port to connect to") 14 | parser.add_argument("file_location", help="The location of the test file on the smp server") 15 | args = parser.parse_args() 16 | port = args.port 17 | file_location = args.file_location 18 | 19 | async with SMPClient(SMPSerialTransport(), port) as client: 20 | start_s = time.time() 21 | file_data = await client.download_file(file_location) 22 | end_s = time.time() 23 | duration = end_s - start_s 24 | speed = round(len(file_data) / ((duration)) / 1000, 2) 25 | 26 | print(f"Speed {speed} KB/s") 27 | 28 | print() 29 | print("Finished downloading file") 30 | 31 | 32 | if __name__ == "__main__": 33 | asyncio.run(main()) 34 | -------------------------------------------------------------------------------- /examples/usb/helloworld.py: -------------------------------------------------------------------------------- 1 | """Echo "Hello, World!" from an SMP server.""" 2 | 3 | import argparse 4 | import asyncio 5 | from typing import Final 6 | 7 | from smpclient import SMPClient 8 | from smpclient.generics import error, success 9 | from smpclient.requests.os_management import EchoWrite 10 | from smpclient.transport.serial import SMPSerialTransport 11 | 12 | 13 | async def main() -> None: 14 | parser = argparse.ArgumentParser(description="Echo 'Hello, World!' from an SMP server") 15 | parser.add_argument("port", help="The serial port to connect to") 16 | port = parser.parse_args().port 17 | 18 | async with SMPClient(SMPSerialTransport(), port) as client: 19 | print("OK") 20 | 21 | print("Sending request...", end="", flush=True) 22 | response: Final = await client.request(EchoWrite(d="Hello, World!")) 23 | print("OK") 24 | 25 | if success(response): 26 | print(f"Received response: {response}") 27 | elif error(response): 28 | print(f"Received error: {response}") 29 | else: 30 | raise Exception(f"Unknown response: {response}") 31 | 32 | 33 | if __name__ == "__main__": 34 | asyncio.run(main()) 35 | -------------------------------------------------------------------------------- /examples/usb/upgrade.py: -------------------------------------------------------------------------------- 1 | """Perform a full DFU routine.""" 2 | 3 | import argparse 4 | import asyncio 5 | import logging 6 | import re 7 | import subprocess 8 | import time 9 | from pathlib import Path 10 | from typing import Final, Tuple 11 | 12 | from serial.tools.list_ports import comports 13 | from smp import error as smperr 14 | from smp.os_management import OS_MGMT_RET_RC 15 | 16 | from smpclient import SMPClient 17 | from smpclient.generics import SMPRequest, TEr1, TEr2, TRep, error, error_v1, error_v2, success 18 | from smpclient.mcuboot import IMAGE_TLV, ImageInfo 19 | from smpclient.requests.image_management import ImageStatesRead, ImageStatesWrite 20 | from smpclient.requests.os_management import ResetWrite 21 | from smpclient.transport.serial import SMPSerialTransport 22 | 23 | logging.basicConfig( 24 | format="%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s", 25 | level=logging.INFO, 26 | datefmt="%Y-%m-%d %H:%M:%S", 27 | ) 28 | 29 | HEX_PATTERN: Final = re.compile(r'a_smp_dut_(\d+)_(\d+)_(\d+)[\.merged]?\.hex') 30 | MCUBOOT_HEX_PATTERN: Final = re.compile(r'mcuboot_a_(\d+)_(\d+)_(\d+)\.merged\.hex') 31 | 32 | 33 | async def main() -> None: 34 | parser = argparse.ArgumentParser(description="Do an SMP DFU test") 35 | parser.add_argument("board", help='Name of the board; the "BUT"') 36 | parser.add_argument( 37 | "--hex", 38 | help="a_smp_dut___.merged.hex", 39 | default="a_smp_dut_128_2_256.merged.hex", 40 | required=False, 41 | type=str, 42 | ) 43 | args: Final = parser.parse_args() 44 | 45 | hex: Final[str] = args.hex 46 | print(f"Using hex: {hex}") 47 | 48 | match = HEX_PATTERN.match(hex) 49 | testing_mcuboot: Final = match is None 50 | if testing_mcuboot: 51 | match = MCUBOOT_HEX_PATTERN.match(hex) 52 | # This example uses CONFIG_BOOT_SERIAL_WAIT_FOR_DFU=y to enter MCUBoot 53 | if match is None: 54 | raise ValueError(f"Invalid hex: {hex}") 55 | assert match is not None 56 | 57 | line_length, line_buffers, max_smp_encoded_frame_size = map(int, match.groups()) 58 | 59 | print(f"Using line_length: {line_length}") 60 | print(f"Using line_buffers: {line_buffers}") 61 | print(f"Using max_smp_encoded_frame_size (netbuf): {max_smp_encoded_frame_size}") 62 | 63 | a_smp_bin: Final = hex.replace(".merged.hex", ".bin") 64 | print(f"Using a_smp_dut.bin: {a_smp_bin}") 65 | 66 | dut_folder: Final = Path(__file__).parent.parent / "duts" / args.board / "usb" 67 | print(f"Using DUT folder: {dut_folder}") 68 | hex_path: Final = dut_folder / hex 69 | 70 | if "merged" in str(hex): 71 | print(f"Using merged.hex: {hex_path}") 72 | print("Flashing the merged.hex...") 73 | else: 74 | mcuboot_path: Final = dut_folder / "mcuboot.hex" 75 | print(f"Using mcuboot: {mcuboot_path}") 76 | print("Flashing the mcuboot.hex...") 77 | assert subprocess.run(get_runner_command(args.board, mcuboot_path)).returncode == 0 78 | 79 | print(f"Using app hex: {hex_path}") 80 | print("Flashing the app hex...") 81 | 82 | assert subprocess.run(get_runner_command(args.board, hex_path)).returncode == 0 83 | 84 | a_smp_dut_hash: Final = ImageInfo.load_file(str(dut_folder / a_smp_bin)).get_tlv( 85 | IMAGE_TLV.SHA256 86 | ) 87 | print(f"A SMP DUT hash: {a_smp_dut_hash}") 88 | 89 | b_smp_dut_path: Final = dut_folder / f"{'mcuboot_' if testing_mcuboot else ''}b_smp_dut.bin" 90 | b_smp_dut_hash: Final = ImageInfo.load_file(str(b_smp_dut_path)).get_tlv(IMAGE_TLV.SHA256) 91 | print(f"B SMP DUT hash: {b_smp_dut_hash}") 92 | 93 | with open(b_smp_dut_path, "rb") as f: 94 | b_smp_dut_bin: Final = f.read() 95 | 96 | smp_server_pid: Final = 0x000A if not testing_mcuboot else 0x000C 97 | 98 | print() 99 | print("Searching for SMP DUT...", end="", flush=True) 100 | while not any(smp_server_pid == p.pid for p in comports()): 101 | print(".", end="", flush=True) 102 | await asyncio.sleep(1) 103 | port_a = next(p for p in comports() if smp_server_pid == p.pid) 104 | print(f"OK - found DUT at {port_a.device}") 105 | 106 | await asyncio.sleep(1) 107 | 108 | print("Connecting to SMP DUT...", end="", flush=True) 109 | async with SMPClient( 110 | SMPSerialTransport( 111 | max_smp_encoded_frame_size=max_smp_encoded_frame_size, 112 | line_length=line_length, 113 | line_buffers=line_buffers, 114 | ), 115 | port_a.device, 116 | ) as client: 117 | print("OK") 118 | 119 | async def ensure_request(request: SMPRequest[TRep, TEr1, TEr2]) -> TRep: 120 | print("Sending request...", end="", flush=True) 121 | response = await client.request(request) 122 | print("OK") 123 | 124 | if success(response): 125 | print(f"Received response: {response}") 126 | return response 127 | elif error(response): 128 | raise Exception(f"Received error: {response}") 129 | else: 130 | raise Exception(f"Unknown response: {response}") 131 | 132 | response = await ensure_request(ImageStatesRead()) 133 | assert response.images[0].hash == a_smp_dut_hash.value 134 | assert response.images[0].slot == 0 135 | 136 | print() 137 | start_s = time.time() 138 | 139 | # TODO: MCUBoot should allow 0, 1, or 2 here but only 2 works! 140 | # It would be best to test with 1 to avoid the swap. And test 141 | # with CONFIG_SINGLE_APPLICATION_SLOT=y. 142 | # Refer to dutfirmware/mcuboot_usb.conf 143 | slot: Final = 2 if testing_mcuboot else 0 144 | 145 | print(f"Uploading {b_smp_dut_path} to slot {slot}") 146 | print() 147 | 148 | async for offset in client.upload(b_smp_dut_bin, slot=slot, first_timeout_s=2.500): 149 | print( 150 | f"\rUploaded {offset:,} / {len(b_smp_dut_bin):,} Bytes | " 151 | f"{offset / (time.time() - start_s) / 1000:.2f} KB/s ", 152 | end="", 153 | flush=True, 154 | ) 155 | 156 | print() 157 | response = await ensure_request(ImageStatesRead()) 158 | assert response.images[1].hash == b_smp_dut_hash.value 159 | assert response.images[1].slot == 1 160 | print("Confirmed the upload") 161 | 162 | # TODO: complete the testing with swap and reset. It is not working 163 | # with the current test images. 164 | if testing_mcuboot: 165 | return 166 | 167 | print() 168 | print("Marking B SMP DUT for test...") 169 | await ensure_request(ImageStatesWrite(hash=response.images[1].hash)) 170 | 171 | print() 172 | print("Resetting for swap...") 173 | reset_response = await client.request(ResetWrite()) 174 | if error_v1(reset_response): 175 | assert reset_response.rc == smperr.MGMT_ERR.EOK 176 | elif error_v2(reset_response): 177 | assert reset_response.err.rc == OS_MGMT_RET_RC.OK 178 | 179 | print() 180 | print("Searching for B SMP DUT...", end="", flush=True) 181 | while not any(0x000B == p.pid for p in comports()): 182 | print(".", end="", flush=True) 183 | await asyncio.sleep(1) 184 | port_b = next(p for p in comports() if 0x000B == p.pid) 185 | print(f"OK - found DUT B at {port_b.device}") 186 | 187 | print("Connecting to B SMP DUT...", end="", flush=True) 188 | async with SMPClient( 189 | SMPSerialTransport( 190 | max_smp_encoded_frame_size=max_smp_encoded_frame_size, 191 | line_length=line_length, 192 | line_buffers=line_buffers, 193 | ), 194 | port_b.device, 195 | ) as client: 196 | print("OK") 197 | 198 | print() 199 | print("Sending request...", end="", flush=True) 200 | images = await client.request(ImageStatesRead()) 201 | print("OK") 202 | 203 | if success(images): 204 | print(f"Received response: {images}") 205 | # assert the swap - B is in primary, A has been swapped to secondary 206 | assert images.images[0].hash == b_smp_dut_hash.value 207 | assert images.images[0].slot == 0 208 | assert images.images[1].hash == a_smp_dut_hash.value 209 | assert images.images[1].slot == 1 210 | print() 211 | print("Confirmed the swap") 212 | elif error(images): 213 | raise SystemExit(f"Received error: {images}") 214 | else: 215 | raise SystemExit(f"Unknown response: {images}") 216 | 217 | 218 | def get_runner_command(board: str, hex_path: Path) -> Tuple[str, ...]: 219 | if "nrf" in board: 220 | print("Using the nrfjprog runner") 221 | return ("nrfjprog", "--recover", "--reset", "--verify", "--program", str(hex_path)) 222 | elif "mimxrt" in board: 223 | print("Using the NXP linkserver runner") 224 | return ("linkserver", "flash", "MIMXRT1062xxxxA:EVK-MIMXRT1060", "load", str(hex_path)) 225 | else: 226 | raise ValueError(f"Don't know what runner to use for {board}") 227 | 228 | 229 | if __name__ == "__main__": 230 | asyncio.run(main()) 231 | -------------------------------------------------------------------------------- /examples/usb/upload_file.py: -------------------------------------------------------------------------------- 1 | """Upload a file to an smp server""" 2 | 3 | import argparse 4 | import asyncio 5 | import time 6 | 7 | from smpclient import SMPClient 8 | from smpclient.transport.serial import SMPSerialTransport 9 | 10 | 11 | async def main() -> None: 12 | parser = argparse.ArgumentParser(description="Upload an file to an smp server") 13 | parser.add_argument("port", help="The serial port to connect to") 14 | parser.add_argument( 15 | "file_path", help="The target path where test file will be uploaded on the smp server" 16 | ) 17 | args = parser.parse_args() 18 | port = args.port 19 | file_path = args.file_path 20 | 21 | file_data = b"""Test document 22 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla est purus, ultrices in porttitor 23 | in, accumsan non quam. Nam consectetur porttitor rhoncus. Curabitur eu est et leo feugiat 24 | auctor vel quis lorem. Ut et ligula dolor, sit amet consequat lorem. Aliquam porta eros sed 25 | velit imperdiet egestas. Maecenas tempus eros ut diam ullamcorper id dictum libero 26 | tempor. Donec quis augue quis magna condimentum lobortis. Quisque imperdiet ipsum vel 27 | magna viverra rutrum. Cras viverra molestie urna, vitae vestibulum turpis varius id. 28 | Vestibulum mollis, arcu iaculis bibendum varius, velit sapien blandit metus, ac posuere lorem 29 | nulla ac dolor. Maecenas urna elit, tincidunt in dapibus nec, vehicula eu dui. Duis lacinia 30 | fringilla massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur 31 | ridiculus mus. Ut consequat ultricies est, non rhoncus mauris congue porta. Vivamus viverra 32 | suscipit felis eget condimentum. Cum sociis natoque penatibus et magnis dis parturient 33 | montes, nascetur ridiculus mus. Integer bibendum sagittis ligula, non faucibus nulla volutpat 34 | vitae. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. 35 | In aliquet quam et velit bibendum accumsan. Cum sociis natoque penatibus et magnis dis 36 | parturient montes, nascetur ridiculus mus. Vestibulum vitae ipsum nec arcu semper 37 | adipiscing at ac lacus. Praesent id pellentesque orci. Morbi congue viverra nisl nec rhoncus. 38 | Integer mattis, ipsum a tincidunt commodo, lacus arcu elementum elit, at mollis eros ante ac 39 | risus. In volutpat, ante at pretium ultricies, velit magna suscipit enim, aliquet blandit massa 40 | orci nec lorem. Nulla facilisi. Duis eu vehicula arcu. Nulla facilisi. Maecenas pellentesque 41 | volutpat felis, quis tristique ligula luctus vel. Sed nec mi eros. Integer augue enim, sollicitudin 42 | ullamcorper mattis eget, aliquam in est. Morbi sollicitudin libero nec augue dignissim ut 43 | consectetur dui volutpat. Nulla facilisi. Mauris egestas vestibulum neque cursus tincidunt. 44 | Donec sit amet pulvinar orci. 45 | Quisque volutpat pharetra tincidunt. Fusce sapien arcu, molestie eget varius egestas, 46 | faucibus ac urna. Sed at nisi in velit egestas aliquam ut a felis. Aenean malesuada iaculis nisl, 47 | ut tempor lacus egestas consequat. Nam nibh lectus, gravida sed egestas ut, feugiat quis 48 | dolor. Donec eu leo enim, non laoreet ante. Morbi dictum tempor vulputate. Phasellus 49 | ultricies risus vel augue sagittis euismod. Vivamus tincidunt placerat nisi in aliquam. Cras 50 | quis mi ac nunc pretium aliquam. Aenean elementum erat ac metus commodo rhoncus. 51 | Aliquam nulla augue, porta non sagittis quis, accumsan vitae sem. Phasellus id lectus tortor, 52 | eget pulvinar augue. Etiam eget velit ac purus fringilla blandit. Donec odio odio, sagittis sed 53 | iaculis sed, consectetur eget sem. Lorem ipsum dolor sit amet, consectetur adipiscing elit. 54 | Maecenas accumsan velit vel turpis rutrum in sodales diam placerat. 55 | Quisque luctus ullamcorper velit sit amet lobortis. Etiam ligula felis, vulputate quis rhoncus 56 | nec, fermentum eget odio. Vivamus vel ipsum ac augue sodales mollis euismod nec tellus. 57 | Fusce et augue rutrum nunc semper vehicula vel semper nisl. Nam laoreet euismod quam at 58 | varius. Sed aliquet auctor nibh. Curabitur malesuada fermentum lacus vel accumsan. Duis 59 | ornare scelerisque nulla, ac pulvinar ligula tempus sit amet. In placerat nulla ac ante 60 | scelerisque posuere. Phasellus at ante felis. Sed hendrerit risus a metus posuere rutrum. 61 | Phasellus eu augue dui. Proin in vestibulum ipsum. Aenean accumsan mollis sapien, ut 62 | eleifend sem blandit at. Vivamus luctus mi eget lorem lobortis pharetra. Phasellus at tortor 63 | quam, a volutpat purus. Etiam sollicitudin arcu vel elit bibendum et imperdiet risus tincidunt. 64 | Etiam elit velit, posuere ut pulvinar ac, condimentum eget justo. Fusce a erat velit. Vivamus 65 | imperdiet ultrices orci in hendrerit. 66 | """ 67 | async with SMPClient(SMPSerialTransport(), port) as client: 68 | start_s = time.time() 69 | async for offset in client.upload_file(file_data=file_data, file_path=file_path): 70 | print( 71 | f"\rUploaded {offset:,} / {len(file_data):,} Bytes | " 72 | f"{offset / (time.time() - start_s) / 1000:.2f} KB/s ", 73 | end="", 74 | flush=True, 75 | ) 76 | print() 77 | print("Finished uploading file") 78 | 79 | 80 | if __name__ == "__main__": 81 | asyncio.run(main()) 82 | -------------------------------------------------------------------------------- /mkdocs.yaml: -------------------------------------------------------------------------------- 1 | site_name: Simple Management Protocol (SMP) Client 2 | 3 | repo_name: intercreate/smpclient 4 | repo_url: https://github.com/intercreate/smpclient 5 | 6 | nav: 7 | - Home: index.md 8 | - Transport: transport/transport.md 9 | - Serial (USB): transport/serial.md 10 | - BLE: transport/ble.md 11 | - UDP (Network): transport/udp.md 12 | - MCUBoot: mcuboot.md 13 | - Request/Response: requests.md 14 | - File Management: file_management.md 15 | - Image Management: image_management.md 16 | - OS Management: os_management.md 17 | - Settings Management: settings_management.md 18 | - Shell Management: shell_management.md 19 | - Statistics Management: statistics_management.md 20 | - Zephyr Management: zephyr_management.md 21 | - User: 22 | - Intercreate: user/intercreate.md 23 | 24 | plugins: 25 | - search 26 | - mkdocstrings: 27 | handlers: 28 | python: 29 | griffe: 30 | allow_inspections: true 31 | options: 32 | docstring_style: google 33 | show_source: true 34 | members_order: source 35 | show_signature_annotations: true 36 | show_inherited_class_members: true 37 | show_if_no_docstring: true 38 | extensions: 39 | - griffe_inherited_docstrings 40 | - mike: 41 | # These fields are all optional; the defaults are as below... 42 | alias_type: symlink 43 | redirect_template: null 44 | deploy_prefix: "" 45 | canonical_version: null 46 | version_selector: true 47 | css_dir: css 48 | javascript_dir: js 49 | 50 | extra: 51 | version: 52 | provider: mike 53 | 54 | extra_css: 55 | - stylesheets/extra.css 56 | 57 | theme: 58 | name: material 59 | palette: 60 | primary: custom 61 | accent: custom 62 | 63 | markdown_extensions: 64 | - admonition 65 | - codehilite 66 | - footnotes 67 | - meta 68 | - toc: 69 | permalink: true 70 | - pymdownx.highlight: 71 | anchor_linenums: true 72 | line_spans: __span 73 | pygments_lang_class: true 74 | - pymdownx.inlinehilite 75 | - pymdownx.magiclink 76 | - pymdownx.snippets 77 | - pymdownx.superfences 78 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "smpclient" 3 | dynamic = ["version"] 4 | description = "Simple Management Protocol (SMP) Client for remotely managing MCU firmware" 5 | authors = [ 6 | { name = "JP Hutchins", email = "jphutchins@gmail.com" }, 7 | { name = "JP Hutchins", email = "jp@intercreate.io" }, 8 | ] 9 | readme = "README.md" 10 | license = "Apache-2.0" 11 | requires-python = ">=3.9,<3.14" 12 | classifiers = [ 13 | "Development Status :: 5 - Production/Stable", 14 | "Intended Audience :: Developers", 15 | "License :: OSI Approved :: Apache Software License", 16 | "Programming Language :: Python", 17 | "Topic :: Software Development :: Libraries", 18 | "Framework :: AsyncIO", 19 | "Operating System :: Microsoft :: Windows", 20 | "Operating System :: POSIX :: Linux", 21 | "Operating System :: MacOS :: MacOS X", 22 | ] 23 | 24 | [project.urls] 25 | Homepage = "https://www.intercreate.io" 26 | Documentation = "https://intercreate.github.io/smpclient" 27 | Repository = "https://github.com/intercreate/smpclient.git" 28 | Issues = "https://github.com/intercreate/smpclient/issues" 29 | 30 | [tool.poetry] 31 | packages = [{ include = "smpclient" }] 32 | 33 | version = "0.0.0" 34 | [project.scripts] 35 | mcuimg = "smpclient.mcuboot:mcuimg" 36 | 37 | [tool.poetry.requires-plugins] 38 | poetry-dynamic-versioning = { version = ">=1.0.0,<2.0.0", extras = ["plugin"] } 39 | 40 | [tool.poetry-dynamic-versioning] 41 | strict = true 42 | enable = true 43 | vcs = "git" 44 | style = "semver" 45 | metadata = true 46 | tagged-metadata = true 47 | dirty = true 48 | fix-shallow-repository = true 49 | pattern = '(?P\d+\.\d+\.\d+)' 50 | format-jinja = "{% if distance == 0 %}{{ base }}{% else %}{{ base }}-dev{{ distance }}+g{{ commit }}{% endif %}{% if dirty %}.dirty{% endif %}" 51 | 52 | [tool.poetry.dependencies] 53 | pyserial = "^3.5" 54 | smp = "^3.1.1" 55 | intelhex = "^2.3.0" 56 | bleak = "^0.22.1" 57 | async-timeout = { version = "^4.0.3", python = "<3.11" } 58 | 59 | [tool.poetry.group.dev.dependencies] 60 | pytest = "^7.4.3" 61 | pytest-cov = "^4.1.0" 62 | black = "^23.11.0" 63 | flake8 = "^6.1.0" 64 | isort = "^5.12.0" 65 | mypy = "^1.7.0" 66 | mypy-extensions = "^1.0.0" 67 | pytest-asyncio = "^0.23.2" 68 | types-pyserial = "^3.5.0.11" 69 | tox = "^4.15.0" 70 | pydoclint = "^0.5.8" 71 | poetry-dynamic-versioning = "^1.7.1" 72 | 73 | [tool.poetry.group.doc.dependencies] 74 | mkdocstrings = { extras = ["python"], version = "^0.26.1" } 75 | mike = "^2.1.3" 76 | mkdocs-material = "^9.5.38" 77 | griffe-inherited-docstrings = "^1.0.1" 78 | griffe = "^1.3.1" 79 | smp = "^3.1.1" 80 | 81 | [tool.black] 82 | line-length = 100 83 | skip-string-normalization = true 84 | extend-exclude = "dutfirmware|.venv|tests/fixtures|.tox|.poetry" 85 | 86 | [tool.isort] 87 | profile = "black" 88 | line_length = 100 89 | multi_line_output = 3 90 | skip = [".venv", "dutfirmware", ".tox"] 91 | 92 | [tool.mypy] 93 | disallow_untyped_defs = true 94 | exclude = ['.venv', 'dutfirmware', '.tox', '.poetry'] 95 | 96 | [tool.pydoclint] 97 | style = "google" 98 | arg-type-hints-in-docstring = false 99 | allow-init-docstring = true 100 | check-return-types = false 101 | check-yield-types = false 102 | 103 | [tool.pytest.ini_options] 104 | norecursedirs = "dutfirmware/*" 105 | filterwarnings = ["ignore:The --rsyncdir:DeprecationWarning"] 106 | 107 | [tool.tox] 108 | legacy_tox_ini = """ 109 | [tox] 110 | min_version = 4.15 111 | env_list = 112 | py38 113 | py39 114 | py310 115 | py311 116 | py312 117 | py313 118 | 119 | [testenv] 120 | allowlist_externals = 121 | poetry 122 | black 123 | isort 124 | flake8 125 | mypy 126 | coverage 127 | commands = 128 | poetry install 129 | black --check . 130 | isort --check-only . 131 | flake8 . 132 | mypy . 133 | coverage erase 134 | pytest --cov --maxfail=1 135 | """ 136 | 137 | [build-system] 138 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 139 | build-backend = "poetry_dynamic_versioning.backend" 140 | -------------------------------------------------------------------------------- /smpclient/exceptions.py: -------------------------------------------------------------------------------- 1 | """`smpclient` module exceptions.""" 2 | 3 | 4 | class SMPClientException(Exception): 5 | ... 6 | 7 | 8 | class SMPBadSequence(SMPClientException): 9 | ... 10 | 11 | 12 | class SMPUploadError(SMPClientException): 13 | ... 14 | -------------------------------------------------------------------------------- /smpclient/extensions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/smpclient/extensions/__init__.py -------------------------------------------------------------------------------- /smpclient/extensions/intercreate.py: -------------------------------------------------------------------------------- 1 | """Intercreate extensions of the `SMPClient`.""" 2 | 3 | from typing import AsyncIterator, Final 4 | 5 | from smp import header as smpheader 6 | 7 | from smpclient import SMPClient 8 | from smpclient.exceptions import SMPUploadError 9 | from smpclient.generics import error, success 10 | from smpclient.requests.user import intercreate as ic 11 | 12 | 13 | class ICUploadClient(SMPClient): 14 | """Support for Intercreate Group Upload.""" 15 | 16 | async def ic_upload(self, data: bytes, image: int = 0) -> AsyncIterator[int]: 17 | """Iteratively upload `data` to the SMP server, yielding the offset.""" 18 | 19 | response = await self.request( 20 | ic.ImageUploadWrite(off=0, data=b'', image=image, len=len(data)) 21 | ) 22 | 23 | if error(response): 24 | raise SMPUploadError(response) 25 | elif success(response): 26 | yield response.off 27 | else: # pragma: no cover 28 | raise Exception("Unreachable") 29 | 30 | # send chunks until the SMP server reports that the offset is at the end of the image 31 | while response.off != len(data): 32 | response = await self.request( 33 | self._ic_maximize_packet(ic.ImageUploadWrite(off=response.off, data=b''), data) 34 | ) 35 | if error(response): 36 | raise SMPUploadError(response) 37 | elif success(response): 38 | yield response.off 39 | else: # pragma: no cover 40 | raise Exception("Unreachable") 41 | 42 | def _ic_maximize_packet(self, request: ic.ImageUploadWrite, data: bytes) -> ic.ImageUploadWrite: 43 | """Given an `ic.ImageUploadWrite` with empty `data`, return the largest packet possible.""" 44 | 45 | h: Final = request.header 46 | cbor_size, data_size = self._get_max_cbor_and_data_size(request) 47 | 48 | if data_size > len(data) - request.off: # final packet 49 | data_size = len(data) - request.off 50 | cbor_size = h.length + data_size + self._cbor_integer_size(data_size) 51 | 52 | return ic.ImageUploadWrite( 53 | header=smpheader.Header( 54 | op=h.op, 55 | version=h.version, 56 | flags=h.flags, 57 | length=cbor_size, 58 | group_id=h.group_id, 59 | sequence=h.sequence, 60 | command_id=h.command_id, 61 | ), 62 | off=request.off, 63 | data=data[request.off : request.off + data_size], 64 | image=request.image, 65 | len=request.len, 66 | sha=request.sha, 67 | ) 68 | -------------------------------------------------------------------------------- /smpclient/generics.py: -------------------------------------------------------------------------------- 1 | """Generics and Type Narrowing for SMP Requests and Responses.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Protocol, Type, TypeVar, Union 6 | 7 | from smp import error as smperror 8 | from smp import header as smphdr 9 | from smp import message as smpmessage 10 | from typing_extensions import TypeIs 11 | 12 | TEr1 = TypeVar("TEr1", bound=smperror.ErrorV1) 13 | """Type of SMP Error V1.""" 14 | 15 | TEr2 = TypeVar("TEr2", bound=smperror.ErrorV2) 16 | """Type of SMP Error V2.""" 17 | 18 | TRep = TypeVar("TRep", bound=Union[smpmessage.ReadResponse, smpmessage.WriteResponse]) 19 | """Type of successful SMP Response (ReadResponse or WriteResponse).""" 20 | 21 | 22 | class SMPRequest(Protocol[TRep, TEr1, TEr2]): 23 | """A `Protocol` that groups the expected response and errors with a request. 24 | 25 | To use, inherit from an SMP Read or Write `Request` and define its expected 26 | `Response`, `ErrorV1`, and `ErrorV2`. 27 | 28 | Example: 29 | ```python 30 | class ImageStatesRead(smpimg.ImageStatesReadRequest): 31 | _Response = smpimg.ImageStatesReadResponse 32 | _ErrorV1 = smpimg.ImageManagementErrorV1 33 | _ErrorV2 = smpimg.ImageManagementErrorV2 34 | ``` 35 | """ 36 | 37 | _Response: Type[TRep] 38 | _ErrorV1: Type[TEr1] 39 | _ErrorV2: Type[TEr2] 40 | 41 | @property 42 | def BYTES(self) -> bytes: # pragma: no cover 43 | ... 44 | 45 | @property 46 | def header(self) -> smphdr.Header: # pragma: no cover 47 | ... 48 | 49 | 50 | def error_v1(response: smperror.ErrorV1 | TEr2 | TRep) -> TypeIs[smperror.ErrorV1]: 51 | """`TypeIs` that returns `True` if the `response` is an `ErrorV1`. 52 | 53 | Args: 54 | response: The response to check. 55 | 56 | Returns: 57 | `True` if the `response` is an `ErrorV1`. 58 | """ 59 | return response.RESPONSE_TYPE == smpmessage.ResponseType.ERROR_V1 60 | 61 | 62 | def error_v2(response: smperror.ErrorV1 | TEr2 | TRep) -> TypeIs[TEr2]: 63 | """`TypeIs` that returns `True` if the `response` is an `ErrorV2`. 64 | 65 | Args: 66 | response: The response to check. 67 | 68 | Returns: 69 | `True` if the `response` is an `ErrorV2`. 70 | """ 71 | return response.RESPONSE_TYPE == smpmessage.ResponseType.ERROR_V2 72 | 73 | 74 | def error(response: smperror.ErrorV1 | TEr2 | TRep) -> TypeIs[smperror.ErrorV1 | TEr2]: 75 | """`TypeIs` that returns `True` if the `response` is an `ErrorV1` or `ErrorV2`. 76 | 77 | Args: 78 | response: The response to check. 79 | 80 | Returns: 81 | `True` if the `response` is an `ErrorV1` or `ErrorV2`. 82 | """ 83 | return error_v1(response) or error_v2(response) 84 | 85 | 86 | def success(response: smperror.ErrorV1 | TEr2 | TRep) -> TypeIs[TRep]: 87 | """`TypeIs` that returns `True` if the `response` is a successful `Response`. 88 | 89 | Args: 90 | response: The response to check. 91 | 92 | Returns: 93 | `True` if the `response` is a successful `Response`. 94 | """ 95 | return response.RESPONSE_TYPE == smpmessage.ResponseType.SUCCESS 96 | -------------------------------------------------------------------------------- /smpclient/mcuboot.py: -------------------------------------------------------------------------------- 1 | """Tools for inspecting MCUBoot compatible firmware images. 2 | 3 | Specification: https://docs.mcuboot.com/design.html 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import argparse 9 | import pathlib 10 | import struct 11 | from enum import IntEnum, IntFlag, unique 12 | from functools import cached_property 13 | from io import BufferedReader, BytesIO 14 | from typing import Dict, Final, List 15 | 16 | from intelhex import hex2bin # type: ignore 17 | from pydantic.dataclasses import dataclass 18 | 19 | IMAGE_MAGIC: Final = 0x96F3B83D 20 | IMAGE_HEADER_SIZE: Final = 32 21 | 22 | _IMAGE_VERSION_FORMAT_STRING: Final = "BBHL" 23 | IMAGE_VERSION_STRUCT: Final = struct.Struct(f"<{_IMAGE_VERSION_FORMAT_STRING}") 24 | assert IMAGE_VERSION_STRUCT.size == 8 25 | 26 | IMAGE_HEADER_STRUCT: Final = struct.Struct(f" 'ImageVersion': 104 | """Load an `ImageVersion` from `bytes`.""" 105 | return ImageVersion(*IMAGE_VERSION_STRUCT.unpack(data)) 106 | 107 | def __str__(self) -> str: 108 | return f"{self.major}.{self.minor}.{self.revision}-build{self.build_num}" 109 | 110 | 111 | @dataclass(frozen=True) 112 | class ImageHeader: 113 | """An MCUBoot signed FW update header.""" 114 | 115 | magic: int 116 | load_addr: int 117 | hdr_size: int 118 | protect_tlv_size: int 119 | img_size: int 120 | flags: IMAGE_F 121 | ver: ImageVersion 122 | 123 | @staticmethod 124 | def loads(data: bytes) -> 'ImageHeader': 125 | """Load an `ImageHeader` from `bytes`.""" 126 | ( 127 | magic, 128 | load_addr, 129 | hdr_size, 130 | protect_tlv_size, 131 | img_size, 132 | flags, 133 | *ver, 134 | ) = IMAGE_HEADER_STRUCT.unpack(data) 135 | return ImageHeader( 136 | magic=magic, 137 | load_addr=load_addr, 138 | hdr_size=hdr_size, 139 | protect_tlv_size=protect_tlv_size, 140 | img_size=img_size, 141 | flags=flags, 142 | ver=ImageVersion(*ver), 143 | ) 144 | 145 | def __post_init__(self) -> None: 146 | """Do initial validation of the header.""" 147 | if self.magic != IMAGE_MAGIC: 148 | raise MCUBootImageError(f"Magic is {hex(self.magic)}, expected {hex(IMAGE_MAGIC)}") 149 | 150 | @staticmethod 151 | def load_from(file: BytesIO | BufferedReader) -> 'ImageHeader': 152 | """Load an `ImageHeader` from an open file.""" 153 | return ImageHeader.loads(file.read(IMAGE_HEADER_STRUCT.size)) 154 | 155 | @staticmethod 156 | def load_file(path: str) -> 'ImageHeader': 157 | """Load an `ImageHeader` the file at `path`.""" 158 | with open(path, 'rb') as f: 159 | return ImageHeader.load_from(f) 160 | 161 | 162 | @dataclass(frozen=True) 163 | class ImageTLVInfo: 164 | """An image Type-Length-Value (TLV) region header.""" 165 | 166 | magic: int 167 | tlv_tot: int 168 | """size of TLV area (including tlv_info header)""" 169 | 170 | def __post_init__(self) -> None: 171 | """Do initial validation of the header.""" 172 | if self.magic != IMAGE_TLV_INFO_MAGIC: 173 | raise MCUBootImageError( 174 | f"TLV info magic is {hex(self.magic)}, expected {hex(IMAGE_TLV_INFO_MAGIC)}" 175 | ) 176 | 177 | @staticmethod 178 | def loads(data: bytes) -> 'ImageTLVInfo': 179 | """Load an `ImageTLVInfo` from bytes.""" 180 | return ImageTLVInfo(*IMAGE_TLV_INFO_STRUCT.unpack(data)) 181 | 182 | @staticmethod 183 | def load_from(file: BytesIO | BufferedReader) -> 'ImageTLVInfo': 184 | """Load an `ImageTLVInfo` from a file.""" 185 | return ImageTLVInfo.loads(file.read(IMAGE_TLV_INFO_STRUCT.size)) 186 | 187 | 188 | @dataclass(frozen=True) 189 | class ImageTLV: 190 | """A TLV header - type and length.""" 191 | 192 | type: IMAGE_TLV 193 | len: int 194 | """Data length (not including TLV header).""" 195 | 196 | @staticmethod 197 | def load_from(file: BytesIO | BufferedReader) -> 'ImageTLV': 198 | """Load an `ImageTLV` from a file.""" 199 | return ImageTLV(*IMAGE_TLV_STRUCT.unpack_from(file.read(IMAGE_TLV_STRUCT.size))) 200 | 201 | 202 | @dataclass(frozen=True) 203 | class ImageTLVValue: 204 | header: ImageTLV 205 | value: bytes 206 | 207 | def __post_init__(self) -> None: 208 | if len(self.value) != self.header.len: 209 | raise MCUBootImageError(f"TLV requires length {self.header.len}, got {len(self.value)}") 210 | 211 | def __str__(self) -> str: 212 | return f"{self.header.type.name}={self.value.hex()}" 213 | 214 | 215 | @dataclass(frozen=True) 216 | class ImageInfo: 217 | """A summary of an MCUBoot FW update image.""" 218 | 219 | header: ImageHeader 220 | tlv_info: ImageTLVInfo 221 | tlvs: List[ImageTLVValue] 222 | file: str | None = None 223 | 224 | def get_tlv(self, tlv: IMAGE_TLV) -> ImageTLVValue: 225 | """Get a TLV from the image or raise `TLVNotFound`.""" 226 | if tlv in self._map_tlv_type_to_value: 227 | return self._map_tlv_type_to_value[tlv] 228 | else: 229 | raise TLVNotFound(f"{tlv} not found in image.") 230 | 231 | @staticmethod 232 | def load_file(path: str) -> 'ImageInfo': 233 | """Load MCUBoot `ImageInfo` from the .bin or .hex file at `path`.""" 234 | file_path = pathlib.Path(path) 235 | if file_path.suffix not in {".bin", ".hex"}: 236 | raise MCUBootImageError( 237 | f"Ambiguous file extension, '{file_path.suffix}', use '.bin' or '.hex'" 238 | ) 239 | 240 | if file_path.suffix == ".bin": 241 | with open(file_path, 'rb') as _f: 242 | f = BytesIO(_f.read()) 243 | else: 244 | f = BytesIO() 245 | ret = hex2bin(str(file_path), f) 246 | if ret != 0: 247 | raise MCUBootImageError(f"hex2bin() ret: {ret}") 248 | 249 | f.seek(0) # move to the start of the image 250 | image_header = ImageHeader.load_from(f) 251 | 252 | tlv_offset = image_header.hdr_size + image_header.img_size 253 | 254 | f.seek(tlv_offset) # move to the start of the TLV area 255 | tlv_info = ImageTLVInfo.load_from(f) 256 | 257 | tlvs: List[ImageTLVValue] = [] 258 | while f.tell() < tlv_offset + tlv_info.tlv_tot: 259 | tlv_header = ImageTLV.load_from(f) 260 | tlvs.append(ImageTLVValue(header=tlv_header, value=f.read(tlv_header.len))) 261 | 262 | return ImageInfo(file=path, header=image_header, tlv_info=tlv_info, tlvs=tlvs) 263 | 264 | @cached_property 265 | def _map_tlv_type_to_value(self) -> Dict[IMAGE_TLV, ImageTLVValue]: 266 | return {tlv.header.type: tlv for tlv in self.tlvs} 267 | 268 | def __str__(self) -> str: 269 | rep = ( 270 | f"{self.__class__.__name__}{': ' + self.file if self.file is not None else ''}\n" 271 | f"{self.header}\n" 272 | f"{self.tlv_info}\n" 273 | ) 274 | 275 | for tlv in self.tlvs: 276 | rep += f" {str(tlv)}\n" 277 | 278 | return rep 279 | 280 | 281 | def mcuimg() -> int: 282 | """A minimal CLI for getting info about an MCUBoot compatible FW image.""" 283 | 284 | parser = argparse.ArgumentParser( 285 | prog="mcuimg", 286 | description=( 287 | "Inspect an MCUBoot compatible firmware update image." 288 | "\nCopyright (C) 2023-2024 Intercreate, Inc. | github.com/intercreate/smpclient" 289 | ), 290 | formatter_class=argparse.RawDescriptionHelpFormatter, 291 | ) 292 | parser.add_argument("file") 293 | 294 | try: 295 | image_info = ImageInfo.load_file(parser.parse_args().file) 296 | except FileNotFoundError as e: 297 | print(e) 298 | return -1 299 | 300 | print(str(image_info)) 301 | 302 | return 0 303 | -------------------------------------------------------------------------------- /smpclient/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/smpclient/py.typed -------------------------------------------------------------------------------- /smpclient/requests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/smpclient/requests/__init__.py -------------------------------------------------------------------------------- /smpclient/requests/file_management.py: -------------------------------------------------------------------------------- 1 | from smp import file_management as smpfs 2 | 3 | 4 | class _FileGroupBase: 5 | _ErrorV1 = smpfs.FileSystemManagementErrorV1 6 | _ErrorV2 = smpfs.FileSystemManagementErrorV2 7 | 8 | 9 | class FileDownload(smpfs.FileDownloadRequest, _FileGroupBase): 10 | _Response = smpfs.FileDownloadResponse 11 | 12 | 13 | class FileUpload(smpfs.FileUploadRequest, _FileGroupBase): 14 | _Response = smpfs.FileUploadResponse 15 | 16 | 17 | class FileStatus(smpfs.FileStatusRequest, _FileGroupBase): 18 | _Response = smpfs.FileStatusResponse 19 | 20 | 21 | class FileHashChecksum(smpfs.FileHashChecksumRequest, _FileGroupBase): 22 | _Response = smpfs.FileHashChecksumResponse 23 | 24 | 25 | class SupportedFileHashChecksumTypes(smpfs.SupportedFileHashChecksumTypesRequest, _FileGroupBase): 26 | _Response = smpfs.SupportedFileHashChecksumTypesResponse 27 | 28 | 29 | class FileClose(smpfs.FileCloseRequest, _FileGroupBase): 30 | _Response = smpfs.FileCloseResponse 31 | -------------------------------------------------------------------------------- /smpclient/requests/image_management.py: -------------------------------------------------------------------------------- 1 | from smp import image_management as smpimg 2 | 3 | 4 | class _ImageGroupBase: 5 | _ErrorV1 = smpimg.ImageManagementErrorV1 6 | _ErrorV2 = smpimg.ImageManagementErrorV2 7 | 8 | 9 | class ImageStatesRead(smpimg.ImageStatesReadRequest, _ImageGroupBase): 10 | _Response = smpimg.ImageStatesReadResponse 11 | 12 | 13 | class ImageStatesWrite(smpimg.ImageStatesWriteRequest, _ImageGroupBase): 14 | _Response = smpimg.ImageStatesWriteResponse 15 | 16 | 17 | class ImageUploadWrite(smpimg.ImageUploadWriteRequest, _ImageGroupBase): 18 | _Response = smpimg.ImageUploadWriteResponse 19 | 20 | 21 | class ImageErase(smpimg.ImageEraseRequest, _ImageGroupBase): 22 | _Response = smpimg.ImageEraseResponse 23 | -------------------------------------------------------------------------------- /smpclient/requests/os_management.py: -------------------------------------------------------------------------------- 1 | from smp import os_management as smpos 2 | 3 | 4 | class _OSGroupBase: 5 | _ErrorV1 = smpos.OSManagementErrorV1 6 | _ErrorV2 = smpos.OSManagementErrorV2 7 | 8 | 9 | class EchoWrite(smpos.EchoWriteRequest, _OSGroupBase): 10 | _Response = smpos.EchoWriteResponse 11 | 12 | 13 | class ResetWrite(smpos.ResetWriteRequest, _OSGroupBase): 14 | _Response = smpos.ResetWriteResponse 15 | 16 | 17 | class TaskStatisticsRead(smpos.TaskStatisticsReadRequest, _OSGroupBase): 18 | _Response = smpos.TaskStatisticsReadResponse 19 | 20 | 21 | class MemoryPoolStatisticsRead(smpos.MemoryPoolStatisticsReadRequest, _OSGroupBase): 22 | _Response = smpos.MemoryPoolStatisticsReadResponse 23 | 24 | 25 | class DateTimeRead(smpos.DateTimeReadRequest, _OSGroupBase): 26 | _Response = smpos.DateTimeReadResponse 27 | 28 | 29 | class DateTimeWrite(smpos.DateTimeWriteRequest, _OSGroupBase): 30 | _Response = smpos.DateTimeWriteResponse 31 | 32 | 33 | class MCUMgrParametersRead(smpos.MCUMgrParametersReadRequest, _OSGroupBase): 34 | _Response = smpos.MCUMgrParametersReadResponse 35 | 36 | 37 | class OSApplicationInfoRead(smpos.OSApplicationInfoReadRequest, _OSGroupBase): 38 | _Response = smpos.OSApplicationInfoReadResponse 39 | 40 | 41 | class BootloaderInformationRead(smpos.BootloaderInformationReadRequest, _OSGroupBase): 42 | _Response = smpos.BootloaderInformationReadResponse 43 | -------------------------------------------------------------------------------- /smpclient/requests/settings_management.py: -------------------------------------------------------------------------------- 1 | import smp.settings_management as smpset 2 | 3 | 4 | class _GroupBase: 5 | _ErrorV1 = smpset.SettingsManagementErrorV1 6 | _ErrorV2 = smpset.SettingsManagementErrorV2 7 | 8 | 9 | class ReadSetting(smpset.ReadSettingRequest, _GroupBase): 10 | _Response = smpset.ReadSettingResponse 11 | 12 | 13 | class WriteSetting(smpset.WriteSettingRequest, _GroupBase): 14 | _Response = smpset.WriteSettingResponse 15 | 16 | 17 | class DeleteSetting(smpset.DeleteSettingRequest, _GroupBase): 18 | _Response = smpset.DeleteSettingResponse 19 | 20 | 21 | class CommitSettings(smpset.CommitSettingsRequest, _GroupBase): 22 | _Response = smpset.CommitSettingsResponse 23 | 24 | 25 | class LoadSettings(smpset.LoadSettingsRequest, _GroupBase): 26 | _Response = smpset.LoadSettingsResponse 27 | 28 | 29 | class SaveSettings(smpset.SaveSettingsRequest, _GroupBase): 30 | _Response = smpset.SaveSettingsResponse 31 | -------------------------------------------------------------------------------- /smpclient/requests/shell_management.py: -------------------------------------------------------------------------------- 1 | from smp import shell_management as smpshell 2 | 3 | 4 | class _ShellGroupBase: 5 | _ErrorV1 = smpshell.ShellManagementErrorV1 6 | _ErrorV2 = smpshell.ShellManagementErrorV2 7 | 8 | 9 | class Execute(smpshell.ExecuteRequest, _ShellGroupBase): 10 | _Response = smpshell.ExecuteResponse 11 | -------------------------------------------------------------------------------- /smpclient/requests/statistics_management.py: -------------------------------------------------------------------------------- 1 | import smp.statistics_management as smpstat 2 | 3 | 4 | class _GroupBase: 5 | _ErrorV1 = smpstat.StatisticsManagementErrorV1 6 | _ErrorV2 = smpstat.StatisticsManagementErrorV2 7 | 8 | 9 | class GroupData(smpstat.GroupDataRequest, _GroupBase): 10 | _Response = smpstat.GroupDataResponse 11 | 12 | 13 | class ListOfGroups(smpstat.ListOfGroupsRequest, _GroupBase): 14 | _Response = smpstat.ListOfGroupsResponse 15 | -------------------------------------------------------------------------------- /smpclient/requests/user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/smpclient/requests/user/__init__.py -------------------------------------------------------------------------------- /smpclient/requests/user/intercreate.py: -------------------------------------------------------------------------------- 1 | from smp.user import intercreate as smpic 2 | 3 | 4 | class _GroupBase: 5 | _ErrorV1 = smpic.ErrorV1 6 | _ErrorV2 = smpic.ErrorV2 7 | 8 | 9 | class ImageUploadWrite(smpic.ImageUploadWriteRequest, _GroupBase): 10 | _Response = smpic.ImageUploadWriteResponse 11 | -------------------------------------------------------------------------------- /smpclient/requests/zephyr_management.py: -------------------------------------------------------------------------------- 1 | import smp.zephyr_management as smpz 2 | 3 | 4 | class _GroupBase: 5 | _ErrorV1 = smpz.ZephyrManagementErrorV1 6 | _ErrorV2 = smpz.ZephyrManagementErrorV2 7 | 8 | 9 | class EraseStorage(smpz.EraseStorageRequest, _GroupBase): 10 | _Response = smpz.EraseStorageResponse 11 | -------------------------------------------------------------------------------- /smpclient/transport/__init__.py: -------------------------------------------------------------------------------- 1 | """Simple Management Protocol (SMP) Client Transport Protocol.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Protocol 6 | 7 | 8 | class SMPTransportDisconnected(Exception): 9 | """Raised when the SMP transport is disconnected.""" 10 | 11 | 12 | class SMPTransport(Protocol): 13 | _smp_server_transport_buffer_size: int | None = None 14 | """The SMP server transport buffer size, in 8-bit bytes.""" 15 | 16 | async def connect(self, address: str, timeout_s: float) -> None: # pragma: no cover 17 | """Connect the `SMPTransport`. 18 | 19 | Args: 20 | address: The SMP server address. 21 | timeout_s: The connection timeout in seconds.""" 22 | 23 | async def disconnect(self) -> None: # pragma: no cover 24 | """Disconnect the `SMPTransport`.""" 25 | 26 | async def send(self, data: bytes) -> None: # pragma: no cover 27 | """Send the encoded `SMPRequest` `data`. 28 | 29 | Args: 30 | data: The encoded `SMPRequest`. 31 | """ 32 | 33 | async def receive(self) -> bytes: # pragma: no cover 34 | """Receive the decoded `SMPResponse` data. 35 | 36 | Returns: 37 | The `SMPResponse` bytes. 38 | """ 39 | 40 | async def send_and_receive(self, data: bytes) -> bytes: # pragma: no cover 41 | """Send the encoded `SMPRequest` `data` and receive the decoded `SMPResponse`. 42 | 43 | Args: 44 | data: The encoded `SMPRequest`. 45 | 46 | Returns: 47 | The `SMPResponse` bytes. 48 | """ 49 | 50 | def initialize(self, smp_server_transport_buffer_size: int) -> None: # pragma: no cover 51 | """Initialize the `SMPTransport` with the server transport buffer size. 52 | 53 | Args: 54 | smp_server_transport_buffer_size: The SMP server transport buffer size, in 8-bit bytes. 55 | """ 56 | self._smp_server_transport_buffer_size = smp_server_transport_buffer_size 57 | 58 | @property 59 | def mtu(self) -> int: # pragma: no cover 60 | """The Maximum Transmission Unit (MTU) in 8-bit bytes.""" 61 | 62 | @property 63 | def max_unencoded_size(self) -> int: # pragma: no cover 64 | """The maximum size of an unencoded message that can be sent, in 8-bit bytes.""" 65 | 66 | # There is a potential speedup in the future by taking advantage of the 67 | # multiple buffers that are provided by the SMP server implementation. 68 | # Generally, the idea is to send as many as buf_count messages BEFORE 69 | # awaiting the response. This will allow the SMP server to buffer the 70 | # new IO while waiting for flash writes to complete. It creates some 71 | # complexity in both the client and server and it's debatable whether 72 | # or not the speedup is worth the complexity. Specifically, if there is 73 | # an error in some write, then some of the writes that have already been 74 | # sent out are no longer valid. That is, the response to each 75 | # concurrent write needs to be tracked very carefully! 76 | 77 | return self._smp_server_transport_buffer_size or self.mtu 78 | -------------------------------------------------------------------------------- /smpclient/transport/_udp_client.py: -------------------------------------------------------------------------------- 1 | """A UDP Client.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | from typing import Any, Final, NamedTuple, Tuple 8 | 9 | from typing_extensions import override 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class Addr(NamedTuple): 15 | host: str 16 | port: int 17 | 18 | 19 | class UDPClient: 20 | """Implementation of a UDP client.""" 21 | 22 | async def connect(self, remote_addr: Addr, _local_addr: Addr | None = None) -> None: 23 | """Create a UDP connection to the given `Addr`. 24 | 25 | Args: 26 | remote_addr: The remote address to connect to. 27 | _local_addr: For unit tests only! The local address to connect from. 28 | 29 | Example: 30 | 31 | ```python 32 | 33 | c = UDPClient() 34 | await c.connect(Addr("192.168.55.55", 1337)) 35 | ``` 36 | """ 37 | 38 | self._transport, self._protocol = await asyncio.get_running_loop().create_datagram_endpoint( 39 | lambda: _UDPProtocol(), 40 | remote_addr=remote_addr, 41 | local_addr=_local_addr, 42 | ) 43 | 44 | def send(self, data: bytes) -> None: 45 | """Send data to the transport. 46 | 47 | This does not block; it buffers the data and arranges for it to be sent 48 | out asynchronously. 49 | 50 | Args: 51 | data: The data to send. 52 | """ 53 | 54 | self._transport.sendto(data) 55 | 56 | async def receive(self) -> bytes: 57 | """Receive data from the transport. 58 | 59 | Returns: 60 | bytes: The data received 61 | """ 62 | 63 | return await self._protocol.receive_queue.get() 64 | 65 | def disconnect(self) -> None: 66 | self._transport.close() 67 | 68 | 69 | class _UDPProtocol(asyncio.DatagramProtocol): 70 | """Implementation of a UDP protocol.""" 71 | 72 | @override 73 | def __init__(self) -> None: 74 | self._receive_queue: Final[asyncio.Queue[bytes]] = asyncio.Queue() 75 | self._error_queue: Final[asyncio.Queue[Exception]] = asyncio.Queue() 76 | 77 | @override 78 | def connection_made(self, transport: asyncio.BaseTransport) -> None: 79 | logger.debug(f"Connection made, {transport=}") 80 | 81 | @override 82 | def datagram_received(self, data: bytes, addr: Tuple[str | Any, int]) -> None: 83 | logger.debug(f"{len(data)} B datagram received from {addr}") 84 | self._receive_queue.put_nowait(data) 85 | 86 | @override 87 | def error_received(self, exc: Exception) -> None: 88 | logger.warning(f"Error received: {exc=}") 89 | self._error_queue.put_nowait(exc) 90 | 91 | @override 92 | def connection_lost(self, exc: Exception | None) -> None: 93 | logger.info("Connection lost") 94 | if exc is not None: 95 | logger.error(f"Connection lost {exc=}") 96 | self._error_queue.put_nowait(exc) 97 | 98 | @property 99 | def receive_queue(self) -> asyncio.Queue[bytes]: 100 | return self._receive_queue 101 | 102 | @property 103 | def error_queue(self) -> asyncio.Queue[Exception]: 104 | return self._error_queue 105 | -------------------------------------------------------------------------------- /smpclient/transport/ble.py: -------------------------------------------------------------------------------- 1 | """A Bluetooth Low Energy (BLE) SMPTransport.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | import re 8 | import sys 9 | from typing import Final, List, Protocol 10 | from uuid import UUID 11 | 12 | from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner 13 | from bleak.backends.client import BaseBleakClient 14 | from bleak.backends.device import BLEDevice 15 | from smp import header as smphdr 16 | from typing_extensions import TypeGuard, override 17 | 18 | from smpclient.exceptions import SMPClientException 19 | from smpclient.transport import SMPTransport, SMPTransportDisconnected 20 | 21 | if sys.platform == "linux": 22 | from bleak.backends.bluezdbus.client import BleakClientBlueZDBus 23 | else: # stub for mypy 24 | 25 | class BleakClientBlueZDBus(Protocol): 26 | async def _acquire_mtu(self) -> None: 27 | ... 28 | 29 | 30 | if sys.platform == "win32": 31 | from bleak.backends.winrt.client import BleakClientWinRT 32 | else: # stub for mypy 33 | 34 | class GattSession(Protocol): 35 | max_pdu_size: int 36 | 37 | class BleakClientWinRT(Protocol): 38 | @property 39 | def _session(self) -> GattSession: 40 | ... 41 | 42 | 43 | SMP_SERVICE_UUID: Final = UUID("8D53DC1D-1DB7-4CD3-868B-8A527460AA84") 44 | SMP_CHARACTERISTIC_UUID: Final = UUID("DA2E7828-FBCE-4E01-AE9E-261174997C48") 45 | 46 | MAC_ADDRESS_PATTERN: Final = re.compile(r"([0-9A-F]{2}[:]){5}[0-9A-F]{2}$", flags=re.IGNORECASE) 47 | UUID_PATTERN: Final = re.compile( 48 | r"^[a-f0-9]{8}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{12}\Z", 49 | flags=re.IGNORECASE, 50 | ) 51 | 52 | 53 | class SMPBLETransportException(SMPClientException): 54 | """Base class for SMP BLE transport exceptions.""" 55 | 56 | 57 | class SMPBLETransportDeviceNotFound(SMPBLETransportException): 58 | """Raised when a BLE device is not found.""" 59 | 60 | 61 | class SMPBLETransportNotSMPServer(SMPBLETransportException): 62 | """Raised when the SMP characteristic UUID is not found.""" 63 | 64 | 65 | logger = logging.getLogger(__name__) 66 | 67 | 68 | class SMPBLETransport(SMPTransport): 69 | """A Bluetooth Low Energy (BLE) SMPTransport.""" 70 | 71 | def __init__(self) -> None: 72 | self._buffer = bytearray() 73 | self._notify_condition = asyncio.Condition() 74 | self._disconnected_event = asyncio.Event() 75 | self._disconnected_event.set() 76 | 77 | self._max_write_without_response_size = 20 78 | """Initially set to BLE minimum; may be mutated by the `connect()` method.""" 79 | 80 | logger.debug(f"Initialized {self.__class__.__name__}") 81 | 82 | @override 83 | async def connect(self, address: str, timeout_s: float) -> None: 84 | logger.debug(f"Scanning for {address=}") 85 | device: BLEDevice | None = ( 86 | await BleakScanner.find_device_by_address(address, timeout=timeout_s) 87 | if MAC_ADDRESS_PATTERN.match(address) or UUID_PATTERN.match(address) 88 | else await BleakScanner.find_device_by_name(address) 89 | ) 90 | 91 | if type(device) is BLEDevice: 92 | self._client = BleakClient( 93 | device, 94 | services=(str(SMP_SERVICE_UUID),), 95 | disconnected_callback=self._set_disconnected_event, 96 | ) 97 | else: 98 | raise SMPBLETransportDeviceNotFound(f"Device '{address}' not found") 99 | 100 | logger.debug(f"Found device: {device=}, connecting...") 101 | await self._client.connect() 102 | self._disconnected_event.clear() 103 | logger.debug(f"Connected to {device=}") 104 | 105 | smp_characteristic = self._client.services.get_characteristic(SMP_CHARACTERISTIC_UUID) 106 | if smp_characteristic is None: 107 | raise SMPBLETransportNotSMPServer("Missing the SMP characteristic UUID.") 108 | 109 | logger.debug(f"Found SMP characteristic: {smp_characteristic=}") 110 | logger.info(f"{smp_characteristic.max_write_without_response_size=}") 111 | self._max_write_without_response_size = smp_characteristic.max_write_without_response_size 112 | if ( 113 | self._winrt_backend(self._client._backend) 114 | and self._max_write_without_response_size == 20 115 | ): 116 | # https://github.com/hbldh/bleak/pull/1552#issuecomment-2105573291 117 | logger.warning( 118 | "The SMP characteristic MTU is 20 bytes, possibly a Windows bug, checking again" 119 | ) 120 | await asyncio.sleep(2) 121 | smp_characteristic._max_write_without_response_size = ( 122 | self._client._backend._session.max_pdu_size - 3 # type: ignore 123 | ) 124 | self._max_write_without_response_size = ( 125 | smp_characteristic.max_write_without_response_size 126 | ) 127 | logger.warning(f"{smp_characteristic.max_write_without_response_size=}") 128 | elif self._bluez_backend(self._client._backend): 129 | logger.debug("Getting MTU from BlueZ backend") 130 | await self._client._backend._acquire_mtu() 131 | logger.debug(f"Got MTU: {self._client.mtu_size}") 132 | self._max_write_without_response_size = self._client.mtu_size - 3 133 | 134 | logger.info(f"{self._max_write_without_response_size=}") 135 | self._smp_characteristic = smp_characteristic 136 | 137 | logger.debug(f"Starting notify on {SMP_CHARACTERISTIC_UUID=}") 138 | await self._client.start_notify(SMP_CHARACTERISTIC_UUID, self._notify_callback) 139 | logger.debug(f"Started notify on {SMP_CHARACTERISTIC_UUID=}") 140 | 141 | @override 142 | async def disconnect(self) -> None: 143 | logger.debug(f"Disonnecting from {self._client.address}") 144 | await self._client.disconnect() 145 | logger.debug(f"Disconnected from {self._client.address}") 146 | 147 | @override 148 | async def send(self, data: bytes) -> None: 149 | logger.debug(f"Sending {len(data)} bytes, {self.mtu=}") 150 | for offset in range(0, len(data), self.mtu): 151 | await self._client.write_gatt_char( 152 | self._smp_characteristic, data[offset : offset + self.mtu], response=False 153 | ) 154 | logger.debug(f"Sent {len(data)} bytes") 155 | 156 | @override 157 | async def receive(self) -> bytes: 158 | # Note: self._buffer is mutated asynchronously by this method and self._notify_callback(). 159 | # self._notify_condition is used to synchronize access to self._buffer. 160 | 161 | async with self._notify_condition: # wait for the header 162 | logger.debug(f"Waiting for notify on {SMP_CHARACTERISTIC_UUID=}") 163 | await self._notify_or_disconnect() 164 | 165 | if len(self._buffer) < smphdr.Header.SIZE: # pragma: no cover 166 | raise SMPBLETransportException( 167 | f"Buffer contents not big enough for SMP header: {self._buffer=}" 168 | ) 169 | 170 | header: Final = smphdr.Header.loads(self._buffer[: smphdr.Header.SIZE]) 171 | logger.debug(f"Received {header=}") 172 | 173 | message_length: Final = header.length + header.SIZE 174 | logger.debug(f"Waiting for the rest of the {message_length} byte response") 175 | 176 | while True: # wait for the rest of the message 177 | async with self._notify_condition: 178 | if len(self._buffer) == message_length: 179 | logger.debug(f"Finished receiving {message_length} byte response") 180 | out = bytes(self._buffer) 181 | self._buffer.clear() 182 | return out 183 | elif len(self._buffer) > message_length: # pragma: no cover 184 | raise SMPBLETransportException("Length of buffer passed expected message size.") 185 | await self._notify_or_disconnect() 186 | 187 | async def _notify_callback(self, sender: BleakGATTCharacteristic, data: bytes) -> None: 188 | if sender.uuid != str(SMP_CHARACTERISTIC_UUID): # pragma: no cover 189 | raise SMPBLETransportException(f"Unexpected notify from {sender}; {data=}") 190 | async with self._notify_condition: 191 | logger.debug(f"Received {len(data)} bytes from {SMP_CHARACTERISTIC_UUID=}") 192 | self._buffer.extend(data) 193 | self._notify_condition.notify() 194 | 195 | async def send_and_receive(self, data: bytes) -> bytes: 196 | await self.send(data) 197 | return await self.receive() 198 | 199 | @override 200 | @property 201 | def mtu(self) -> int: 202 | return self._max_write_without_response_size 203 | 204 | @staticmethod 205 | async def scan(timeout: int = 5) -> List[BLEDevice]: 206 | """Scan for BLE devices.""" 207 | logger.debug(f"Scanning for BLE devices for {timeout} seconds") 208 | devices: Final = await BleakScanner(service_uuids=[str(SMP_SERVICE_UUID)]).discover( 209 | timeout=timeout, return_adv=True 210 | ) 211 | smp_servers: Final = [ 212 | d for d, a in devices.values() if SMP_SERVICE_UUID in {UUID(u) for u in a.service_uuids} 213 | ] 214 | logger.debug(f"Found {len(smp_servers)} SMP devices: {smp_servers=}") 215 | return smp_servers 216 | 217 | @staticmethod 218 | def _bluez_backend(client_backend: BaseBleakClient) -> TypeGuard[BleakClientBlueZDBus]: 219 | return client_backend.__class__.__name__ == "BleakClientBlueZDBus" 220 | 221 | @staticmethod 222 | def _winrt_backend(client_backend: BaseBleakClient) -> TypeGuard[BleakClientWinRT]: 223 | return client_backend.__class__.__name__ == "BleakClientWinRT" 224 | 225 | def _set_disconnected_event(self, client: BleakClient) -> None: 226 | if client is not self._client: 227 | raise SMPBLETransportException( 228 | f"Unexpected client disconnected: {client=}, {self._client=}" 229 | ) 230 | logger.warning(f"Disconnected from {client.address}") 231 | self._disconnected_event.set() 232 | 233 | async def _notify_or_disconnect(self) -> None: 234 | disconnected_task: Final = asyncio.create_task(self._disconnected_event.wait()) 235 | notify_task: Final = asyncio.create_task(self._notify_condition.wait()) 236 | done, pending = await asyncio.wait( 237 | (disconnected_task, notify_task), return_when=asyncio.FIRST_COMPLETED 238 | ) 239 | for task in pending: 240 | task.cancel() 241 | try: 242 | await asyncio.gather(*pending) 243 | except asyncio.CancelledError: 244 | pass 245 | if disconnected_task in done: 246 | raise SMPTransportDisconnected( 247 | f"{self.__class__.__name__} disconnected from {self._client.address}" 248 | ) 249 | -------------------------------------------------------------------------------- /smpclient/transport/serial.py: -------------------------------------------------------------------------------- 1 | """A serial SMPTransport. 2 | 3 | In addition to UART, this transport can be used with USB CDC ACM and CAN. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import asyncio 9 | import logging 10 | import math 11 | import time 12 | from enum import IntEnum, unique 13 | from functools import cached_property 14 | from typing import Final 15 | 16 | from serial import Serial, SerialException 17 | from smp import packet as smppacket 18 | from typing_extensions import override 19 | 20 | from smpclient.transport import SMPTransport, SMPTransportDisconnected 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | def _base64_cost(size: int) -> int: 26 | """The worst case size required to encode `size` `bytes`.""" 27 | 28 | if size == 0: 29 | return 0 30 | 31 | return math.ceil(4 / 3 * size) + 2 32 | 33 | 34 | def _base64_max(size: int) -> int: 35 | """Given a max `size`, return how many bytes can be encoded.""" 36 | 37 | if size < 4: 38 | return 0 39 | 40 | return math.floor(3 / 4 * size) - 2 41 | 42 | 43 | class SMPSerialTransport(SMPTransport): 44 | _POLLING_INTERVAL_S = 0.005 45 | _CONNECTION_RETRY_INTERVAL_S = 0.500 46 | 47 | class _ReadBuffer: 48 | """The state of the read buffer.""" 49 | 50 | @unique 51 | class State(IntEnum): 52 | SMP = 0 53 | """An SMP start or continue delimiter has been received and the 54 | `smp_buffer` is being filled with the remainder of the SMP packet. 55 | """ 56 | 57 | SER = 1 58 | """The SMP start delimiter has not been received and the 59 | `ser_buffer` is being filled with data. 60 | """ 61 | 62 | def __init__(self) -> None: 63 | self.smp = bytearray([]) 64 | """The buffer for the SMP packet.""" 65 | 66 | self.ser = bytearray([]) 67 | """The buffer for serial data that is not part of an SMP packet.""" 68 | 69 | self.state = SMPSerialTransport._ReadBuffer.State.SER 70 | """The state of the read buffer.""" 71 | 72 | def __init__( # noqa: DOC301 73 | self, 74 | max_smp_encoded_frame_size: int = 256, 75 | line_length: int = 128, 76 | line_buffers: int = 2, 77 | baudrate: int = 115200, 78 | bytesize: int = 8, 79 | parity: str = "N", 80 | stopbits: float = 1, 81 | timeout: float | None = None, 82 | xonxoff: bool = False, 83 | rtscts: bool = False, 84 | write_timeout: float | None = None, 85 | dsrdtr: bool = False, 86 | inter_byte_timeout: float | None = None, 87 | exclusive: bool | None = None, 88 | ) -> None: 89 | """Initialize the serial transport. 90 | 91 | Args: 92 | max_smp_encoded_frame_size: The maximum size of an encoded SMP 93 | frame. The SMP server needs to have a buffer large enough to 94 | receive the encoded frame packets and to store the decoded frame. 95 | line_length: The maximum SMP packet size. 96 | line_buffers: The number of line buffers in the serial buffer. 97 | baudrate: The baudrate of the serial connection. OK to ignore for 98 | USB CDC ACM. 99 | bytesize: The number of data bits. 100 | parity: The parity setting. 101 | stopbits: The number of stop bits. 102 | timeout: The read timeout. 103 | xonxoff: Enable software flow control. 104 | rtscts: Enable hardware (RTS/CTS) flow control. 105 | write_timeout: The write timeout. 106 | dsrdtr: Enable hardware (DSR/DTR) flow control. 107 | inter_byte_timeout: The inter-byte timeout. 108 | exclusive: The exclusive access timeout. 109 | 110 | """ 111 | if max_smp_encoded_frame_size < line_length * line_buffers: 112 | logger.error( 113 | f"{max_smp_encoded_frame_size=} is less than {line_length=} * {line_buffers=}!" 114 | ) 115 | elif max_smp_encoded_frame_size != line_length * line_buffers: 116 | logger.warning( 117 | f"{max_smp_encoded_frame_size=} is not equal to {line_length=} * {line_buffers=}!" 118 | ) 119 | 120 | self._max_smp_encoded_frame_size: Final = max_smp_encoded_frame_size 121 | self._line_length: Final = line_length 122 | self._line_buffers: Final = line_buffers 123 | self._conn: Final = Serial( 124 | baudrate=baudrate, 125 | bytesize=bytesize, 126 | parity=parity, 127 | stopbits=stopbits, 128 | timeout=timeout, 129 | xonxoff=xonxoff, 130 | rtscts=rtscts, 131 | write_timeout=write_timeout, 132 | dsrdtr=dsrdtr, 133 | inter_byte_timeout=inter_byte_timeout, 134 | exclusive=exclusive, 135 | ) 136 | self._buffer = SMPSerialTransport._ReadBuffer() 137 | logger.debug(f"Initialized {self.__class__.__name__}") 138 | 139 | @override 140 | async def connect(self, address: str, timeout_s: float) -> None: 141 | self._conn.port = address 142 | logger.debug(f"Connecting to {self._conn.port=}") 143 | start_time: Final = time.time() 144 | while time.time() - start_time <= timeout_s: 145 | try: 146 | self._conn.open() 147 | logger.debug(f"Connected to {self._conn.port=}") 148 | return 149 | except SerialException as e: 150 | logger.debug( 151 | f"Failed to connect to {self._conn.port=}: {e}, " 152 | f"retrying in {SMPSerialTransport._CONNECTION_RETRY_INTERVAL_S} seconds" 153 | ) 154 | await asyncio.sleep(SMPSerialTransport._CONNECTION_RETRY_INTERVAL_S) 155 | 156 | raise TimeoutError(f"Failed to connect to {address=}") 157 | 158 | @override 159 | async def disconnect(self) -> None: 160 | logger.debug(f"Disconnecting from {self._conn.port=}") 161 | self._conn.close() 162 | logger.debug(f"Disconnected from {self._conn.port=}") 163 | 164 | @override 165 | async def send(self, data: bytes) -> None: 166 | if len(data) > self.max_unencoded_size: 167 | raise ValueError( 168 | f"Data size {len(data)} exceeds maximum unencoded size {self.max_unencoded_size}" 169 | ) 170 | logger.debug(f"Sending {len(data)} bytes") 171 | try: 172 | for packet in smppacket.encode(data, line_length=self._line_length): 173 | self._conn.write(packet) 174 | logger.debug(f"Writing encoded packet of size {len(packet)}B; {self._line_length=}") 175 | 176 | # fake async until I get around to replacing pyserial 177 | while self._conn.out_waiting > 0: 178 | await asyncio.sleep(SMPSerialTransport._POLLING_INTERVAL_S) 179 | except SerialException as e: 180 | logger.error(f"Failed to send {len(data)} bytes: {e}") 181 | raise SMPTransportDisconnected( 182 | f"{self.__class__.__name__} disconnected from {self._conn.port}" 183 | ) 184 | 185 | logger.debug(f"Sent {len(data)} bytes") 186 | 187 | @override 188 | async def receive(self) -> bytes: 189 | decoder = smppacket.decode() 190 | next(decoder) 191 | 192 | logger.debug("Waiting for response") 193 | while True: 194 | try: 195 | b = await self._readuntil() 196 | decoder.send(b) 197 | except StopIteration as e: 198 | logger.debug(f"Finished receiving {len(e.value)} byte response") 199 | return e.value 200 | except SerialException as e: 201 | logger.error(f"Failed to receive response: {e}") 202 | raise SMPTransportDisconnected( 203 | f"{self.__class__.__name__} disconnected from {self._conn.port}" 204 | ) 205 | 206 | async def _readuntil(self) -> bytes: 207 | """Read `bytes` until the `delimiter` then return the `bytes` including the `delimiter`.""" 208 | 209 | START_DELIMITER: Final = smppacket.SIXTY_NINE 210 | CONTINUE_DELIMITER: Final = smppacket.FOUR_TWENTY 211 | END_DELIMITER: Final = b"\n" 212 | 213 | # fake async until I get around to replacing pyserial 214 | 215 | i_smp_start = 0 216 | i_smp_end = 0 217 | i_start: int | None = None 218 | i_continue: int | None = None 219 | while True: 220 | if self._buffer.state == SMPSerialTransport._ReadBuffer.State.SER: 221 | # read the entire OS buffer 222 | try: 223 | self._buffer.ser.extend(self._conn.read_all() or []) 224 | except StopIteration: 225 | pass 226 | 227 | try: # search the buffer for the index of the start delimiter 228 | i_start = self._buffer.ser.index(START_DELIMITER) 229 | except ValueError: 230 | i_start = None 231 | 232 | try: # search the buffer for the index of the continue delimiter 233 | i_continue = self._buffer.ser.index(CONTINUE_DELIMITER) 234 | except ValueError: 235 | i_continue = None 236 | 237 | if i_start is not None and i_continue is not None: 238 | i_smp_start = min(i_start, i_continue) 239 | elif i_start is not None: 240 | i_smp_start = i_start 241 | elif i_continue is not None: 242 | i_smp_start = i_continue 243 | else: # no delimiters found yet, clear non SMP data and wait 244 | while True: 245 | try: # search the buffer for newline characters 246 | i = self._buffer.ser.index(b"\n") 247 | try: # log as a string if possible 248 | logger.warning( 249 | f"{self._conn.port}: {self._buffer.ser[:i].decode()}" 250 | ) 251 | except UnicodeDecodeError: # log as bytes if not 252 | logger.warning(f"{self._conn.port}: {self._buffer.ser[:i].hex()}") 253 | self._buffer.ser = self._buffer.ser[i + 1 :] 254 | except ValueError: 255 | break 256 | await asyncio.sleep(SMPSerialTransport._POLLING_INTERVAL_S) 257 | continue 258 | 259 | if i_smp_start != 0: # log the rest of the serial buffer 260 | try: # log as a string if possible 261 | logger.warning( 262 | f"{self._conn.port}: {self._buffer.ser[:i_smp_start].decode()}" 263 | ) 264 | except UnicodeDecodeError: # log as bytes if not 265 | logger.warning(f"{self._conn.port}: {self._buffer.ser[:i_smp_start].hex()}") 266 | 267 | self._buffer.smp = self._buffer.ser[i_smp_start:] 268 | self._buffer.ser.clear() 269 | self._buffer.state = SMPSerialTransport._ReadBuffer.State.SMP 270 | i_smp_end = 0 271 | 272 | # don't await since the buffer may already contain the end delimiter 273 | 274 | elif self._buffer.state == SMPSerialTransport._ReadBuffer.State.SMP: 275 | # read the entire OS buffer 276 | try: 277 | self._buffer.smp.extend(self._conn.read_all() or []) 278 | except StopIteration: 279 | pass 280 | 281 | try: # search the buffer for the index of the delimiter 282 | i_smp_end = self._buffer.smp.index(END_DELIMITER, i_smp_end) + len( 283 | END_DELIMITER 284 | ) 285 | except ValueError: # delimiter not found yet, wait 286 | await asyncio.sleep(SMPSerialTransport._POLLING_INTERVAL_S) 287 | continue 288 | 289 | # out is everything up to and including the delimiter 290 | out = self._buffer.smp[:i_smp_end] 291 | logger.debug(f"Received {len(out)} byte chunk") 292 | 293 | # there may be some leftover to save for the next read, but 294 | # it's not necessarily SMP data 295 | self._buffer.ser = self._buffer.smp[i_smp_end:] 296 | 297 | self._buffer.state = SMPSerialTransport._ReadBuffer.State.SER 298 | 299 | return out 300 | 301 | @override 302 | async def send_and_receive(self, data: bytes) -> bytes: 303 | await self.send(data) 304 | return await self.receive() 305 | 306 | @override 307 | @property 308 | def mtu(self) -> int: 309 | return self._max_smp_encoded_frame_size 310 | 311 | @override 312 | @cached_property 313 | def max_unencoded_size(self) -> int: 314 | """The serial transport encodes each packet instead of sending SMP messages as raw bytes.""" 315 | 316 | # For each packet, AKA line_buffer, include the cost of the base64 317 | # encoded frame_length and CRC16 and the start/continue delimiter. 318 | # Add to that the cost of the stop delimiter. 319 | packet_framing_size: Final = ( 320 | _base64_cost(smppacket.FRAME_LENGTH_STRUCT.size + smppacket.CRC16_STRUCT.size) 321 | + smppacket.DELIMITER_SIZE 322 | ) * self._line_buffers + len(smppacket.END_DELIMITER) 323 | 324 | # Get the number of unencoded bytes that can fit in self.mtu and 325 | # subtract the cost of framing the separate packets. 326 | # This is the maximum number of unencoded bytes that can be received by 327 | # the SMP server with this transport configuration. 328 | return _base64_max(self.mtu) - packet_framing_size 329 | -------------------------------------------------------------------------------- /smpclient/transport/udp.py: -------------------------------------------------------------------------------- 1 | """A UDP SMPTransport for Network connections like Wi-Fi or Ethernet.""" 2 | 3 | import asyncio 4 | import logging 5 | from typing import Final 6 | 7 | from smp import header as smphdr 8 | from typing_extensions import override 9 | 10 | from smpclient.exceptions import SMPClientException 11 | from smpclient.transport import SMPTransport 12 | from smpclient.transport._udp_client import Addr, UDPClient 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class SMPUDPTransport(SMPTransport): 18 | def __init__(self, mtu: int = 1500) -> None: 19 | """Initialize the SMP UDP transport. 20 | 21 | Args: 22 | mtu: The Maximum Transmission Unit (MTU) in 8-bit bytes. 23 | """ 24 | self._mtu = mtu 25 | 26 | self._client: Final = UDPClient() 27 | 28 | @override 29 | async def connect(self, address: str, timeout_s: float, port: int = 1337) -> None: 30 | logger.debug(f"Connecting to {address=} {port=}") 31 | await asyncio.wait_for(self._client.connect(Addr(host=address, port=port)), timeout_s) 32 | logger.info(f"Connected to {address=} {port=}") 33 | 34 | @override 35 | async def disconnect(self) -> None: 36 | logger.debug("Disconnecting from transport") 37 | self._client.disconnect() 38 | 39 | if not self._client._protocol.error_queue.empty(): 40 | logger.warning( 41 | f"{self._client._protocol.error_queue.qsize()} exceptions were uncollected before " 42 | "disconnecting, fetching them now" 43 | ) 44 | while True: 45 | try: 46 | logger.warning(f"{self._client._protocol.error_queue.get_nowait()}") 47 | except asyncio.QueueEmpty: 48 | break 49 | 50 | logger.info("Disconnected from transport") 51 | 52 | @override 53 | async def send(self, data: bytes) -> None: 54 | if len(data) > self.max_unencoded_size: 55 | logger.warning( 56 | "Fragmenting UDP packets is not recommended: " 57 | f"{len(data)=} B > {self.max_unencoded_size=} B" 58 | ) 59 | 60 | logger.debug(f"Sending {len(data)} B") 61 | for offset in range(0, len(data), self.max_unencoded_size): 62 | self._client.send(data[offset : offset + self.max_unencoded_size]) 63 | logger.debug(f"Sent {len(data)} B") 64 | 65 | @override 66 | async def receive(self) -> bytes: 67 | logger.debug("Awaiting data") 68 | 69 | first_packet: Final = await self._client.receive() 70 | logger.debug(f"Received {len(first_packet)} B") 71 | 72 | header: Final = smphdr.Header.loads(first_packet[: smphdr.Header.SIZE]) 73 | logger.debug(f"Received {header=}") 74 | 75 | message_length: Final = header.length + smphdr.Header.SIZE 76 | message: Final = bytearray(first_packet) 77 | 78 | if len(message) != message_length: 79 | logger.debug(f"Waiting for the rest of the {message_length} B response") 80 | while len(message) < message_length: 81 | packet = await self._client.receive() 82 | logger.debug(f"Received {len(packet)} B") 83 | message.extend(packet) 84 | if len(message) > message_length: 85 | error: Final = ( 86 | f"Received more data than expected: {len(message)} B > {message_length} B" 87 | ) 88 | logger.error(error) 89 | raise SMPClientException(error) 90 | 91 | logger.debug(f"Finished receiving message of length {message_length} B") 92 | return message 93 | 94 | @override 95 | async def send_and_receive(self, data: bytes) -> bytes: 96 | await self.send(data) 97 | return await self.receive() 98 | 99 | @override 100 | @property 101 | def mtu(self) -> int: 102 | return self._mtu 103 | 104 | @override 105 | @property 106 | def max_unencoded_size(self) -> int: 107 | return self._mtu 108 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/tests/__init__.py -------------------------------------------------------------------------------- /tests/extensions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/tests/extensions/__init__.py -------------------------------------------------------------------------------- /tests/extensions/test_intercreate.py: -------------------------------------------------------------------------------- 1 | """Test the Intercreate extensions.""" 2 | 3 | from pathlib import Path 4 | from typing import List 5 | from unittest.mock import PropertyMock, patch 6 | 7 | import pytest 8 | from smp import packet as smppacket 9 | from smp.user import intercreate as smpic 10 | 11 | from smpclient.extensions.intercreate import ICUploadClient 12 | from smpclient.requests.user import intercreate as ic 13 | from smpclient.transport.serial import SMPSerialTransport 14 | 15 | 16 | @patch('tests.test_smp_client.SMPSerialTransport.mtu', new_callable=PropertyMock) 17 | @pytest.mark.asyncio 18 | async def test_upload_hello_world_bin_encoded(mock_mtu: PropertyMock) -> None: 19 | mock_mtu.return_value = 127 # testing at 127, the default for Shell Transport 20 | 21 | with open( 22 | str(Path("tests", "fixtures", "zephyr-v3.5.0-2795-g28ff83515d", "hello_world.signed.bin")), 23 | 'rb', 24 | ) as f: 25 | image = f.read() 26 | 27 | m = SMPSerialTransport() 28 | s = ICUploadClient(m, "address") 29 | assert s._transport.mtu == 127 30 | assert s._transport.max_unencoded_size < 127 31 | 32 | packets: List[bytes] = [] 33 | 34 | def mock_write(data: bytes) -> int: 35 | """Accumulate the raw packets in the global `packets`.""" 36 | assert len(data) <= s._transport.mtu 37 | packets.append(data) 38 | return len(data) 39 | 40 | s._transport._conn.write = mock_write # type: ignore 41 | type(s._transport._conn).out_waiting = 0 # type: ignore 42 | 43 | async def mock_request(request: ic.ImageUploadWrite) -> smpic.ImageUploadWriteResponse: 44 | # call the real send method (with write mocked) but don't bother with receive 45 | # this does provide coverage for the MTU-limited encoding done in the send method 46 | await s._transport.send(request.BYTES) 47 | return ic.ImageUploadWrite._Response.get_default()(off=request.off + len(request.data)) # type: ignore # noqa 48 | 49 | s.request = mock_request # type: ignore 50 | 51 | assert ( 52 | s._transport.max_unencoded_size < s._transport.mtu 53 | ), "The serial transport has encoding overhead" 54 | 55 | async for _ in s.ic_upload(image): 56 | pass 57 | 58 | reconstructed_image = bytearray([]) 59 | 60 | decoder = smppacket.decode() 61 | next(decoder) 62 | 63 | for packet in packets: 64 | try: 65 | decoder.send(packet) 66 | except StopIteration as e: 67 | reconstructed_request = smpic.ImageUploadWriteRequest.loads(e.value) 68 | reconstructed_image.extend(reconstructed_request.data) 69 | 70 | decoder = smppacket.decode() 71 | next(decoder) 72 | 73 | assert reconstructed_image == image 74 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intercreate/smpclient/dcace1aae61910ff25d8a674fcb8351a538f95b3/tests/fixtures/__init__.py -------------------------------------------------------------------------------- /tests/fixtures/analyze-mcuboot-img.py: -------------------------------------------------------------------------------- 1 | # pragma: no cover 2 | # noqa 3 | # type: ignore 4 | 5 | # NOTE: copied from https://gist.github.com/mbolivar/285309cca792f746d6c698f56941041a 6 | 7 | # Copyright (c) 2018 Foundries.io 8 | # 9 | # SPDX-License-Identifier: Apache-2.0 10 | 11 | import argparse 12 | import struct 13 | from collections import namedtuple 14 | 15 | # Field names for mcuboot header, with struct image_version inlined, 16 | # as well as struct module format string and reprs format strings for 17 | # each. 18 | IMG_HDR_FIELDS = [ 19 | 'magic', 20 | 'load_addr', 21 | 'hdr_size', 22 | 'img_size', 23 | 'flags', 24 | 'ver_major', 25 | 'ver_minor', 26 | 'ver_revision', 27 | 'ver_build_num', 28 | ] 29 | IMG_HDR_FMT = ' bytes: 14 | """Generate `n` random bytes.""" 15 | return urandom(n) 16 | 17 | random.randbytes = randbytes 18 | 19 | 20 | def test_base64_sizing() -> None: 21 | """Assert that `_base64_max` is always within 4 of encoded size.""" 22 | 23 | random.seed(1) 24 | 25 | for size in range(1, 0xFFFF): 26 | assert 0 <= size - _base64_cost(_base64_max(size)) < 4 27 | data = random.randbytes(_base64_max(size)) 28 | encoded = b64encode(data) 29 | assert 0 <= size - len(encoded) < 4 30 | -------------------------------------------------------------------------------- /tests/test_mcuboot_tools.py: -------------------------------------------------------------------------------- 1 | """Test the FW image inspection tools.""" 2 | 3 | from __future__ import annotations 4 | 5 | import struct 6 | from pathlib import Path 7 | from typing import Protocol 8 | 9 | import pytest 10 | 11 | from smpclient.mcuboot import ( 12 | IMAGE_MAGIC, 13 | IMAGE_TLV, 14 | IMAGE_TLV_INFO_MAGIC, 15 | ImageHeader, 16 | ImageInfo, 17 | ImageVersion, 18 | ) 19 | 20 | 21 | class _ImageFileFixture(Protocol): 22 | PATH: Path 23 | SHA256: bytes 24 | KEYHASH: bytes 25 | RSA2048_PSS: bytes 26 | 27 | 28 | class _HELLO_WORLD_SIGNED_BASE: 29 | SHA256 = bytes.fromhex("90a0d88baaa733640dab01fd8e9311dbe8ea1032966b6b286ef6ef772cc608cf") 30 | KEYHASH = bytes.fromhex("fc5701dc6135e1323847bdc40f04d2e5bee5833b23c29f93593d00018cfa9994") 31 | 32 | 33 | class SIGNED_BIN(_HELLO_WORLD_SIGNED_BASE): 34 | PATH = Path("tests", "fixtures", "zephyr-v3.5.0-2795-g28ff83515d", "hello_world.signed.bin") 35 | RSA2048_PSS = bytes.fromhex( 36 | "457dde4937c30fc253fc98c241defb2c2d8f50a7cd74d9166629c5498fcaa822210bccfd6468ae9846a8a52fa0eaa647d9b5ffdcaea4fb397ce2a0e5912a4933fe6945ec65ddf826496cde2fd0530ff105ce37405e7bc60d6b52ee01f317a1219db3d49e48be9798095254d135d55b832bbe60780b9bf61f95fb83b1131ae576dd33945895bd0a8c870c425342449d211155fa87b134c2c3164319c46827106c27c67c9f7418877aab48164aaf567b2c21964a26735f5746400198ae1fd94f2f56f26eebbd38e3e3d4c36f6c764f9ee4639cf99adaced9d38966fc0879f3005d697e3b588b71b6bb08a466384080353ead7c3b1fb8eed51af6497ef1f1d836be" # noqa 37 | ) 38 | 39 | 40 | class SIGNED_HEX(_HELLO_WORLD_SIGNED_BASE): 41 | PATH = Path("tests", "fixtures", "zephyr-v3.5.0-2795-g28ff83515d", "hello_world.signed.hex") 42 | RSA2048_PSS = bytes.fromhex( 43 | "46d0c082b77b48a70af315db284beaf4cedae49a51f1aa935df934a2e14a6762773c43c926809cf0bd83b2e944c06d2666617083cdc7afbd358070e207759a6100997602e63313c2d3dcd68f7d8c04ab381751f3d96e7908076fee25b157c9d5922ddd2007c1a2f9104d1196dc7d702ee64b27db710f043d80c3e371e84682c0de402b7e3447a34900c71da3ba3bc7681c7cd28273b6e6f7c99bd731bd289d1710e0fbeb4619556ab0e4f343b09c394993e745acc450ef58589148d9daf8a63214d66ad09186503dd07a9c110f6c5cad2f3075838806c42c78c431454c947186e09f969f9564f1ba30771dc9df76985b2dbc47a7fe2bd2c2436b8c890b8e0de8" # noqa 44 | ) 45 | 46 | 47 | @pytest.mark.parametrize("image", [SIGNED_BIN, SIGNED_HEX]) 48 | def test_ImageInfo(image: _ImageFileFixture) -> None: 49 | image_info = ImageInfo.load_file(str(image.PATH)) 50 | 51 | assert image_info.file == str(image.PATH) 52 | 53 | # header 54 | h = image_info.header 55 | assert h.magic == IMAGE_MAGIC 56 | assert h.load_addr == 0 57 | assert h.hdr_size == 512 58 | assert h.protect_tlv_size == 0 59 | assert h.img_size == 24692 60 | assert h.flags == 0 61 | assert h.ver.major == 0 62 | assert h.ver.minor == 0 63 | assert h.ver.revision == 0 64 | assert h.ver.build_num == 0 65 | 66 | # TLV header 67 | t = image_info.tlv_info 68 | assert t.magic == IMAGE_TLV_INFO_MAGIC 69 | assert t.tlv_tot == 336 70 | 71 | # TLVs 72 | assert len(image_info.tlvs) == 3 73 | 74 | # IMAGE_TLV_SHA256 75 | v = image_info.get_tlv(IMAGE_TLV.SHA256) 76 | assert v.header.len == 32 77 | assert v.header.type == IMAGE_TLV.SHA256 78 | assert v.value == image.SHA256 79 | 80 | # IMAGE_TLV_KEYHASH 81 | v = image_info.get_tlv(IMAGE_TLV.KEYHASH) 82 | assert v.header.len == 32 83 | assert v.header.type == IMAGE_TLV.KEYHASH 84 | assert v.value == image.KEYHASH 85 | 86 | # IMAGE_TLV_RSA2048_PSS 87 | v = image_info.get_tlv(IMAGE_TLV.RSA2048_PSS) 88 | assert v.header.len == 256 89 | assert v.header.type == IMAGE_TLV.RSA2048_PSS 90 | assert v.value == image.RSA2048_PSS 91 | 92 | 93 | @pytest.mark.parametrize("image", [SIGNED_BIN]) 94 | def test_ImageHeader(image: _ImageFileFixture) -> None: 95 | h = ImageHeader.load_file(str(image.PATH)) 96 | 97 | assert h.magic == IMAGE_MAGIC 98 | assert h.load_addr == 0 99 | assert h.hdr_size == 512 100 | assert h.protect_tlv_size == 0 101 | assert h.img_size == 24692 102 | assert h.flags == 0 103 | assert h.ver.major == 0 104 | assert h.ver.minor == 0 105 | assert h.ver.revision == 0 106 | assert h.ver.build_num == 0 107 | 108 | 109 | def test_ImageVersion() -> None: 110 | v = ImageVersion.loads(struct.pack(" None: 272 | a, b, Response, ErrorV1, ErrorV2 = test_tuple 273 | 274 | # assert that headers match (other than sequence) 275 | assert a.header.op == b.header.op 276 | assert a.header.version == b.header.version 277 | assert a.header.flags == b.header.flags 278 | assert a.header.length == b.header.length 279 | assert a.header.group_id == b.header.group_id 280 | assert a.header.command_id == b.header.command_id 281 | 282 | # assert that the CBOR payloads match 283 | amodel = a.model_dump(exclude_unset=True, exclude={'header'}, exclude_none=True) 284 | bmodel = b.model_dump(exclude_unset=True, exclude={'header'}, exclude_none=True) # type: ignore 285 | assert amodel == bmodel 286 | assert a.BYTES[smphdr.Header.SIZE :] == b.BYTES[smphdr.Header.SIZE :] 287 | 288 | # assert that the response and error types are as expected 289 | assert b._Response is Response 290 | assert b._ErrorV1 is ErrorV1 291 | assert b._ErrorV2 is ErrorV2 292 | # assert that the response and error types are as expected 293 | assert b._Response is Response 294 | assert b._ErrorV1 is ErrorV1 295 | assert b._ErrorV2 is ErrorV2 296 | -------------------------------------------------------------------------------- /tests/test_smp_ble_transport.py: -------------------------------------------------------------------------------- 1 | """Tests for `SMPBLETransport`.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | from typing import cast 7 | from unittest.mock import AsyncMock, MagicMock, patch 8 | from uuid import UUID 9 | 10 | import pytest 11 | from bleak import BleakClient, BleakGATTCharacteristic 12 | from bleak.backends.device import BLEDevice 13 | 14 | from smpclient.requests.os_management import EchoWrite 15 | from smpclient.transport.ble import ( 16 | MAC_ADDRESS_PATTERN, 17 | SMP_CHARACTERISTIC_UUID, 18 | SMP_SERVICE_UUID, 19 | UUID_PATTERN, 20 | SMPBLETransport, 21 | SMPBLETransportDeviceNotFound, 22 | ) 23 | 24 | 25 | class MockBleakClient: 26 | class Backend: 27 | ... 28 | 29 | def __new__(cls, *args, **kwargs) -> "MockBleakClient": # type: ignore 30 | client = MagicMock(spec=BleakClient, name="MockBleakClient") 31 | client._backend = MockBleakClient.Backend() 32 | return client 33 | 34 | 35 | def test_constructor() -> None: 36 | t = SMPBLETransport() 37 | assert t._buffer == bytearray() 38 | assert isinstance(t._notify_condition, asyncio.Condition) 39 | 40 | 41 | def test_MAC_ADDRESS_PATTERN() -> None: 42 | assert MAC_ADDRESS_PATTERN.match("00:00:00:00:00:00") 43 | assert MAC_ADDRESS_PATTERN.match("FF:FF:FF:FF:FF:FF") 44 | assert MAC_ADDRESS_PATTERN.match("00:FF:00:FF:00:FF") 45 | assert MAC_ADDRESS_PATTERN.match("FF:00:FF:00:FF:00") 46 | 47 | assert not MAC_ADDRESS_PATTERN.match("00:00:00:00:00") 48 | assert not MAC_ADDRESS_PATTERN.match("00:00:00:00:00:00:00") 49 | assert not MAC_ADDRESS_PATTERN.match("00:00:00:00:00:00:00:00") 50 | assert not MAC_ADDRESS_PATTERN.match("00:00:00:00:00:00:00:00:00") 51 | assert not MAC_ADDRESS_PATTERN.match("00:00:00:00:00:0G") 52 | assert not MAC_ADDRESS_PATTERN.match("00:00:00:00:00:00:0G") 53 | assert not MAC_ADDRESS_PATTERN.match("00:00:00:00:00:00:00:0G") 54 | assert not MAC_ADDRESS_PATTERN.match("00:00:00:00:00:00:00:00:0G") 55 | 56 | 57 | def test_UUID_PATTERN() -> None: 58 | assert UUID_PATTERN.match("00000000-0000-4000-8000-000000000000") 59 | assert UUID_PATTERN.match("FFFFFFFF-FFFF-4FFF-9FFF-FFFFFFFFFFFF") 60 | assert UUID_PATTERN.match("0000FFFF-0000-4FFF-a000-FFFFFFFFFFFF") 61 | assert UUID_PATTERN.match("FFFF0000-FFFF-4000-bFFF-000000000000") 62 | 63 | assert UUID_PATTERN.match(UUID("00000000-0000-4000-8000-000000000000").hex) 64 | assert UUID_PATTERN.match(UUID("FFFFFFFF-FFFF-4FFF-9FFF-FFFFFFFFFFFF").hex) 65 | assert UUID_PATTERN.match(UUID("0000FFFF-0000-4FFF-a000-FFFFFFFFFFFF").hex) 66 | assert UUID_PATTERN.match(UUID("FFFF0000-FFFF-4000-bFFF-000000000000").hex) 67 | 68 | 69 | def test_SMP_gatt_consts() -> None: 70 | assert SMP_CHARACTERISTIC_UUID == UUID("DA2E7828-FBCE-4E01-AE9E-261174997C48") 71 | assert SMP_SERVICE_UUID == UUID("8D53DC1D-1DB7-4CD3-868B-8A527460AA84") 72 | 73 | 74 | @patch( 75 | "smpclient.transport.ble.BleakScanner.find_device_by_address", 76 | return_value=BLEDevice("address", "name", None, -60), 77 | ) 78 | @patch( 79 | "smpclient.transport.ble.BleakScanner.find_device_by_name", 80 | return_value=BLEDevice("address", "name", None, -60), 81 | ) 82 | @patch("smpclient.transport.ble.BleakClient", new=MockBleakClient) 83 | @pytest.mark.asyncio 84 | async def test_connect( 85 | mock_find_device_by_name: MagicMock, 86 | mock_find_device_by_address: MagicMock, 87 | ) -> None: 88 | # assert that it searches by name if MAC or UUID is not provided 89 | await SMPBLETransport().connect("device name", 1.0) 90 | mock_find_device_by_name.assert_called_once_with("device name") 91 | mock_find_device_by_name.reset_mock() 92 | 93 | # assert that it searches by MAC if MAC is provided 94 | await SMPBLETransport().connect("00:00:00:00:00:00", 1.0) 95 | mock_find_device_by_address.assert_called_once_with("00:00:00:00:00:00", timeout=1.0) 96 | mock_find_device_by_address.reset_mock() 97 | 98 | # assert that it searches by UUID if UUID is provided 99 | await SMPBLETransport().connect(UUID("00000000-0000-4000-8000-000000000000").hex, 1.0) 100 | mock_find_device_by_address.assert_called_once_with( 101 | "00000000000040008000000000000000", timeout=1.0 102 | ) 103 | mock_find_device_by_address.reset_mock() 104 | 105 | # assert that it raises an exception if the device is not found 106 | mock_find_device_by_address.return_value = None 107 | with pytest.raises(SMPBLETransportDeviceNotFound): 108 | await SMPBLETransport().connect("00:00:00:00:00:00", 1.0) 109 | mock_find_device_by_address.reset_mock() 110 | 111 | # assert that connect is awaited 112 | t = SMPBLETransport() 113 | await t.connect("name", 1.0) 114 | t._client = cast(MagicMock, t._client) 115 | t._client.reset_mock() 116 | await t.connect("name", 1.0) 117 | t._client.connect.assert_awaited_once_with() 118 | 119 | # these are hard to mock now because the _client is created in the connect method 120 | # reenable these after the SMPTransport Protocol is updated to take address 121 | # at initialization rather than in the connect method - a BREAKING CHANGE 122 | 123 | # # assert that the SMP characteristic is checked 124 | # t._client.services.get_characteristic.assert_called_once_with(SMP_CHARACTERISTIC_UUID) 125 | 126 | # # assert that an exception is raised if the SMP characteristic is not found 127 | # t._client.services.get_characteristic.return_value = None 128 | # with pytest.raises(SMPBLETransportNotSMPServer): 129 | # await t.connect("name", 1.0) 130 | # t._client.reset_mock() 131 | 132 | # # assert that the SMP characteristic is saved 133 | # m = MagicMock() 134 | # t._client.services.get_characteristic.return_value = m 135 | # await t.connect("name", 1.0) 136 | # assert t._smp_characteristic is m 137 | 138 | # assert that SMP characteristic notifications are started 139 | t._client.start_notify.assert_called_once_with(SMP_CHARACTERISTIC_UUID, t._notify_callback) 140 | 141 | 142 | @pytest.mark.asyncio 143 | async def test_disconnect() -> None: 144 | t = SMPBLETransport() 145 | t._client = MagicMock(spec=BleakClient) 146 | await t.disconnect() 147 | t._client.disconnect.assert_awaited_once_with() 148 | 149 | 150 | @pytest.mark.asyncio 151 | async def test_send() -> None: 152 | t = SMPBLETransport() 153 | t._client = MagicMock(spec=BleakClient) 154 | t._smp_characteristic = MagicMock(spec=BleakGATTCharacteristic) 155 | t._smp_characteristic.max_write_without_response_size = 20 156 | await t.send(b"Hello pytest!") 157 | t._client.write_gatt_char.assert_awaited_once_with( 158 | t._smp_characteristic, b"Hello pytest!", response=False 159 | ) 160 | 161 | 162 | @pytest.mark.asyncio 163 | async def test_receive() -> None: 164 | t = SMPBLETransport() 165 | t._client = MagicMock(spec=BleakClient) 166 | t._smp_characteristic = MagicMock(spec=BleakGATTCharacteristic) 167 | t._smp_characteristic.uuid = str(SMP_CHARACTERISTIC_UUID) 168 | t._disconnected_event.clear() # pretend t.connect() was successful 169 | 170 | REP = EchoWrite._Response.get_default()(sequence=0, r="Hello pytest!").BYTES # type: ignore 171 | 172 | b, _ = await asyncio.gather( 173 | t.receive(), 174 | t._notify_callback(t._smp_characteristic, REP), 175 | ) 176 | 177 | assert b == REP 178 | 179 | # cool, now try with a fragmented response 180 | async def fragmented_notifies() -> None: 181 | await t._notify_callback(t._smp_characteristic, REP[:10]) 182 | await asyncio.sleep(0.001) 183 | await t._notify_callback(t._smp_characteristic, REP[10:]) 184 | 185 | b, _ = await asyncio.gather( 186 | t.receive(), 187 | fragmented_notifies(), 188 | ) 189 | 190 | assert b == REP 191 | 192 | 193 | @pytest.mark.asyncio 194 | async def test_send_and_receive() -> None: 195 | t = SMPBLETransport() 196 | t.send = AsyncMock() # type: ignore 197 | t.receive = AsyncMock() # type: ignore 198 | await t.send_and_receive(b"Hello pytest!") 199 | t.send.assert_awaited_once_with(b"Hello pytest!") 200 | t.receive.assert_awaited_once_with() 201 | 202 | 203 | def test_max_unencoded_size() -> None: 204 | t = SMPBLETransport() 205 | t._client = MagicMock(spec=BleakClient) 206 | t._max_write_without_response_size = 42 207 | assert t.max_unencoded_size == 42 208 | 209 | 210 | def test_max_unencoded_size_mcumgr_param() -> None: 211 | t = SMPBLETransport() 212 | t._client = MagicMock(spec=BleakClient) 213 | t._smp_server_transport_buffer_size = 9001 214 | assert t.max_unencoded_size == 9001 215 | -------------------------------------------------------------------------------- /tests/test_smp_serial_transport.py: -------------------------------------------------------------------------------- 1 | """Tests for `SMPSerialTransport`.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch 7 | 8 | import pytest 9 | from serial import Serial 10 | from smp import packet as smppacket 11 | 12 | from smpclient.requests.os_management import EchoWrite 13 | from smpclient.transport.serial import SMPSerialTransport 14 | 15 | 16 | def test_constructor() -> None: 17 | t = SMPSerialTransport() 18 | assert isinstance(t._conn, Serial) 19 | 20 | t = SMPSerialTransport(max_smp_encoded_frame_size=512, line_length=128, line_buffers=4) 21 | assert isinstance(t._conn, Serial) 22 | assert t.mtu == 512 23 | assert t.max_unencoded_size < 512 24 | 25 | 26 | @patch("smpclient.transport.serial.Serial") 27 | @pytest.mark.asyncio 28 | async def test_connect(_: MagicMock) -> None: 29 | t = SMPSerialTransport() 30 | 31 | await t.connect("COM2", 1.0) 32 | assert t._conn.port == "COM2" 33 | 34 | t._conn.open.assert_called_once() # type: ignore 35 | 36 | t._conn.reset_mock() # type: ignore 37 | 38 | t = SMPSerialTransport() 39 | 40 | await t.connect("/dev/ttyACM0", 1.0) 41 | assert t._conn.port == "/dev/ttyACM0" 42 | 43 | t._conn.open.assert_called_once() # type: ignore 44 | 45 | 46 | @patch("smpclient.transport.serial.Serial") 47 | @pytest.mark.asyncio 48 | async def test_disconnect(_: MagicMock) -> None: 49 | t = SMPSerialTransport() 50 | await t.disconnect() 51 | t._conn.close.assert_called_once() # type: ignore 52 | 53 | 54 | @pytest.mark.asyncio 55 | async def test_send() -> None: 56 | t = SMPSerialTransport() 57 | t._conn.write = MagicMock() # type: ignore 58 | p = PropertyMock(return_value=0) 59 | type(t._conn).out_waiting = p # type: ignore 60 | 61 | r = EchoWrite(d="Hello pytest!") 62 | await t.send(r.BYTES) 63 | t._conn.write.assert_called_once() 64 | p.assert_called_once_with() 65 | 66 | t._conn.write.reset_mock() 67 | p = PropertyMock(side_effect=(1, 0)) 68 | type(t._conn).out_waiting = p # type: ignore 69 | 70 | await t.send(r.BYTES) 71 | t._conn.write.assert_called_once() 72 | assert p.call_count == 2 # called twice since out buffer was not drained on first call 73 | 74 | 75 | @pytest.mark.asyncio 76 | async def test_receive() -> None: 77 | t = SMPSerialTransport() 78 | m = EchoWrite._Response.get_default()(sequence=0, r="Hello pytest!") # type: ignore 79 | p = [p for p in smppacket.encode(m.BYTES, t.max_unencoded_size)] 80 | t._readuntil = AsyncMock(side_effect=p) # type: ignore 81 | 82 | b = await t.receive() 83 | t._readuntil.assert_awaited_once_with() 84 | assert b == m.BYTES 85 | 86 | p = [p for p in smppacket.encode(m.BYTES, 8)] # test packet fragmentation 87 | t._readuntil = AsyncMock(side_effect=p) # type: ignore 88 | 89 | b = await t.receive() 90 | t._readuntil.assert_awaited() 91 | assert b == m.BYTES 92 | 93 | 94 | @pytest.mark.asyncio 95 | async def test_readuntil() -> None: 96 | t = SMPSerialTransport() 97 | m1 = EchoWrite._Response.get_default()(sequence=0, r="Hello pytest!") # type: ignore 98 | m2 = EchoWrite._Response.get_default()(sequence=1, r="Hello computer!") # type: ignore 99 | p1 = [p for p in smppacket.encode(m1.BYTES, 8)] 100 | p2 = [p for p in smppacket.encode(m2.BYTES, 8)] 101 | packets = p1 + p2 102 | t._conn.read_all = MagicMock(side_effect=packets) # type: ignore 103 | 104 | for p in packets: 105 | assert p == await t._readuntil() 106 | 107 | # do again, but manually fragment the buffers 108 | packets = [p for p in smppacket.encode(m1.BYTES, 512)] + [ 109 | p for p in smppacket.encode(m2.BYTES, 512) 110 | ] 111 | assert len(packets) == 2 112 | buffers = [ 113 | packets[0][0:3], 114 | packets[0][3:5], 115 | packets[0][5:12], 116 | packets[0][12:] + packets[1][0:3], 117 | packets[1][3:5], 118 | packets[1][5:12], 119 | packets[1][12:], 120 | ] 121 | 122 | t._conn.read_all = MagicMock(side_effect=buffers) # type: ignore 123 | 124 | for p in packets: 125 | assert p == await t._readuntil() 126 | 127 | 128 | @pytest.mark.asyncio 129 | async def test_readuntil_with_smp_server_logging(caplog: pytest.LogCaptureFixture) -> None: 130 | t = SMPSerialTransport() 131 | m1 = EchoWrite._Response.get_default()(sequence=0, r="Hello pytest!") # type: ignore 132 | m2 = EchoWrite._Response.get_default()(sequence=1, r="Hello computer!") # type: ignore 133 | p1 = [p for p in smppacket.encode(m1.BYTES, 8)] 134 | p2 = [p for p in smppacket.encode(m2.BYTES, 8)] 135 | packets = p1 + p2 136 | 137 | t._conn.read_all = MagicMock( # type: ignore 138 | side_effect=( 139 | [b"Hi, there!"] 140 | + [b"newline \n"] 141 | + [b"Another line\nAgain \n"] 142 | + [b"log with no newline"] 143 | + p1 144 | + [b"Thought \n I'd just say hi!\n"] 145 | + [bytes([0, 1, 2, 3])] 146 | + [b"Bye!\n"] 147 | + p2 148 | + [b"One more thing...\n"] 149 | + [b"We \n could \n use \n newlines\n"] 150 | ) 151 | ) 152 | 153 | t._conn.port = "/dev/ttyUSB0" 154 | 155 | with caplog.at_level(logging.WARNING): 156 | for p in packets: 157 | assert p == await t._readuntil() 158 | 159 | messages = {r.message for r in caplog.records} 160 | assert "/dev/ttyUSB0: Hi, there!newline " in messages 161 | assert "/dev/ttyUSB0: Another line" in messages 162 | assert "/dev/ttyUSB0: Again " in messages 163 | assert "/dev/ttyUSB0: log with no newline" in messages 164 | assert "/dev/ttyUSB0: Thought \n I'd just say hi!\n\x00\x01\x02\x03Bye!\n" in messages 165 | 166 | 167 | @pytest.mark.asyncio 168 | async def test_send_and_receive() -> None: 169 | t = SMPSerialTransport() 170 | t.send = AsyncMock() # type: ignore 171 | t.receive = AsyncMock() # type: ignore 172 | 173 | await t.send_and_receive(b"some data") 174 | 175 | t.send.assert_awaited_once_with(b"some data") 176 | t.receive.assert_awaited_once_with() 177 | -------------------------------------------------------------------------------- /tests/test_smp_udp_transport.py: -------------------------------------------------------------------------------- 1 | """Tests for `SMPUDPTransport`.""" 2 | 3 | import asyncio 4 | from typing import Final, cast 5 | from unittest.mock import AsyncMock, MagicMock, call, patch 6 | 7 | import pytest 8 | 9 | from smpclient.exceptions import SMPClientException 10 | from smpclient.requests.os_management import EchoWrite 11 | from smpclient.transport._udp_client import Addr, UDPClient 12 | from smpclient.transport.udp import SMPUDPTransport 13 | 14 | 15 | def test_init() -> None: 16 | t = SMPUDPTransport() 17 | assert t.mtu == 1500 18 | assert isinstance(t._client, UDPClient) 19 | 20 | t = SMPUDPTransport(mtu=512) 21 | assert t.mtu == 512 22 | 23 | 24 | @patch("smpclient.transport.udp.UDPClient", autospec=True) 25 | @pytest.mark.asyncio 26 | async def test_connect(_: MagicMock) -> None: 27 | t = SMPUDPTransport() 28 | t._client = cast(MagicMock, t._client) # type: ignore 29 | 30 | await t.connect("192.168.0.1", 0.001) 31 | t._client.connect.assert_awaited_once_with(Addr(host="192.168.0.1", port=1337)) 32 | 33 | 34 | @patch("smpclient.transport.udp.UDPClient", autospec=True) 35 | @pytest.mark.asyncio 36 | async def test_disconnect(_: MagicMock) -> None: 37 | t = SMPUDPTransport() 38 | t._client = cast(MagicMock, t._client) # type: ignore 39 | t._client._protocol = MagicMock() 40 | 41 | # no errors in error queue 42 | t._client._protocol.error_queue.empty.return_value = True 43 | await t.disconnect() 44 | t._client.disconnect.assert_called_once() 45 | 46 | # errors in error queue 47 | t._client._protocol.error_queue = asyncio.Queue() 48 | t._client._protocol.error_queue.put_nowait(Exception("beep")) 49 | t._client._protocol.error_queue.put_nowait(Exception("boop")) 50 | assert t._client._protocol.error_queue.empty() is False 51 | await t.disconnect() 52 | assert t._client._protocol.error_queue.empty() is True 53 | 54 | 55 | @patch("smpclient.transport.udp.UDPClient", autospec=True) 56 | @pytest.mark.asyncio 57 | async def test_send(_: MagicMock) -> None: 58 | t = SMPUDPTransport() 59 | t._client.send = cast(MagicMock, t._client.send) # type: ignore 60 | 61 | await t.send(b"hello") 62 | t._client.send.assert_called_once_with(b"hello") 63 | 64 | t._client.send.reset_mock() 65 | 66 | # test fragmentation - really don't suggest this over UDP 67 | big_message: Final = bytes(t.max_unencoded_size + 1) 68 | await t.send(big_message) 69 | assert t._client.send.call_count == 2 70 | t._client.send.assert_has_calls( 71 | (call(big_message[: t.max_unencoded_size]), call(big_message[t.max_unencoded_size :])) 72 | ) 73 | 74 | 75 | @patch("smpclient.transport.udp.UDPClient", autospec=True) 76 | @pytest.mark.asyncio 77 | async def test_receive(_: MagicMock) -> None: 78 | t = SMPUDPTransport() 79 | t._client.receive = AsyncMock() # type: ignore 80 | 81 | message = bytes(EchoWrite._Response.get_default()(sequence=0, r="Hello pytest!")) # type: ignore # noqa 82 | 83 | # no fragmentation 84 | t._client.receive.return_value = message 85 | assert await t.receive() == message 86 | 87 | # fragmentation 88 | t._client.receive.side_effect = (message[:10], message[10:11], message[11:12], message[12:]) 89 | assert await t.receive() == message 90 | 91 | # received transmission that included some of the next packet 92 | # technically we could support this, but we don't for now 93 | with pytest.raises(SMPClientException): 94 | t._client.receive.side_effect = ( 95 | message[:10], 96 | message[10:11], 97 | message[11:12], 98 | message[12:] + message[:10], 99 | ) 100 | await t.receive() 101 | 102 | 103 | @pytest.mark.asyncio 104 | async def test_send_and_receive() -> None: 105 | with patch("smpclient.transport.udp.SMPUDPTransport.send") as send_mock, patch( 106 | "smpclient.transport.udp.SMPUDPTransport.receive" 107 | ) as receive_mock: 108 | t = SMPUDPTransport() 109 | message: Final = b"hello" 110 | await t.send_and_receive(message) 111 | send_mock.assert_awaited_once_with(message) 112 | receive_mock.assert_awaited_once() 113 | -------------------------------------------------------------------------------- /tests/test_udp_client.py: -------------------------------------------------------------------------------- 1 | """Test the generic UDP client implementation.""" 2 | 3 | import asyncio 4 | from typing import List, Tuple, cast 5 | from unittest.mock import AsyncMock, MagicMock, patch 6 | 7 | import pytest 8 | import pytest_asyncio 9 | from typing_extensions import AsyncGenerator 10 | 11 | from smpclient.transport._udp_client import Addr, UDPClient, _UDPProtocol 12 | 13 | try: 14 | from asyncio import timeout # type: ignore 15 | except ImportError: # backport for Python3.10 and below 16 | from async_timeout import timeout # type: ignore 17 | 18 | 19 | def test_UDPClient_init() -> None: 20 | UDPClient() 21 | 22 | 23 | def test_UDPProtocol_init() -> None: 24 | p = _UDPProtocol() 25 | 26 | assert p._receive_queue is p.receive_queue 27 | assert p.receive_queue.empty() 28 | 29 | assert p._error_queue is p.error_queue 30 | assert p.error_queue.empty() 31 | 32 | 33 | @patch("smpclient.transport._udp_client._UDPProtocol", autospec=True) 34 | @pytest.mark.asyncio 35 | async def test_UDPClient_connect(_: MagicMock) -> None: 36 | c = UDPClient() 37 | 38 | await c.connect(Addr("127.0.0.1", 1337)) 39 | assert isinstance(c._transport, asyncio.BaseTransport) 40 | assert isinstance(c._protocol, _UDPProtocol) 41 | assert isinstance(c._protocol.receive_queue, MagicMock) 42 | c._protocol = cast(MagicMock, c._protocol) 43 | c._protocol.connection_made.assert_called_once_with(c._transport) 44 | 45 | 46 | def test_UDPClient_send() -> None: 47 | c = UDPClient() 48 | 49 | c._transport = MagicMock() 50 | c.send(b"hello") 51 | c._transport.sendto.assert_called_once_with(b"hello") 52 | 53 | 54 | @pytest.mark.asyncio 55 | async def test_UDPClient_receive() -> None: 56 | c = UDPClient() 57 | 58 | c._protocol = MagicMock() 59 | c._protocol.receive_queue.get = AsyncMock() 60 | await c.receive() 61 | c._protocol.receive_queue.get.assert_awaited_once() 62 | 63 | 64 | @patch("smpclient.transport._udp_client._UDPProtocol", autospec=True) 65 | @pytest.mark.asyncio 66 | async def test_UDPClient_disconnect(_: MagicMock) -> None: 67 | c = UDPClient() 68 | 69 | c._transport = MagicMock() 70 | c.disconnect() 71 | c._transport.close.assert_called_once_with() 72 | 73 | c = UDPClient() 74 | await c.connect(Addr("127.0.0.1", 1337)) 75 | c.disconnect() 76 | await asyncio.sleep(0.001) 77 | c._protocol = cast(MagicMock, c._protocol) 78 | c._protocol.connection_lost.assert_called_once_with(None) 79 | 80 | 81 | class _ServerProtocol(asyncio.DatagramProtocol): 82 | """A mock SMP server protocol for unit testing.""" 83 | 84 | def __init__(self) -> None: 85 | self.datagrams_recieved: List[bytes] = [] 86 | 87 | def datagram_received(self, data: bytes, addr: Tuple[str, int]) -> None: 88 | self.datagrams_recieved.append(data) 89 | 90 | 91 | @pytest_asyncio.fixture 92 | async def udp_server() -> AsyncGenerator[Tuple[asyncio.DatagramTransport, _ServerProtocol], None]: 93 | transport, protocol = await asyncio.get_running_loop().create_datagram_endpoint( 94 | lambda: _ServerProtocol(), local_addr=("127.0.0.1", 1337) 95 | ) 96 | 97 | yield transport, protocol 98 | 99 | transport.close() 100 | 101 | 102 | @pytest.mark.asyncio 103 | async def test_send(udp_server: Tuple[asyncio.DatagramTransport, _ServerProtocol]) -> None: 104 | _, p = udp_server 105 | 106 | c = UDPClient() 107 | await c.connect(Addr("127.0.0.1", 1337)) 108 | 109 | c.send(b"hello") 110 | await asyncio.sleep(0.001) 111 | 112 | assert p.datagrams_recieved == [b"hello"] 113 | 114 | 115 | @pytest.mark.asyncio 116 | async def test_receive(udp_server: Tuple[asyncio.DatagramTransport, _ServerProtocol]) -> None: 117 | t, _ = udp_server 118 | 119 | CLIENT_ADDR = Addr("127.0.0.1", 1338) 120 | 121 | c = UDPClient() 122 | await c.connect(Addr("127.0.0.1", 1337), CLIENT_ADDR) 123 | 124 | t.sendto(b"hello", CLIENT_ADDR) 125 | 126 | async with timeout(0.050): 127 | assert await c.receive() == b"hello" 128 | 129 | 130 | @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") 131 | @pytest.mark.asyncio 132 | async def test_error_received() -> None: 133 | c = UDPClient() 134 | await c.connect(Addr("127.0.0.1", 1337)) 135 | 136 | class MockError(OSError): 137 | ... 138 | 139 | c._protocol.error_received(MockError()) 140 | async with timeout(0.050): 141 | assert isinstance(await c._protocol.error_queue.get(), MockError) 142 | 143 | 144 | @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") 145 | @pytest.mark.asyncio 146 | async def test_connection_lost_no_exception() -> None: 147 | c = UDPClient() 148 | await c.connect(Addr("127.0.0.1", 1337)) 149 | 150 | c._protocol.connection_lost(None) 151 | await asyncio.sleep(0.001) 152 | assert c._protocol.error_queue.empty() 153 | 154 | 155 | @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") 156 | @pytest.mark.asyncio 157 | async def test_connection_lost() -> None: 158 | c = UDPClient() 159 | await c.connect(Addr("127.0.0.1", 1337)) 160 | 161 | class MockError(OSError): 162 | ... 163 | 164 | c._protocol.connection_lost(MockError()) 165 | async with timeout(0.050): 166 | assert isinstance(await c._protocol.error_queue.get(), MockError) 167 | --------------------------------------------------------------------------------