├── .gitignore ├── LICENSE ├── README.md ├── logo.png ├── manifest.py ├── provision_client.py ├── sdk_utils.py ├── tb_device_mqtt.py └── umqtt.py /.gitignore: -------------------------------------------------------------------------------- 1 | ### PyCharm ### 2 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 3 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 4 | 5 | # User-specific stuff 6 | .idea/**/workspace.xml 7 | .idea/**/tasks.xml 8 | .idea/**/usage.statistics.xml 9 | .idea/**/dictionaries 10 | .idea/**/shelf 11 | 12 | # AWS User-specific 13 | .idea/**/aws.xml 14 | 15 | # Generated files 16 | .idea/**/contentModel.xml 17 | 18 | # Sensitive or high-churn files 19 | .idea/**/dataSources/ 20 | .idea/**/dataSources.ids 21 | .idea/**/dataSources.local.xml 22 | .idea/**/sqlDataSources.xml 23 | .idea/**/dynamic.xml 24 | .idea/**/uiDesigner.xml 25 | .idea/**/dbnavigator.xml 26 | 27 | # Gradle 28 | .idea/**/gradle.xml 29 | .idea/**/libraries 30 | 31 | # Gradle and Maven with auto-import 32 | # When using Gradle or Maven with auto-import, you should exclude module files, 33 | # since they will be recreated, and may cause churn. Uncomment if using 34 | # auto-import. 35 | # .idea/artifacts 36 | # .idea/compiler.xml 37 | # .idea/jarRepositories.xml 38 | # .idea/modules.xml 39 | # .idea/*.iml 40 | # .idea/modules 41 | # *.iml 42 | # *.ipr 43 | 44 | # CMake 45 | cmake-build-*/ 46 | 47 | # Mongo Explorer plugin 48 | .idea/**/mongoSettings.xml 49 | 50 | # File-based project format 51 | *.iws 52 | 53 | # IntelliJ 54 | out/ 55 | 56 | # mpeltonen/sbt-idea plugin 57 | .idea_modules/ 58 | 59 | # JIRA plugin 60 | atlassian-ide-plugin.xml 61 | 62 | # Cursive Clojure plugin 63 | .idea/replstate.xml 64 | 65 | # SonarLint plugin 66 | .idea/sonarlint/ 67 | 68 | # Crashlytics plugin (for Android Studio and IntelliJ) 69 | com_crashlytics_export_strings.xml 70 | crashlytics.properties 71 | crashlytics-build.properties 72 | fabric.properties 73 | 74 | # Editor-based Rest Client 75 | .idea/httpRequests 76 | 77 | # Android studio 3.1+ serialized cache file 78 | .idea/caches/build_file_checksums.ser 79 | 80 | ### PyCharm Patch ### 81 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 82 | 83 | # *.iml 84 | # modules.xml 85 | # .idea/misc.xml 86 | # *.ipr 87 | 88 | # Sonarlint plugin 89 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 90 | .idea/**/sonarlint/ 91 | 92 | # SonarQube Plugin 93 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 94 | .idea/**/sonarIssues.xml 95 | 96 | # Markdown Navigator plugin 97 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 98 | .idea/**/markdown-navigator.xml 99 | .idea/**/markdown-navigator-enh.xml 100 | .idea/**/markdown-navigator/ 101 | 102 | # Cache file creation bug 103 | # See https://youtrack.jetbrains.com/issue/JBR-2257 104 | .idea/$CACHE_FILE$ 105 | 106 | # CodeStream plugin 107 | # https://plugins.jetbrains.com/plugin/12206-codestream 108 | .idea/codestream.xml 109 | 110 | # Azure Toolkit for IntelliJ plugin 111 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij 112 | .idea/**/azureSettings.xml 113 | 114 | ### Python ### 115 | # Byte-compiled / optimized / DLL files 116 | __pycache__/ 117 | *.py[cod] 118 | *$py.class 119 | 120 | # C extensions 121 | *.so 122 | 123 | # Distribution / packaging 124 | .Python 125 | build/ 126 | develop-eggs/ 127 | dist/ 128 | downloads/ 129 | eggs/ 130 | .eggs/ 131 | lib/ 132 | lib64/ 133 | parts/ 134 | sdist/ 135 | var/ 136 | wheels/ 137 | share/python-wheels/ 138 | *.egg-info/ 139 | .installed.cfg 140 | *.egg 141 | MANIFEST 142 | 143 | # PyInstaller 144 | # Usually these files are written by a python script from a template 145 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 146 | *.manifest 147 | *.spec 148 | 149 | # Installer logs 150 | pip-log.txt 151 | pip-delete-this-directory.txt 152 | 153 | # Unit test / coverage reports 154 | htmlcov/ 155 | .tox/ 156 | .nox/ 157 | .coverage 158 | .coverage.* 159 | .cache 160 | nosetests.xml 161 | coverage.xml 162 | *.cover 163 | *.py,cover 164 | .hypothesis/ 165 | .pytest_cache/ 166 | cover/ 167 | 168 | # Translations 169 | *.mo 170 | *.pot 171 | 172 | # Django stuff: 173 | *.log 174 | local_settings.py 175 | db.sqlite3 176 | db.sqlite3-journal 177 | 178 | # Flask stuff: 179 | instance/ 180 | .webassets-cache 181 | 182 | # Scrapy stuff: 183 | .scrapy 184 | 185 | # Sphinx documentation 186 | docs/_build/ 187 | 188 | # PyBuilder 189 | .pybuilder/ 190 | target/ 191 | 192 | # Jupyter Notebook 193 | .ipynb_checkpoints 194 | 195 | # IPython 196 | profile_default/ 197 | ipython_config.py 198 | 199 | # pyenv 200 | # For a library or package, you might want to ignore these files since the code is 201 | # intended to run in multiple environments; otherwise, check them in: 202 | # .python-version 203 | 204 | # pipenv 205 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 206 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 207 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 208 | # install all needed dependencies. 209 | #Pipfile.lock 210 | 211 | # poetry 212 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 213 | # This is especially recommended for binary packages to ensure reproducibility, and is more 214 | # commonly ignored for libraries. 215 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 216 | #poetry.lock 217 | 218 | # pdm 219 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 220 | #pdm.lock 221 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 222 | # in version control. 223 | # https://pdm.fming.dev/#use-with-ide 224 | .pdm.toml 225 | 226 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 227 | __pypackages__/ 228 | 229 | # Celery stuff 230 | celerybeat-schedule 231 | celerybeat.pid 232 | 233 | # SageMath parsed files 234 | *.sage.py 235 | 236 | # Environments 237 | .env 238 | .venv 239 | env/ 240 | venv/ 241 | ENV/ 242 | env.bak/ 243 | venv.bak/ 244 | 245 | # Spyder project settings 246 | .spyderproject 247 | .spyproject 248 | 249 | # Rope project settings 250 | .ropeproject 251 | 252 | # mkdocs documentation 253 | /site 254 | 255 | # mypy 256 | .mypy_cache/ 257 | .dmypy.json 258 | dmypy.json 259 | 260 | # Pyre type checker 261 | .pyre/ 262 | 263 | # pytype static type analyzer 264 | .pytype/ 265 | 266 | # Cython debug symbols 267 | cython_debug/ 268 | 269 | # PyCharm 270 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 271 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 272 | # and can be added to the global gitignore or merged into this file. For a more nuclear 273 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 274 | #.idea/ 275 | 276 | ### Python Patch ### 277 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 278 | poetry.toml 279 | 280 | # ruff 281 | .ruff_cache/ 282 | 283 | # End of https://www.toptal.com/developers/gitignore/api/python,pycharm 284 | 285 | /main.py 286 | /boot.py 287 | /venv/ 288 | .idea/ 289 | /__init__.py 290 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023. ThingsBoard 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ThingsBoard MQTT client MicroPython SDK 2 | [![Join the chat at https://gitter.im/thingsboard/chat](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/thingsboard/chat?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 3 | 4 | 5 | 6 | **💡 Make the notion that it is the early alpha of MQTT client MicroPython SDK special for controllers. So we appreciate any 7 | help in improving this project and getting it growing.** 8 | 9 | ThingsBoard is an open-source IoT platform for data collection, processing, visualization, and device management. 10 | This project is a MicroPython library that provides convenient client SDK for Device API using MicroPython. 11 | 12 | SDK supports: 13 | - Provided all supported feature of umqtt library 14 | - Unencrypted and encrypted (TLS v1.2) connection 15 | - QoS 0 and 1 (MQTT only) 16 | - Automatic reconnect 17 | - [Device MQTT](https://thingsboard.io/docs/reference/mqtt-api/) API provided by ThingsBoard 18 | - Firmware updates 19 | - Device Claiming are not supported yet 20 | 21 | The [Device MQTT](https://thingsboard.io/docs/reference/mqtt-api/) API are based on uMQTT library. 22 | 23 | **For now library support only local install (not from package manager relates to MicroPython)** 24 | 25 | ## Getting Started 26 | 27 | Client initialization and telemetry publishing 28 | ### MQTT 29 | ```python 30 | from tb_device_mqtt import TBDeviceMqttClient 31 | telemetry = {"temperature": 41.9, "enabled": False, "currentFirmwareVersion": "v1.2.2"} 32 | client = TBDeviceMqttClient("127.0.0.1", "A1_TEST_TOKEN") 33 | # Connect to ThingsBoard 34 | client.connect() 35 | # Sending telemetry without checking the delivery status 36 | client.send_telemetry(telemetry) 37 | # Sending telemetry and checking the delivery status (QoS = 1 by default) 38 | result = client.send_telemetry(telemetry) 39 | # Disconnect from ThingsBoard 40 | client.disconnect() 41 | ``` 42 | 43 | ## Using Device APIs 44 | 45 | **TBDeviceMqttClient** provides access to Device MQTT APIs of ThingsBoard platform. It allows to publish telemetry and attribute updates, subscribe to attribute changes, send and receive RPC commands, etc. Use **TBHTTPClient** for the Device HTTP API. 46 | #### Subscription to attributes 47 | ##### MQTT 48 | ```python 49 | import time 50 | from tb_device_mqtt import TBDeviceMqttClient 51 | 52 | def on_attributes_change(client, result, exception): 53 | if exception is not None: 54 | print("Exception: " + str(exception)) 55 | else: 56 | print(result) 57 | 58 | client = TBDeviceMqttClient("127.0.0.1", "A1_TEST_TOKEN") 59 | client.connect() 60 | client.subscribe_to_attribute("uploadFrequency", on_attributes_change) 61 | client.subscribe_to_all_attributes(on_attributes_change) 62 | while True: 63 | client.wait_for_msg() 64 | time.sleep(1) 65 | ``` 66 | 67 | #### Telemetry pack sending 68 | ##### MQTT 69 | ```python 70 | import logging 71 | from tb_device_mqtt import TBDeviceMqttClient 72 | import time 73 | telemetry_with_ts = {"ts": int(round(time.time() * 1000)), "values": {"temperature": 42.1, "humidity": 70}} 74 | client = TBDeviceMqttClient("127.0.0.1", "A1_TEST_TOKEN") 75 | client.connect() 76 | results = [] 77 | result = True 78 | for i in range(0, 100): 79 | results.append(client.send_telemetry(telemetry_with_ts)) 80 | 81 | print("Result " + str(result)) 82 | client.disconnect() 83 | ``` 84 | 85 | #### Request attributes from server 86 | ##### MQTT 87 | ```python 88 | import logging 89 | import time 90 | from tb_device_mqtt import TBDeviceMqttClient 91 | 92 | def on_attributes_change(client,result, exception: 93 | if exception is not None: 94 | print("Exception: " + str(exception)) 95 | else: 96 | print(result) 97 | 98 | client = TBDeviceMqttClient("127.0.0.1", "A1_TEST_TOKEN") 99 | client.connect() 100 | client.request_attributes(["configuration","targetFirmwareVersion"], callback=on_attributes_change) 101 | while True: 102 | time.sleep(1) 103 | ``` 104 | 105 | #### Respond to server RPC call 106 | ##### MQTT 107 | ```python 108 | import psutil 109 | import time 110 | import logging 111 | from tb_device_mqtt import TBDeviceMqttClient 112 | 113 | # dependently of request method we send different data back 114 | def on_server_side_rpc_request(client, request_id, request_body): 115 | print(request_id, request_body) 116 | if request_body["method"] == "getCPULoad": 117 | client.send_rpc_reply(request_id, {"CPU percent": psutil.cpu_percent()}) 118 | elif request_body["method"] == "getMemoryUsage": 119 | client.send_rpc_reply(request_id, {"Memory": psutil.virtual_memory().percent}) 120 | 121 | client = TBDeviceMqttClient("127.0.0.1", "A1_TEST_TOKEN") 122 | client.set_server_side_rpc_request_handler(on_server_side_rpc_request) 123 | client.connect() 124 | while True: 125 | time.sleep(1) 126 | ``` 127 | ## Device provisioning 128 | **ProvisionManager** - class created to have ability to provision device to ThingsBoard, using device provisioning feature [Provisioning devices](https://thingsboard.io/docs/paas/user-guide/device-provisioning/) 129 | First, you need to set up and configure the `ProvisionManager`, which allows you to provision a device on the ThingsBoard server via MQTT. Below are the steps for using this class. 130 | 131 | ```python 132 | from tb_device_mqtt import TBDeviceMqttClient, ProvisionManager 133 | 134 | THINGSBOARD_HOST = "YOUR_THINGSBOARD_HOST" 135 | THINGSBOARD_PORT = 1883 136 | PROVISION_DEVICE_KEY = "YOUR_PROVISION_DEVICE_KEY" 137 | PROVISION_DEVICE_SECRET = "YOUR_PROVISION_DEVICE_SECRET" 138 | DEVICE_NAME = "MyDevice" 139 | 140 | provision_manager = ProvisionManager(THINGSBOARD_HOST, THINGSBOARD_PORT) 141 | 142 | credentials = provision_manager.provision_device( 143 | provision_device_key=PROVISION_DEVICE_KEY, 144 | provision_device_secret=PROVISION_DEVICE_SECRET, 145 | device_name=DEVICE_NAME 146 | 147 | ) 148 | if not credentials: 149 | print("Provisioning failed!") 150 | raise SystemExit("Exiting: Provisioning unsuccessful.") 151 | 152 | print(f"Provisioning successful! Credentials: {credentials}") 153 | 154 | access_token = credentials.get("credentialsValue") 155 | if not access_token: 156 | print("No access token found in credentials!") 157 | raise SystemExit("Exiting: Access token missing.") 158 | 159 | client_id = f"{DEVICE_NAME}_client" 160 | mqtt_client = TBDeviceMqttClient(host=THINGSBOARD_HOST, port=THINGSBOARD_PORT, access_token=access_token) 161 | 162 | try: 163 | mqtt_client.connect() 164 | print(f"Connected to ThingsBoard server at {THINGSBOARD_HOST}:{THINGSBOARD_PORT}") 165 | 166 | TELEMETRY_DATA = { 167 | "temperature": 22.5, 168 | "humidity": 60 169 | } 170 | 171 | mqtt_client.send_telemetry(TELEMETRY_DATA) 172 | print(f"Telemetry sent: {TELEMETRY_DATA}") 173 | 174 | except Exception as e: 175 | print(f"An error occurred: {e}") 176 | finally: 177 | if mqtt_client.connect(): 178 | mqtt_client.disconnect() 179 | print("Disconnected from ThingsBoard server.") 180 | else: 181 | print("Client was not connected; no need to disconnect.") 182 | ``` 183 | 184 | # Claim device 185 | [**Claim device**](https://thingsboard.io/docs/pe/user-guide/claiming-devices/) is a function designed to handle the device claiming feature in ThingsBoard. It enables sending device claiming requests to the ThingsBoard MQTT broker, allowing dynamic assignment of devices to users or customers. 186 | 187 | ```python 188 | from tb_device_mqtt import TBDeviceMqttClient 189 | 190 | THINGSBOARD_HOST = "YOUR_THINGSBOARD_HOST" 191 | THINGSBOARD_PORT = 1883 192 | DEVICE_TOKEN = "YOUR_DEVICE_TOKEN" 193 | DURATION_MS = 30000 194 | SECRET_KEY = "YOUR_SECRET_KEY" 195 | 196 | client = TBDeviceMqttClient(THINGSBOARD_HOST, THINGSBOARD_PORT, DEVICE_TOKEN) 197 | 198 | try: 199 | client.connect() 200 | 201 | client.claim_device(SECRET_KEY, DURATION_MS) 202 | print(f"Claim request sent with secretKey: {SECRET_KEY} and durationMs: {DURATION_MS}") 203 | except Exception as e: 204 | print(f"An error occurred: {e}") 205 | finally: 206 | if client.connected: 207 | client.disconnect() 208 | print("Disconnected from ThingsBoard.") 209 | ``` 210 | ## Other Examples 211 | 212 | There are more examples for both [device](https://github.com/thingsboard/thingsboard-python-client-sdk/tree/master/examples/device) and [gateway](https://github.com/thingsboard/thingsboard-python-client-sdk/tree/master/examples/gateway) in corresponding [folders](https://github.com/thingsboard/thingsboard-python-client-sdk/tree/master/examples). 213 | 214 | ## Support 215 | 216 | - [Community chat](https://gitter.im/thingsboard/chat) 217 | - [Q&A forum](https://groups.google.com/forum/#!forum/thingsboard) 218 | - [Stackoverflow](http://stackoverflow.com/questions/tagged/thingsboard) 219 | 220 | ## Licenses 221 | 222 | This project is released under [Apache 2.0 License](./LICENSE). 223 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thingsboard/thingsboard-micropython-client-sdk/09f79070c7d758686dee2d302a28d2c1f29b91a4/logo.png -------------------------------------------------------------------------------- /manifest.py: -------------------------------------------------------------------------------- 1 | metadata(description="ThingsBoard uPython Client SDK", version="0.0.1") 2 | include("$(BOARD_DIR)/manifest.py") 3 | module("umqtt.py") 4 | module("sdk_utils.py") 5 | module("tb_device_mqtt.py") 6 | module("provision_client.py") 7 | 8 | -------------------------------------------------------------------------------- /provision_client.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025. ThingsBoard 2 | # # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | 16 | from ujson import dumps, loads 17 | from umqtt import MQTTClient 18 | from gc import collect 19 | 20 | 21 | class ProvisionClient: 22 | PROVISION_REQUEST_TOPIC = b"/provision/request" 23 | PROVISION_RESPONSE_TOPIC = b"/provision/response" 24 | 25 | def __init__(self, host, port, provision_request): 26 | self._host = host 27 | self._port = port 28 | self._client_id = b"provision" 29 | self._provision_request = provision_request 30 | self._credentials = None 31 | 32 | def _on_message(self, topic, msg): 33 | try: 34 | response = loads(msg) 35 | if response.get("status") == "SUCCESS": 36 | self._credentials = response 37 | else: 38 | print(f"Provisioning failed: {response.get('errorMsg', 'Unknown error')}") 39 | except MemoryError: 40 | print("MemoryError during message processing!") 41 | 42 | def provision(self): 43 | mqtt_client = None 44 | try: 45 | collect() 46 | 47 | mqtt_client = MQTTClient(self._client_id, self._host, self._port, keepalive=10) 48 | mqtt_client.set_callback(self._on_message) 49 | mqtt_client.connect(clean_session=True) 50 | mqtt_client.subscribe(self.PROVISION_RESPONSE_TOPIC) 51 | collect() 52 | 53 | provision_request_str = dumps(self._provision_request, separators=(',', ':')) 54 | mqtt_client.publish(self.PROVISION_REQUEST_TOPIC, provision_request_str) 55 | del provision_request_str 56 | collect() 57 | 58 | mqtt_client.wait_msg() 59 | finally: 60 | if mqtt_client: 61 | mqtt_client.disconnect() 62 | collect() 63 | 64 | @property 65 | def credentials(self): 66 | return self._credentials 67 | -------------------------------------------------------------------------------- /sdk_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023. ThingsBoard 2 | # # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | 16 | from random import getrandbits 17 | from hashlib import sha256 18 | 19 | 20 | def verify_checksum(firmware_data, checksum_alg, checksum): 21 | if firmware_data is None: 22 | print('Firmware was not received!') 23 | return False 24 | if checksum is None: 25 | print('Checksum was\'t provided!') 26 | return False 27 | checksum_of_received_firmware = None 28 | print('Checksum algorithm is: %s' % checksum_alg) 29 | if checksum_alg.lower() == "sha256": 30 | checksum_of_received_firmware = "".join(["%.2x" % i for i in sha256(firmware_data).digest()]) 31 | else: 32 | print('Client error. Unsupported checksum algorithm.') 33 | 34 | print(checksum_of_received_firmware) 35 | 36 | return checksum_of_received_firmware == checksum 37 | -------------------------------------------------------------------------------- /tb_device_mqtt.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023. ThingsBoard 2 | # # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | 16 | from time import sleep 17 | from sdk_utils import verify_checksum 18 | from umqtt import MQTTClient, MQTTException 19 | from ujson import dumps, loads 20 | from ubinascii import hexlify 21 | from machine import unique_id, reset 22 | from gc import collect 23 | from provision_client import ProvisionClient 24 | 25 | 26 | FW_TITLE_ATTR = "fw_title" 27 | FW_VERSION_ATTR = "fw_version" 28 | FW_CHECKSUM_ATTR = "fw_checksum" 29 | FW_CHECKSUM_ALG_ATTR = "fw_checksum_algorithm" 30 | FW_SIZE_ATTR = "fw_size" 31 | FW_STATE_ATTR = "fw_state" 32 | 33 | REQUIRED_SHARED_KEYS = "{0},{1},{2},{3},{4}".format( 34 | FW_CHECKSUM_ATTR, FW_CHECKSUM_ALG_ATTR, FW_SIZE_ATTR, FW_TITLE_ATTR, FW_VERSION_ATTR 35 | ) 36 | 37 | RPC_REQUEST_TOPIC = 'v1/devices/me/rpc/request/' 38 | RPC_RESPONSE_TOPIC = 'v1/devices/me/rpc/response/' 39 | ATTRIBUTES_TOPIC = 'v1/devices/me/attributes' 40 | ATTRIBUTE_REQUEST_TOPIC = 'v1/devices/me/attributes/request/' 41 | ATTRIBUTE_TOPIC_RESPONSE = 'v1/devices/me/attributes/response/' 42 | CLAIMING_TOPIC = "v1/devices/me/claim" 43 | 44 | 45 | class TBDeviceMqttClient: 46 | def __init__( 47 | self, host, port=1883, access_token=None, quality_of_service=None, client_id=None, chunk_size=0 48 | ): 49 | self._host = host 50 | self._port = port 51 | self.quality_of_service = quality_of_service if quality_of_service is not None else 1 52 | self.current_firmware_info = { 53 | "current_" + FW_TITLE_ATTR: "Initial", 54 | "current_" + FW_VERSION_ATTR: "v0" 55 | } 56 | self._attr_request_dict = {} 57 | self.__device_client_rpc_dict = {} 58 | self.__device_sub_dict = {} 59 | self.__device_on_server_side_rpc_response = None 60 | self.__attr_request_number = 0 61 | self.__device_client_rpc_number = 0 62 | self.__device_max_sub_id = 0 63 | self.__firmware_request_id = 0 64 | self.__request_id = 0 65 | self.__chunk_size = chunk_size 66 | self.connected = False 67 | 68 | if not access_token: 69 | print("token is not set, connection without tls wont be established") 70 | self._access_token = access_token 71 | 72 | if not client_id: 73 | client_id = hexlify(unique_id()) 74 | self._client_id = client_id 75 | self._client = MQTTClient( 76 | self._client_id, self._host, self._port, self._access_token, 'pswd', keepalive=120 77 | ) 78 | 79 | def connect(self, timeout=5): 80 | try: 81 | response = self._client.connect(timeout=timeout) 82 | self._client.set_callback(self._callback) 83 | 84 | self._client.subscribe(ATTRIBUTES_TOPIC, qos=self.quality_of_service) 85 | self._client.subscribe(ATTRIBUTES_TOPIC + "/response/+", qos=self.quality_of_service) 86 | self._client.subscribe(RPC_REQUEST_TOPIC + '+', qos=self.quality_of_service) 87 | self._client.subscribe(RPC_RESPONSE_TOPIC + '+', qos=self.quality_of_service) 88 | 89 | self.connected = True 90 | return response 91 | 92 | except MQTTException as e: 93 | self.connected = False 94 | print(f"MQTT connection error: {e}") 95 | except Exception as e: 96 | self.connected = False 97 | print(f"Unexpected connection error: {e}") 98 | 99 | def disconnect(self): 100 | self._client.disconnect() 101 | self.connected = False 102 | 103 | def _callback(self, topic, msg): 104 | topic = topic.decode('utf-8') 105 | print('callback', topic, msg) 106 | 107 | update_response_pattern = "v2/fw/response/" + str(self.__firmware_request_id) + "/chunk/" 108 | 109 | if topic.startswith(update_response_pattern): 110 | firmware_data = msg 111 | 112 | self.firmware_data = self.firmware_data + firmware_data 113 | self.__current_chunk = self.__current_chunk + 1 114 | 115 | print('Getting chunk with number: %s. Chunk size is : %r byte(s).' % ( 116 | self.__current_chunk, self.__chunk_size 117 | )) 118 | 119 | if len(self.firmware_data) == self.__target_firmware_length: 120 | self.__process_firmware() 121 | else: 122 | self.__get_firmware() 123 | else: 124 | self._on_decode_message(topic, msg) 125 | 126 | def _on_decode_message(self, topic, msg): 127 | if topic.startswith(RPC_REQUEST_TOPIC): 128 | request_id = topic[len(RPC_REQUEST_TOPIC):len(topic)] 129 | if self.__device_on_server_side_rpc_response: 130 | self.__device_on_server_side_rpc_response(request_id, loads(msg)) 131 | elif topic.startswith(RPC_RESPONSE_TOPIC): 132 | request_id = int(topic[len(RPC_RESPONSE_TOPIC):len(topic)]) 133 | callback = self.__device_client_rpc_dict.pop(request_id) 134 | callback(request_id, loads(msg), None) 135 | elif topic == ATTRIBUTES_TOPIC: 136 | msg = loads(msg) 137 | dict_results = [] 138 | # callbacks for everything 139 | if self.__device_sub_dict.get("*"): 140 | for subscription_id in self.__device_sub_dict["*"]: 141 | dict_results.append(self.__device_sub_dict["*"][subscription_id]) 142 | # specific callback 143 | keys = msg.keys() 144 | keys_list = [] 145 | for key in keys: 146 | keys_list.append(key) 147 | # iterate through message 148 | for key in keys_list: 149 | # find key in our dict 150 | if self.__device_sub_dict.get(key): 151 | for subscription in self.__device_sub_dict[key]: 152 | dict_results.append(self.__device_sub_dict[key][subscription]) 153 | for res in dict_results: 154 | res(msg, None) 155 | elif topic.startswith(ATTRIBUTE_TOPIC_RESPONSE): 156 | req_id = int(topic[len(ATTRIBUTES_TOPIC + "/response/"):]) 157 | callback = self._attr_request_dict.pop(req_id) 158 | if isinstance(callback, tuple): 159 | callback[0](loads(msg), None, callback[1]) 160 | else: 161 | callback(loads(msg), None) 162 | 163 | if topic.startswith(ATTRIBUTES_TOPIC): 164 | self.firmware_info = loads(msg) 165 | 166 | if '/response/' in topic: 167 | self.firmware_info = self.firmware_info.get("shared", {}) if isinstance(self.firmware_info, dict) else {} 168 | 169 | if (self.firmware_info.get(FW_VERSION_ATTR) is not None and self.firmware_info.get( 170 | FW_VERSION_ATTR) != self.current_firmware_info.get("current_" + FW_VERSION_ATTR)) or \ 171 | (self.firmware_info.get(FW_TITLE_ATTR) is not None and self.firmware_info.get( 172 | FW_TITLE_ATTR) != self.current_firmware_info.get("current_" + FW_TITLE_ATTR)): 173 | print('Firmware is not the same') 174 | self.firmware_data = b'' 175 | self.__current_chunk = 0 176 | 177 | self.current_firmware_info[FW_STATE_ATTR] = "DOWNLOADING" 178 | self.send_telemetry(self.current_firmware_info) 179 | sleep(1) 180 | 181 | self.__firmware_request_id = self.__firmware_request_id + 1 182 | self.__target_firmware_length = self.firmware_info[FW_SIZE_ATTR] 183 | firmware_tail = 0 if not self.__chunk_size else self.firmware_info[FW_SIZE_ATTR] % self.__chunk_size 184 | self.__chunk_count = 0 if not self.__chunk_size else int( 185 | self.firmware_info[FW_SIZE_ATTR] / self.__chunk_size) + (0 if not firmware_tail else 1) 186 | self.__get_firmware() 187 | 188 | def __get_firmware(self): 189 | payload = '' if not self.__chunk_size or self.__chunk_size > self.firmware_info.get(FW_SIZE_ATTR, 0) else str( 190 | self.__chunk_size).encode() 191 | self._client.publish("v2/fw/request/{0}/chunk/{1}".format(self.__firmware_request_id, self.__current_chunk), 192 | payload, qos=1) 193 | 194 | def __process_firmware(self): 195 | self.current_firmware_info[FW_STATE_ATTR] = "DOWNLOADED" 196 | self.send_telemetry(self.current_firmware_info) 197 | sleep(1) 198 | 199 | verification_result = verify_checksum(self.firmware_data, self.firmware_info.get(FW_CHECKSUM_ALG_ATTR), 200 | self.firmware_info.get(FW_CHECKSUM_ATTR)) 201 | 202 | if verification_result: 203 | print('Checksum verified!') 204 | self.current_firmware_info[FW_STATE_ATTR] = "VERIFIED" 205 | self.send_telemetry(self.current_firmware_info) 206 | sleep(1) 207 | 208 | with open(self.firmware_info.get(FW_TITLE_ATTR), "wb") as firmware_file: 209 | firmware_file.write(self.firmware_data) 210 | 211 | self.current_firmware_info = { 212 | "current_" + FW_TITLE_ATTR: self.firmware_info.get(FW_TITLE_ATTR), 213 | "current_" + FW_VERSION_ATTR: self.firmware_info.get(FW_VERSION_ATTR), 214 | FW_STATE_ATTR: "UPDATED" 215 | } 216 | self.send_telemetry(self.current_firmware_info) 217 | print('Firmware is updated!\n Current firmware version is: {0}'.format(self.firmware_info.get(FW_VERSION_ATTR))) 218 | self.firmware_received = True 219 | reset() 220 | else: 221 | print('Checksum verification failed!') 222 | self.current_firmware_info[FW_STATE_ATTR] = "FAILED" 223 | self.send_telemetry(self.current_firmware_info) 224 | self.__request_id = self.__request_id + 1 225 | self._client.publish("v1/devices/me/attributes/request/{0}".format(self.__request_id), 226 | dumps({"sharedKeys": REQUIRED_SHARED_KEYS})) 227 | return 228 | 229 | def set_server_side_rpc_request_handler(self, handler): 230 | self.__device_on_server_side_rpc_response = handler 231 | 232 | def send_telemetry(self, data): 233 | telemetry_topic = 'v1/devices/me/telemetry' 234 | self._client.publish(telemetry_topic, dumps(data)) 235 | 236 | def send_attributes(self, data): 237 | self._client.publish(ATTRIBUTES_TOPIC, dumps(data)) 238 | 239 | def request_attributes(self, client_keys=None, shared_keys=None, callback=None): 240 | msg = {} 241 | if client_keys: 242 | tmp = "" 243 | for key in client_keys: 244 | tmp += key + "," 245 | tmp = tmp[:len(tmp) - 1] 246 | msg.update({"clientKeys": tmp}) 247 | if shared_keys: 248 | tmp = "" 249 | for key in shared_keys: 250 | tmp += key + "," 251 | tmp = tmp[:len(tmp) - 1] 252 | msg.update({"sharedKeys": tmp}) 253 | self.__attr_request_number += 1 254 | self._attr_request_dict.update({self.__attr_request_number: callback}) 255 | self._client.publish(ATTRIBUTE_REQUEST_TOPIC + str(self.__attr_request_number), 256 | dumps(msg), 257 | qos=self.quality_of_service) 258 | self._client.wait_msg() 259 | 260 | def send_rpc_call(self, method, params, callback): 261 | self.__device_client_rpc_number += 1 262 | self.__device_client_rpc_dict.update({self.__device_client_rpc_number: callback}) 263 | rpc_request_id = self.__device_client_rpc_number 264 | payload = {"method": method, "params": params} 265 | self._client.publish(RPC_REQUEST_TOPIC + str(rpc_request_id), 266 | dumps(payload), 267 | qos=self.quality_of_service) 268 | self._client.wait_msg() 269 | 270 | def unsubscribe_from_attribute(self, subscription_id): 271 | for attribute in self.__device_sub_dict: 272 | if self.__device_sub_dict[attribute].get(subscription_id): 273 | del self.__device_sub_dict[attribute][subscription_id] 274 | print("Unsubscribed from {0}, subscription id {1}".format(attribute, subscription_id)) 275 | if subscription_id == '*': 276 | self.__device_sub_dict = {} 277 | self.__device_sub_dict = dict((k, v) for k, v in self.__device_sub_dict.items() if v) 278 | 279 | def clean_device_sub_dict(self): 280 | self.__device_sub_dict = {} 281 | 282 | def subscribe_to_all_attributes(self, callback): 283 | return self.subscribe_to_attribute("*", callback) 284 | 285 | def subscribe_to_attribute(self, key, callback): 286 | self.__device_max_sub_id += 1 287 | if key not in self.__device_sub_dict: 288 | self.__device_sub_dict.update({key: {self.__device_max_sub_id: callback}}) 289 | else: 290 | self.__device_sub_dict[key].update({self.__device_max_sub_id: callback}) 291 | print("Subscribed to {0} with id {1}".format(key, self.__device_max_sub_id)) 292 | return self.__device_max_sub_id 293 | 294 | def wait_for_msg(self): 295 | self._client.wait_msg() 296 | 297 | def claim_device(self, secret_key=None, duration_ms=None): 298 | claim_request = {} 299 | if secret_key: 300 | claim_request["secretKey"] = secret_key 301 | if duration_ms: 302 | claim_request["durationMs"] = duration_ms 303 | 304 | payload = dumps(claim_request) 305 | print(f"Sending claim request to topic '{CLAIMING_TOPIC}' with payload: {payload}") 306 | self._client.publish(CLAIMING_TOPIC, payload, qos=self.quality_of_service) 307 | 308 | 309 | class ProvisionManager: 310 | def __init__(self, host, port=1883): 311 | self.host = host 312 | self.port = port 313 | self.credentials = None 314 | 315 | def provision_device(self, 316 | provision_device_key, 317 | provision_device_secret, 318 | device_name=None, 319 | access_token=None, 320 | client_id=None, 321 | username=None, 322 | password=None, 323 | hash=None, 324 | gateway=None): 325 | 326 | collect() 327 | try: 328 | provision_request = { 329 | "provisionDeviceKey": provision_device_key, 330 | "provisionDeviceSecret": provision_device_secret 331 | } 332 | 333 | if access_token: 334 | provision_request["token"] = access_token 335 | provision_request["credentialsType"] = "ACCESS_TOKEN" 336 | elif username or password or client_id: 337 | provision_request["username"] = username 338 | provision_request["password"] = password 339 | provision_request["clientId"] = client_id 340 | provision_request["credentialsType"] = "MQTT_BASIC" 341 | elif hash: 342 | provision_request["hash"] = hash 343 | provision_request["credentialsType"] = "X509_CERTIFICATE" 344 | 345 | if device_name: 346 | provision_request["deviceName"] = device_name 347 | 348 | if gateway: 349 | provision_request["gateway"] = gateway 350 | 351 | provision_client = ProvisionClient(self.host, self.port, provision_request) 352 | 353 | provision_client.provision() 354 | 355 | if provision_client.credentials: 356 | print("Provisioning successful. Credentials obtained.") 357 | self.credentials = provision_client.credentials 358 | return self.credentials 359 | else: 360 | print("Provisioning failed. No credentials obtained.") 361 | finally: 362 | collect() 363 | -------------------------------------------------------------------------------- /umqtt.py: -------------------------------------------------------------------------------- 1 | import usocket as socket 2 | import ustruct as struct 3 | 4 | 5 | class MQTTException(Exception): 6 | pass 7 | 8 | 9 | class MQTTClient: 10 | def __init__( 11 | self, 12 | client_id, 13 | server, 14 | port=0, 15 | user=None, 16 | password=None, 17 | keepalive=0, 18 | ssl=False, 19 | ssl_params={}, 20 | ): 21 | if port == 0: 22 | port = 8883 if ssl else 1883 23 | self.client_id = client_id 24 | self.sock = None 25 | self.server = server 26 | self.port = port 27 | self.ssl = ssl 28 | self.ssl_params = ssl_params 29 | self.pid = 0 30 | self.cb = None 31 | self.user = user 32 | self.pswd = password 33 | self.keepalive = keepalive 34 | self.lw_topic = None 35 | self.lw_msg = None 36 | self.lw_qos = 0 37 | self.lw_retain = False 38 | 39 | def _send_str(self, s): 40 | self.sock.write(struct.pack("!H", len(s))) 41 | self.sock.write(s) 42 | 43 | def _recv_len(self): 44 | n = 0 45 | sh = 0 46 | while 1: 47 | b = self.sock.read(1)[0] 48 | n |= (b & 0x7F) << sh 49 | if not b & 0x80: 50 | return n 51 | sh += 7 52 | 53 | def set_callback(self, f): 54 | self.cb = f 55 | 56 | def set_last_will(self, topic, msg, retain=False, qos=0): 57 | assert 0 <= qos <= 2 58 | assert topic 59 | self.lw_topic = topic 60 | self.lw_msg = msg 61 | self.lw_qos = qos 62 | self.lw_retain = retain 63 | 64 | def connect(self, clean_session=True, timeout=5): 65 | self.sock = socket.socket() 66 | self.sock.settimeout(timeout) 67 | addr = socket.getaddrinfo(self.server, self.port)[0][-1] 68 | self.sock.connect(addr) 69 | if self.ssl: 70 | import ussl 71 | 72 | self.sock = ussl.wrap_socket(self.sock, **self.ssl_params) 73 | premsg = bytearray(b"\x10\0\0\0\0\0") 74 | msg = bytearray(b"\x04MQTT\x04\x02\0\0") 75 | 76 | sz = 10 + 2 + len(self.client_id) 77 | msg[6] = clean_session << 1 78 | if self.user is not None: 79 | sz += 2 + len(self.user) + 2 + len(self.pswd) if self.pswd else 0 80 | msg[6] |= 0xC0 81 | if self.keepalive: 82 | assert self.keepalive < 65536 83 | msg[7] |= self.keepalive >> 8 84 | msg[8] |= self.keepalive & 0x00FF 85 | if self.lw_topic: 86 | sz += 2 + len(self.lw_topic) + 2 + len(self.lw_msg) 87 | msg[6] |= 0x4 | (self.lw_qos & 0x1) << 3 | (self.lw_qos & 0x2) << 3 88 | msg[6] |= self.lw_retain << 5 89 | 90 | i = 1 91 | while sz > 0x7F: 92 | premsg[i] = (sz & 0x7F) | 0x80 93 | sz >>= 7 94 | i += 1 95 | premsg[i] = sz 96 | 97 | self.sock.write(premsg, i + 2) 98 | self.sock.write(msg) 99 | # print(hex(len(msg)), hexlify(msg, ":")) 100 | self._send_str(self.client_id) 101 | if self.lw_topic: 102 | self._send_str(self.lw_topic) 103 | self._send_str(self.lw_msg) 104 | if self.user is not None: 105 | self._send_str(self.user) 106 | if self.pswd is not None: 107 | self._send_str(self.pswd) 108 | resp = self.sock.read(4) 109 | assert resp[0] == 0x20 and resp[1] == 0x02 110 | if resp[3] != 0: 111 | raise MQTTException(resp[3]) 112 | return resp[2] & 1 113 | 114 | def disconnect(self): 115 | self.sock.write(b"\xe0\0") 116 | self.sock.close() 117 | 118 | def ping(self): 119 | self.sock.write(b"\xc0\0") 120 | 121 | def publish(self, topic, msg, retain=False, qos=0): 122 | pkt = bytearray(b"\x30\0\0\0") 123 | pkt[0] |= qos << 1 | retain 124 | sz = 2 + len(topic) + len(msg) 125 | if qos > 0: 126 | sz += 2 127 | assert sz < 2097152 128 | i = 1 129 | while sz > 0x7F: 130 | pkt[i] = (sz & 0x7F) | 0x80 131 | sz >>= 7 132 | i += 1 133 | pkt[i] = sz 134 | # print(hex(len(pkt)), hexlify(pkt, ":")) 135 | self.sock.write(pkt, i + 1) 136 | self._send_str(topic) 137 | if qos > 0: 138 | self.pid += 1 139 | pid = self.pid 140 | struct.pack_into("!H", pkt, 0, pid) 141 | self.sock.write(pkt, 2) 142 | self.sock.write(msg) 143 | if qos == 1: 144 | while 1: 145 | op = self.wait_msg() 146 | if op == 0x40: 147 | sz = self.sock.read(1) 148 | assert sz == b"\x02" 149 | rcv_pid = self.sock.read(2) 150 | rcv_pid = rcv_pid[0] << 8 | rcv_pid[1] 151 | if pid == rcv_pid: 152 | return 153 | elif qos == 2: 154 | assert 0 155 | 156 | def subscribe(self, topic, qos=0): 157 | assert self.cb is not None, "Subscribe callback is not set" 158 | pkt = bytearray(b"\x82\0\0\0") 159 | self.pid += 1 160 | struct.pack_into("!BH", pkt, 1, 2 + 2 + len(topic) + 1, self.pid) 161 | # print(hex(len(pkt)), hexlify(pkt, ":")) 162 | self.sock.write(pkt) 163 | self._send_str(topic) 164 | self.sock.write(qos.to_bytes(1, "little")) 165 | while 1: 166 | op = self.wait_msg() 167 | if op == 0x90: 168 | resp = self.sock.read(4) 169 | # print(resp) 170 | assert resp[1] == pkt[2] and resp[2] == pkt[3] 171 | if resp[3] == 0x80: 172 | raise MQTTException(resp[3]) 173 | return 174 | 175 | # Wait for a single incoming MQTT message and process it. 176 | # Subscribed messages are delivered to a callback previously 177 | # set by .set_callback() method. Other (internal) MQTT 178 | # messages processed internally. 179 | def wait_msg(self): 180 | res = self.sock.read(1) 181 | self.sock.setblocking(True) 182 | if res is None: 183 | return None 184 | if res == b"": 185 | raise OSError(-1) 186 | if res == b"\xd0": # PINGRESP 187 | sz = self.sock.read(1)[0] 188 | assert sz == 0 189 | return None 190 | op = res[0] 191 | if op & 0xF0 != 0x30: 192 | return op 193 | sz = self._recv_len() 194 | topic_len = self.sock.read(2) 195 | topic_len = (topic_len[0] << 8) | topic_len[1] 196 | topic = self.sock.read(topic_len) 197 | sz -= topic_len + 2 198 | if op & 6: 199 | pid = self.sock.read(2) 200 | pid = pid[0] << 8 | pid[1] 201 | sz -= 2 202 | msg = self.sock.read(sz) 203 | self.cb(topic, msg) 204 | if op & 6 == 2: 205 | pkt = bytearray(b"\x40\x02\0\0") 206 | struct.pack_into("!H", pkt, 2, pid) 207 | self.sock.write(pkt) 208 | elif op & 6 == 4: 209 | assert 0 210 | return op 211 | 212 | # Checks whether a pending message from server is available. 213 | # If not, returns immediately with None. Otherwise, does 214 | # the same processing as wait_msg. 215 | def check_msg(self): 216 | self.sock.setblocking(False) 217 | return self.wait_msg() 218 | --------------------------------------------------------------------------------