├── tests ├── __init__.py ├── ci.sh └── ci.py ├── MANIFEST.in ├── .github └── workflows │ ├── changelog.json │ ├── check-commits.yml │ ├── python-linter.yml │ ├── python-package.yml │ └── client-test.yml ├── package.json ├── pyproject.toml ├── .gitignore ├── examples ├── micropython_async_wifi.py ├── example.py ├── micropython_basic.py └── micropython_advanced.py ├── src └── arduino_iot_cloud │ ├── ussl.py │ ├── __init__.py │ ├── umqtt.py │ └── ucloud.py ├── README.md └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include examples/* 2 | exclude .git* 3 | exclude .github/workflows/* 4 | exclude MANIFEST.in 5 | -------------------------------------------------------------------------------- /.github/workflows/changelog.json: -------------------------------------------------------------------------------- 1 | { 2 | "transformers": [ 3 | { 4 | "pattern": "^(.*)\/(.+:.*)", 5 | "target": "- $2" 6 | } 7 | ], 8 | "sort": "DESC", 9 | "template": "🪄 Changelog:\n\n${{UNCATEGORIZED}}\n", 10 | "pr_template": "- ${{TITLE}}", 11 | "empty_template": "- no changes", 12 | "max_tags_to_fetch": 100, 13 | "max_pull_requests": 100, 14 | "max_back_track_time_days": 100 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "urls": [ 3 | ["arduino_iot_cloud/__init__.py", "github:arduino/arduino-iot-cloud-py/src/arduino_iot_cloud/__init__.py"], 4 | ["arduino_iot_cloud/ucloud.py", "github:arduino/arduino-iot-cloud-py/src/arduino_iot_cloud/ucloud.py"], 5 | ["arduino_iot_cloud/umqtt.py", "github:arduino/arduino-iot-cloud-py/src/arduino_iot_cloud/umqtt.py"], 6 | ["arduino_iot_cloud/ussl.py", "github:arduino/arduino-iot-cloud-py/src/arduino_iot_cloud/ussl.py"] 7 | ], 8 | "deps": [ 9 | ["senml", "0.1.0"], 10 | ["logging", "0.6.0"] 11 | ], 12 | "version": "0.0.5" 13 | } 14 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=45.0", "setuptools_scm[toml]>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | write_to = "src/arduino_iot_cloud/_version.py" 7 | 8 | [project] 9 | name = "arduino_iot_cloud" 10 | dynamic = ["version"] 11 | authors = [ 12 | { name="Ibrahim Abdelkader", email="i.abdelkader@arduino.cc" }, 13 | ] 14 | description = "Arduino IoT Cloud Python client" 15 | readme = "README.md" 16 | requires-python = ">=3.8" 17 | classifiers = [ 18 | "Programming Language :: Python :: 3", 19 | "Topic :: Software Development :: Embedded Systems", 20 | "Operating System :: OS Independent", 21 | "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", 22 | ] 23 | dependencies = [ 24 | 'cbor2 >= 5.6.2', 25 | 'micropython-senml >= 0.1.1', 26 | ] 27 | 28 | [project.urls] 29 | "Homepage" = "https://github.com/arduino/arduino-iot-cloud-py" 30 | "Bug Tracker" = "https://github.com/arduino/arduino-iot-cloud-py/issues" 31 | -------------------------------------------------------------------------------- /.github/workflows/check-commits.yml: -------------------------------------------------------------------------------- 1 | name: '📜 Check Commit Messages' 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - edited 8 | - reopened 9 | - synchronize 10 | branches: 11 | - 'main' 12 | 13 | jobs: 14 | check-commit-messages: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: '📜 Check commit messages format' 18 | uses: gsactions/commit-message-checker@v1 19 | with: 20 | pattern: '^[^!]+: [A-Za-z]+.+ .+\.$' 21 | flags: 'gm' 22 | error: 'Commit subject line must match the following pattern: : .' 23 | excludeTitle: 'false' 24 | excludeDescription: 'true' 25 | checkAllCommitMessages: 'true' 26 | accessToken: ${{ secrets.GITHUB_TOKEN }} 27 | - name: '📜 Check commit messages length' 28 | uses: gsactions/commit-message-checker@v1 29 | with: 30 | pattern: '^[^#].{10,78}$' 31 | error: 'Commit subject line maximum line length of 78 characters is exceeded.' 32 | excludeTitle: 'false' 33 | excludeDescription: 'true' 34 | checkAllCommitMessages: 'true' 35 | accessToken: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /.github/workflows/python-linter.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: '🔎 Python Linter' 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | 19 | steps: 20 | - name: '⏳ Checkout repository' 21 | uses: actions/checkout@v3 22 | 23 | - name: '🐍 Set up Python' 24 | uses: actions/setup-python@v4 25 | with: 26 | cache: 'pip' 27 | python-version: "3.10" 28 | 29 | - name: '🛠 Install dependencies' 30 | run: | 31 | python -m pip install --upgrade pip 32 | python -m pip install flake8==6.0.0 pytest==7.4.0 33 | 34 | - name: '😾 Lint with flake8' 35 | run: | 36 | # stop the build if there are Python syntax errors or undefined names 37 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 38 | flake8 . --count --ignore=C901 --max-complexity=15 --max-line-length=120 --statistics 39 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: '📦 Python Package' 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*.*.*' 10 | branches: 11 | - "!*" 12 | paths: 13 | - '*.py' 14 | - '.github/workflows/*.yml' 15 | - '.github/workflows/*.json' 16 | - '!**/README.md' 17 | 18 | permissions: 19 | contents: write 20 | pull-requests: read 21 | 22 | jobs: 23 | build: 24 | runs-on: ubuntu-latest 25 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 26 | steps: 27 | - name: '⏳ Checkout repository' 28 | uses: actions/checkout@v3 29 | 30 | - name: '🐍 Set up Python 3' 31 | uses: actions/setup-python@v3 32 | with: 33 | python-version: "3.10" 34 | 35 | - name: '🛠 Install dependencies' 36 | run: | 37 | python -m pip install --upgrade pip 38 | python -m pip install build 39 | 40 | - name: '📦 Build package' 41 | run: python3 -m build 42 | 43 | - name: "✏️ Generate changelog" 44 | id: changelog 45 | uses: mikepenz/release-changelog-builder-action@v3 46 | with: 47 | configuration: '.github/workflows/changelog.json' 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | 51 | - name: '🔥 Create release' 52 | uses: softprops/action-gh-release@v1 53 | with: 54 | draft: false 55 | files: dist/* 56 | body: ${{steps.changelog.outputs.changelog}} 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | 60 | - name: '📦 Publish package' 61 | uses: pypa/gh-action-pypi-publish@release/v1 62 | with: 63 | password: ${{ secrets.PYPI_API_TOKEN }} 64 | -------------------------------------------------------------------------------- /tests/ci.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ci_install_micropython() { 4 | CACHE_DIR=${HOME}/cache/bin 5 | mkdir -p ${CACHE_DIR} 6 | 7 | sudo apt-get install gcc-arm-none-eabi libnewlib-arm-none-eabi 8 | 9 | git clone --depth=1 https://github.com/micropython/micropython.git 10 | 11 | cat > micropython/ports/unix/manifest.py <<-EOF 12 | include("\$(PORT_DIR)/variants/standard/manifest.py") 13 | require("bundle-networking") 14 | require("time") 15 | require("senml") 16 | require("logging") 17 | EOF 18 | 19 | echo "#undef MICROPY_PY_SELECT_SELECT" >> micropython/ports/unix/variants/mpconfigvariant_common.h 20 | echo "#undef MICROPY_PY_SELECT_POSIX_OPTIMISATIONS" >> micropython/ports/unix/variants/mpconfigvariant_common.h 21 | 22 | make -j12 -C micropython/mpy-cross/ 23 | make -j12 -C micropython/ports/unix/ submodules 24 | make -j12 -C micropython/ports/unix/ FROZEN_MANIFEST=manifest.py CFLAGS_EXTRA="-DMICROPY_PY_SELECT=1" 25 | cp micropython/ports/unix/build-standard/micropython ${CACHE_DIR} 26 | } 27 | 28 | ci_configure_softhsm() { 29 | TOKEN_DIR=${HOME}/softhsm/tokens/ 30 | TOKEN_URI="pkcs11:token=arduino" 31 | PROVIDER=/lib/x86_64-linux-gnu/softhsm/libsofthsm2.so 32 | 33 | mkdir -p ${TOKEN_DIR} 34 | cat > ${TOKEN_DIR}/softhsm2.conf <<-EOF 35 | directories.tokendir = ${TOKEN_DIR} 36 | objectstore.backend = file 37 | 38 | # ERROR, WARNING, INFO, DEBUG 39 | log.level = ERROR 40 | 41 | # If CKF_REMOVABLE_DEVICE flag should be set 42 | slots.removable = false 43 | 44 | # Enable and disable PKCS#11 mechanisms using slots.mechanisms. 45 | slots.mechanisms = ALL 46 | 47 | # If the library should reset the state on fork 48 | library.reset_on_fork = false 49 | EOF 50 | 51 | export SOFTHSM2_CONF=${TOKEN_DIR}/softhsm2.conf 52 | 53 | echo "$KEY_PEM" >> key.pem 54 | echo "$CERT_PEM" >> cert.pem 55 | echo "$CA_PEM" >> ca-root.pem 56 | 57 | softhsm2-util --init-token --slot 0 --label "arduino" --pin 1234 --so-pin 1234 58 | p11tool --provider=${PROVIDER} --login --set-pin=1234 --write ${TOKEN_URI} --load-privkey key.pem --label "mykey" 59 | p11tool --provider=${PROVIDER} --login --set-pin=1234 --write ${TOKEN_URI} --load-certificate cert.pem --label "mycert" 60 | 61 | # Convert to DER for MicroPython. 62 | openssl ec -in key.pem -out key.der -outform DER 63 | openssl x509 -in cert.pem -out cert.der -outform DER 64 | openssl x509 -in ca-root.pem -out ca-root.der -outform DER 65 | } 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /examples/micropython_async_wifi.py: -------------------------------------------------------------------------------- 1 | # This file is part of the Python Arduino IoT Cloud. 2 | # Any copyright is dedicated to the Public Domain. 3 | # https://creativecommons.org/publicdomain/zero/1.0/ 4 | 5 | import time 6 | import logging 7 | from time import strftime 8 | from machine import Pin 9 | 10 | from secrets import DEVICE_ID 11 | from secrets import SECRET_KEY 12 | 13 | from arduino_iot_cloud import Task 14 | from arduino_iot_cloud import ArduinoCloudClient 15 | from arduino_iot_cloud import async_wifi_connection 16 | 17 | 18 | def read_temperature(client): 19 | return 50.0 20 | 21 | 22 | def read_humidity(client): 23 | return 100.0 24 | 25 | 26 | def on_switch_changed(client, value): 27 | led = Pin("LED_BLUE", Pin.OUT) 28 | # Note the LED is usually inverted 29 | led.value(not value) 30 | # Update the value of the led cloud variable. 31 | client["led"] = value 32 | 33 | 34 | if __name__ == "__main__": 35 | # Configure the logger. 36 | # All message equal or higher to the logger level are printed. 37 | # To see more debugging messages, set level=logging.DEBUG. 38 | logging.basicConfig( 39 | datefmt="%H:%M:%S", 40 | format="%(asctime)s.%(msecs)03d %(message)s", 41 | level=logging.INFO, 42 | ) 43 | 44 | # Create a client object to connect to the Arduino IoT cloud. 45 | # The most basic authentication method uses a username and password. The username is the device 46 | # ID, and the password is the secret key obtained from the IoT cloud when provisioning a device. 47 | client = ArduinoCloudClient( 48 | device_id=DEVICE_ID, username=DEVICE_ID, password=SECRET_KEY 49 | ) 50 | 51 | # Register cloud objects. 52 | # Note: The following objects must be created first in the dashboard and linked to the device. 53 | # This cloud object is initialized with its last known value from the cloud. When this object is updated 54 | # from the dashboard, the on_switch_changed function is called with the client object and the new value. 55 | client.register("sw1", value=None, on_write=on_switch_changed, interval=0.250) 56 | 57 | # This cloud object is updated manually in the switch's on_write_change callback to update 58 | # the LED state in the cloud. 59 | client.register("led", value=None) 60 | 61 | # This is a periodic cloud object that gets updated at fixed intervals (in this case 1 seconed) with the 62 | # value returned from its on_read function (a formatted string of the current time). Note this object's 63 | # initial value is None, it will be initialized by calling the on_read function. 64 | client.register( 65 | "clk", 66 | value=None, 67 | on_read=lambda x: strftime("%H:%M:%S", time.localtime()), 68 | interval=1.0, 69 | ) 70 | 71 | # Register some sensor readings. 72 | client.register("humidity", value=None, on_read=read_humidity, interval=1.0) 73 | client.register("temperature", value=None, on_read=read_temperature, interval=1.0) 74 | 75 | # This function is registered as a background task to reconnect to WiFi if it ever gets 76 | # disconnected. Note, it can also be used for the initial WiFi connection, in synchronous 77 | # mode, if it's called without any args (i.e, async_wifi_connection()) at the beginning of 78 | # this script. 79 | client.register( 80 | Task("wifi_connection", on_run=async_wifi_connection, interval=60.0) 81 | ) 82 | 83 | # Start the Arduino IoT cloud client. 84 | client.start() 85 | -------------------------------------------------------------------------------- /.github/workflows/client-test.yml: -------------------------------------------------------------------------------- 1 | name: '🧪 Test Cloud Client' 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | paths: 8 | - '**.py' 9 | - '.github/workflows/*.yml' 10 | - '.github/workflows/*.json' 11 | - '!**/README.md' 12 | 13 | pull_request: 14 | types: 15 | - opened 16 | - edited 17 | - reopened 18 | - synchronize 19 | branches: 20 | - 'main' 21 | paths: 22 | - '**.py' 23 | - '.github/workflows/*.yml' 24 | - '.github/workflows/*.json' 25 | - '!**/README.md' 26 | 27 | schedule: 28 | - cron: '0 12 * * *' # Runs every day at 12 PM UTC 29 | 30 | jobs: 31 | build: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: '⏳ Checkout repository' 35 | uses: actions/checkout@v3 36 | 37 | - name: '♻ Caching dependencies' 38 | uses: actions/cache@v4.2.2 39 | id: cache 40 | with: 41 | path: ~/cache/bin/ 42 | key: 'micropython' 43 | 44 | - name: '🐍 Set up Python' 45 | uses: actions/setup-python@v4 46 | with: 47 | cache: 'pip' 48 | python-version: "3.10" 49 | 50 | - name: '🐍 Set up MicroPython' 51 | if: steps.cache.outputs.cache-hit != 'true' 52 | run: source tests/ci.sh && ci_install_micropython 53 | 54 | - name: '🛠 Install dependencies' 55 | run: | 56 | python -m pip install --upgrade pip 57 | python -m pip install build==0.10.0 cbor2==5.4.6 M2Crypto==0.38.0 micropython-senml==0.1.0 58 | sudo apt-get update 59 | sudo apt-get install softhsm2 gnutls-bin libengine-pkcs11-openssl 60 | 61 | - name: '📦 Build package' 62 | run: python3 -m build 63 | 64 | - name: '🛠 Install package' 65 | run: | 66 | python3 -m build 67 | pip install --user dist/arduino_iot_cloud-*.whl 68 | pip install --target=${HOME}/.micropython/lib dist/arduino_iot_cloud-*.whl 69 | 70 | - name: '🔑 Configure secure element' 71 | env: 72 | KEY_PEM: ${{ secrets.KEY_PEM }} 73 | CERT_PEM: ${{ secrets.CERT_PEM }} 74 | CA_PEM: ${{ secrets.CA_PEM }} 75 | run: | 76 | source tests/ci.sh && ci_configure_softhsm 77 | 78 | - name: '☁️ Connect to IoT cloud (CPython / Basic Auth / Async)' 79 | env: 80 | DEVICE_ID: ${{ secrets.DEVICE_ID1 }} 81 | SECRET_KEY: ${{ secrets.SECRET_KEY }} 82 | run: | 83 | python tests/ci.py --basic-auth 84 | 85 | - name: '☁️ Connect to IoT cloud (CPython / Basic Auth / Sync)' 86 | env: 87 | DEVICE_ID: ${{ secrets.DEVICE_ID1 }} 88 | SECRET_KEY: ${{ secrets.SECRET_KEY }} 89 | run: | 90 | python tests/ci.py --basic-auth --sync 91 | 92 | - name: '☁️ Connect to IoT cloud (CPython / Key-Cert Auth / Async)' 93 | env: 94 | DEVICE_ID: ${{ secrets.DEVICE_ID2 }} 95 | run: | 96 | python tests/ci.py --file-auth 97 | 98 | - name: '☁️ Connect to IoT cloud (CPython / Key-Cert Auth / CADATA / Async)' 99 | env: 100 | DEVICE_ID: ${{ secrets.DEVICE_ID2 }} 101 | run: | 102 | python tests/ci.py --file-auth --ca-data 103 | 104 | - name: '☁️ Connect to IoT cloud (CPython / Crypto Auth / Async)' 105 | env: 106 | DEVICE_ID: ${{ secrets.DEVICE_ID2 }} 107 | run: | 108 | export SOFTHSM2_CONF="${HOME}/softhsm/tokens/softhsm2.conf" 109 | python tests/ci.py --crypto-device 110 | 111 | - name: '☁️ Connect to IoT cloud (MicroPython / Basic Auth / Async)' 112 | env: 113 | DEVICE_ID: ${{ secrets.DEVICE_ID1 }} 114 | SECRET_KEY: ${{ secrets.SECRET_KEY }} 115 | run: | 116 | export PATH="${HOME}/cache/bin:${PATH}" 117 | micropython -c "import sys; print(sys.path)" 118 | micropython tests/ci.py --basic-auth 119 | 120 | - name: '☁️ Connect to IoT cloud (MicroPython / Basic Auth / Sync)' 121 | env: 122 | DEVICE_ID: ${{ secrets.DEVICE_ID1 }} 123 | SECRET_KEY: ${{ secrets.SECRET_KEY }} 124 | run: | 125 | export PATH="${HOME}/cache/bin:${PATH}" 126 | micropython -c "import sys; print(sys.path)" 127 | micropython tests/ci.py --basic-auth --sync 128 | 129 | - name: '☁️ Connect to IoT cloud (MicroPython / Key-Cert Auth / Async)' 130 | env: 131 | DEVICE_ID: ${{ secrets.DEVICE_ID2 }} 132 | run: | 133 | export PATH="${HOME}/cache/bin:${PATH}" 134 | micropython -c "import sys; print(sys.path)" 135 | micropython tests/ci.py --file-auth 136 | -------------------------------------------------------------------------------- /tests/ci.py: -------------------------------------------------------------------------------- 1 | # This file is part of the Python Arduino IoT Cloud. 2 | # Any copyright is dedicated to the Public Domain. 3 | # https://creativecommons.org/publicdomain/zero/1.0/ 4 | import logging 5 | import os 6 | import time 7 | import sys 8 | import asyncio 9 | from arduino_iot_cloud import ArduinoCloudClient 10 | from arduino_iot_cloud import Task 11 | from arduino_iot_cloud import CADATA # noqa 12 | import argparse 13 | 14 | 15 | def exception_handler(loop, context): 16 | pass 17 | 18 | 19 | def on_value_changed(client, value): 20 | logging.info(f"The answer to life, the universe, and everything is {value}") 21 | loop = asyncio.get_event_loop() 22 | loop.set_exception_handler(exception_handler) 23 | sys.exit(0) 24 | 25 | 26 | def wdt_task(client, args, ts=[None]): 27 | if ts[0] is None: 28 | ts[0] = time.time() 29 | if time.time() - ts[0] > 20: 30 | loop = asyncio.get_event_loop() 31 | loop.set_exception_handler(exception_handler) 32 | logging.error("Timeout waiting for variable") 33 | sys.exit(1) 34 | 35 | 36 | if __name__ == "__main__": 37 | # Parse command line args. 38 | parser = argparse.ArgumentParser(description="arduino_iot_cloud.py") 39 | parser.add_argument( 40 | "-d", "--debug", action="store_true", help="Enable debugging messages" 41 | ) 42 | parser.add_argument( 43 | "-b", "--basic-auth", action="store_true", help="Username and password auth", 44 | ) 45 | parser.add_argument( 46 | "-c", "--crypto-device", action="store_true", help="Use soft-hsm/crypto device", 47 | ) 48 | parser.add_argument( 49 | "-f", "--file-auth", action="store_true", help="Use key/cert files" 50 | ) 51 | parser.add_argument( 52 | "-ca", "--ca-data", action="store_true", help="Use embedded CADATA" 53 | ) 54 | parser.add_argument( 55 | "-s", "--sync", action="store_true", help="Run in synchronous mode" 56 | ) 57 | args = parser.parse_args() 58 | 59 | # Configure the logger. 60 | # All message equal or higher to the logger level are printed. 61 | # To see more debugging messages, pass --debug on the command line. 62 | logging.basicConfig( 63 | datefmt="%H:%M:%S", 64 | format="%(asctime)s.%(msecs)03d %(message)s", 65 | level=logging.DEBUG if args.debug else logging.INFO, 66 | ) 67 | 68 | # Create a client object to connect to the Arduino IoT cloud. 69 | # To use a secure element, set the token's "pin" and URI in "keyfile" and "certfile", and 70 | # the CA certificate (if any) in "ssl_params". Alternatively, a username and password can 71 | # be used to authenticate, for example: 72 | # client = ArduinoCloudClient(device_id=DEVICE_ID, username=DEVICE_ID, password=SECRET_KEY) 73 | if args.basic_auth: 74 | client = ArduinoCloudClient( 75 | device_id=os.getenv("DEVICE_ID"), 76 | username=os.getenv("DEVICE_ID"), 77 | password=os.getenv("SECRET_KEY"), 78 | sync_mode=args.sync, 79 | ) 80 | elif args.file_auth: 81 | import ssl 82 | fmt = "der" if sys.implementation.name == "micropython" else "pem" 83 | ca_key = "cadata" if args.ca_data else "cafile" 84 | ca_val = CADATA if args.ca_data else f"ca-root.{fmt}" 85 | client = ArduinoCloudClient( 86 | device_id=os.getenv("DEVICE_ID"), 87 | ssl_params={ 88 | "keyfile": f"key.{fmt}", 89 | "certfile": f"cert.{fmt}", 90 | ca_key: ca_val, 91 | "cert_reqs": ssl.CERT_REQUIRED, 92 | }, 93 | sync_mode=args.sync, 94 | ) 95 | elif args.crypto_device: 96 | import ssl 97 | client = ArduinoCloudClient( 98 | device_id=os.getenv("DEVICE_ID"), 99 | ssl_params={ 100 | "pin": "1234", 101 | "use_hsm": True, 102 | "keyfile": "pkcs11:token=arduino", 103 | "certfile": "pkcs11:token=arduino", 104 | "cafile": "ca-root.pem", 105 | "cert_reqs": ssl.CERT_REQUIRED, 106 | "engine_path": "/lib/x86_64-linux-gnu/engines-3/libpkcs11.so", 107 | "module_path": "/lib/x86_64-linux-gnu/softhsm/libsofthsm2.so", 108 | }, 109 | sync_mode=args.sync, 110 | ) 111 | else: 112 | parser.print_help() 113 | sys.exit(1) 114 | 115 | # Register cloud objects. 116 | # When this object gets initialized from the cloud the test is complete. 117 | client.register("answer", value=None, on_write=on_value_changed) 118 | # This task will exist with failure after a timeout. 119 | client.register(Task("wdt_task", on_run=wdt_task, interval=1.0)) 120 | 121 | # Start the Arduino IoT cloud client. 122 | client.start() 123 | -------------------------------------------------------------------------------- /src/arduino_iot_cloud/ussl.py: -------------------------------------------------------------------------------- 1 | # This file is part of the Arduino IoT Cloud Python client. 2 | # Copyright (c) 2022 Arduino SA 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | # 7 | # SSL module with m2crypto backend for HSM support. 8 | 9 | import ssl 10 | import sys 11 | import logging 12 | try: 13 | from micropython import const 14 | except (ImportError, AttributeError): 15 | def const(x): 16 | return x 17 | 18 | pkcs11 = None 19 | se_dev = None 20 | 21 | # Default engine and provider. 22 | _ENGINE_PATH = "/usr/lib/engines-3/libpkcs11.so" 23 | _MODULE_PATH = "/usr/lib/softhsm/libsofthsm2.so" 24 | 25 | # Reference EC key for the SE. 26 | _EC_REF_KEY = const( 27 | b"\x30\x41\x02\x01\x00\x30\x13\x06\x07\x2A\x86\x48\xCE\x3D\x02\x01" 28 | b"\x06\x08\x2A\x86\x48\xCE\x3D\x03\x01\x07\x04\x27\x30\x25\x02\x01" 29 | b"\x01\x04\x20\xA5\xA6\xB5\xB6\xA5\xA6\xB5\xB6\x00\x00\x00\x00\x00" 30 | b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xFF" 31 | b"\xFF\xFF\xFF" 32 | ) 33 | 34 | 35 | def log_level_enabled(level): 36 | return logging.getLogger().isEnabledFor(level) 37 | 38 | 39 | def ecdsa_sign_callback(key, data): 40 | if log_level_enabled(logging.DEBUG): 41 | key_hex = "".join("%02X" % b for b in key) 42 | logging.debug(f"ecdsa_sign_callback key:{key_hex}") 43 | 44 | if key[0:8] != b"\xA5\xA6\xB5\xB6\xA5\xA6\xB5\xB6": 45 | if log_level_enabled(logging.DEBUG): 46 | logging.debug("ecdsa_sign_callback falling back to default sign") 47 | return None 48 | 49 | obj_id = int.from_bytes(key[-4:], "big") 50 | if log_level_enabled(logging.DEBUG): 51 | logging.debug(f"ecdsa_sign_callback oid: 0x{obj_id:02X}") 52 | 53 | # Sign data on SE using reference key object id. 54 | sig = se_dev.sign(obj_id, data) 55 | if log_level_enabled(logging.DEBUG): 56 | sig_hex = "".join("%02X" % b for b in sig) 57 | logging.debug(f"ecdsa_sign_callback sig: {sig_hex}") 58 | logging.info("Signed using secure element") 59 | return sig 60 | 61 | 62 | def wrap_socket(sock, ssl_params={}): 63 | keyfile = ssl_params.get("keyfile", None) 64 | certfile = ssl_params.get("certfile", None) 65 | cafile = ssl_params.get("cafile", None) 66 | cadata = ssl_params.get("cadata", None) 67 | ciphers = ssl_params.get("ciphers", None) 68 | verify = ssl_params.get("verify_mode", ssl.CERT_NONE) 69 | hostname = ssl_params.get("server_hostname", None) 70 | 71 | se_key_token = keyfile is not None and "token" in keyfile 72 | se_crt_token = certfile is not None and "token" in certfile 73 | sys_micropython = sys.implementation.name == "micropython" 74 | 75 | if sys_micropython and (se_key_token or se_crt_token): 76 | import se05x 77 | 78 | # Create and initialize SE05x device. 79 | global se_dev 80 | if se_dev is None: 81 | se_dev = se05x.SE05X() 82 | 83 | if se_key_token: 84 | # Create a reference key for the secure element. 85 | obj_id = int(keyfile.split("=")[1], 16) 86 | keyfile = _EC_REF_KEY[0:-4] + obj_id.to_bytes(4, "big") 87 | 88 | if se_crt_token: 89 | # Load the certificate from the secure element. 90 | certfile = se_dev.read(0x65, 412) 91 | 92 | if keyfile is None or "token" not in keyfile: 93 | # Use MicroPython/CPython SSL to wrap socket. 94 | ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 95 | if hasattr(ctx, "set_default_verify_paths"): 96 | ctx.set_default_verify_paths() 97 | if hasattr(ctx, "check_hostname") and verify != ssl.CERT_REQUIRED: 98 | ctx.check_hostname = False 99 | ctx.verify_mode = verify 100 | if keyfile is not None and certfile is not None: 101 | ctx.load_cert_chain(certfile, keyfile) 102 | if ciphers is not None: 103 | ctx.set_ciphers(ciphers) 104 | if cafile is not None or cadata is not None: 105 | ctx.load_verify_locations(cafile=cafile, cadata=cadata) 106 | if sys_micropython and se_key_token: 107 | # Set alternate ECDSA sign function. 108 | ctx._context.ecdsa_sign_callback = ecdsa_sign_callback 109 | return ctx.wrap_socket(sock, server_hostname=hostname) 110 | else: 111 | # Use M2Crypto to load key and cert from HSM. 112 | try: 113 | from M2Crypto import m2, SSL, Engine 114 | except (ImportError, AttributeError): 115 | logging.error("The m2crypto module is required to use HSM.") 116 | sys.exit(1) 117 | 118 | global pkcs11 119 | if pkcs11 is None: 120 | pkcs11 = Engine.load_dynamic_engine( 121 | "pkcs11", ssl_params.get("engine_path", _ENGINE_PATH) 122 | ) 123 | pkcs11.ctrl_cmd_string( 124 | "MODULE_PATH", ssl_params.get("module_path", _MODULE_PATH) 125 | ) 126 | if "pin" in ssl_params: 127 | pkcs11.ctrl_cmd_string("PIN", ssl_params["pin"]) 128 | pkcs11.init() 129 | 130 | # Create and configure SSL context 131 | ctx = SSL.Context("tls") 132 | ctx.set_default_verify_paths() 133 | ctx.set_allow_unknown_ca(False) 134 | if verify == ssl.CERT_NONE: 135 | ctx.set_verify(SSL.verify_none, depth=9) 136 | else: 137 | ctx.set_verify(SSL.verify_peer | SSL.verify_fail_if_no_peer_cert, depth=9) 138 | if cafile is not None: 139 | if ctx.load_verify_locations(cafile) != 1: 140 | raise Exception("Failed to load CA certs") 141 | if ciphers is not None: 142 | ctx.set_cipher_list(ciphers) 143 | 144 | key = pkcs11.load_private_key(keyfile) 145 | m2.ssl_ctx_use_pkey_privkey(ctx.ctx, key.pkey) 146 | 147 | cert = pkcs11.load_certificate(certfile) 148 | m2.ssl_ctx_use_x509(ctx.ctx, cert.x509) 149 | 150 | sslobj = SSL.Connection(ctx, sock=sock) 151 | if verify == ssl.CERT_NONE: 152 | sslobj.clientPostConnectionCheck = None 153 | elif hostname is not None: 154 | sslobj.set1_host(hostname) 155 | return sslobj 156 | -------------------------------------------------------------------------------- /examples/example.py: -------------------------------------------------------------------------------- 1 | # This file is part of the Python Arduino IoT Cloud. 2 | # Any copyright is dedicated to the Public Domain. 3 | # https://creativecommons.org/publicdomain/zero/1.0/ 4 | import time 5 | import logging 6 | from time import strftime 7 | from arduino_iot_cloud import ArduinoCloudClient 8 | from arduino_iot_cloud import Location 9 | from arduino_iot_cloud import Schedule 10 | from arduino_iot_cloud import ColoredLight 11 | from arduino_iot_cloud import Task 12 | from random import uniform 13 | import argparse 14 | import ssl # noqa 15 | 16 | from secrets import DEVICE_ID 17 | from secrets import SECRET_KEY # noqa 18 | 19 | KEY_PATH = "pkcs11:token=arduino" 20 | CERT_PATH = "pkcs11:token=arduino" 21 | CA_PATH = "ca-root.pem" 22 | 23 | 24 | def on_switch_changed(client, value): 25 | # This is a write callback for the switch that toggles the LED variable. The LED 26 | # variable can be accessed via the client object passed in the first argument. 27 | client["led"] = value 28 | 29 | 30 | def on_clight_changed(client, clight): 31 | logging.info(f"ColoredLight changed. Swi: {clight.swi} Bri: {clight.bri} Sat: {clight.sat} Hue: {clight.hue}") 32 | 33 | 34 | def user_task(client, args): 35 | # NOTE: this function should not block. 36 | # This is a user-defined task that updates the colored light. Note any registered 37 | # cloud object can be accessed using the client object passed to this function. 38 | # The composite ColoredLight object fields can be assigned to individually, using dot: 39 | client["clight"].hue = round(uniform(0, 100), 1) 40 | client["clight"].bri = round(uniform(0, 100), 1) 41 | 42 | 43 | if __name__ == "__main__": 44 | # Parse command line args. 45 | parser = argparse.ArgumentParser(description="arduino_iot_cloud.py") 46 | parser.add_argument("-d", "--debug", action="store_true", help="Enable debugging messages") 47 | parser.add_argument("-s", "--sync", action="store_true", help="Run in synchronous mode") 48 | args = parser.parse_args() 49 | 50 | # Assume the host has an active Internet connection. 51 | 52 | # Configure the logger. 53 | # All message equal or higher to the logger level are printed. 54 | # To see more debugging messages, pass --debug on the command line. 55 | logging.basicConfig( 56 | datefmt="%H:%M:%S", 57 | format="%(asctime)s.%(msecs)03d %(message)s", 58 | level=logging.DEBUG if args.debug else logging.INFO, 59 | ) 60 | 61 | # Create a client object to connect to the Arduino IoT cloud. 62 | # The most basic authentication method uses a username and password. The username is the device 63 | # ID, and the password is the secret key obtained from the IoT cloud when provisioning a device. 64 | client = ArduinoCloudClient(device_id=DEVICE_ID, username=DEVICE_ID, password=SECRET_KEY, sync_mode=args.sync) 65 | 66 | # Alternatively, the client supports key and certificate-based authentication. To use this 67 | # mode, set "keyfile" and "certfile", and specify the CA certificate (if any) in "ssl_params". 68 | # Secure elements, which can be used to store the key and certificate, are also supported. 69 | # To use secure elements, provide the key and certificate URIs (in provider:token format) and 70 | # set the token's PIN (if applicable). For example: 71 | # client = ArduinoCloudClient( 72 | # device_id=DEVICE_ID, 73 | # ssl_params={ 74 | # "pin": "1234", "keyfile": KEY_PATH, "certfile": CERT_PATH, "cafile": CA_PATH, 75 | # "verify_mode": ssl.CERT_REQUIRED, "server_hostname" : "iot.arduino.cc" 76 | # }, 77 | # sync_mode=args.sync, 78 | # ) 79 | 80 | # Register cloud objects. 81 | # Note: The following objects must be created first in the dashboard and linked to the device. 82 | # This cloud object is initialized with its last known value from the cloud. When this object is updated 83 | # from the dashboard, the on_switch_changed function is called with the client object and the new value. 84 | client.register("sw1", value=None, on_write=on_switch_changed, interval=0.250) 85 | 86 | # This cloud object is updated manually in the switch's on_write_change callback. 87 | client.register("led", value=None) 88 | 89 | # This is a periodic cloud object that gets updated at fixed intervals (in this case 1 seconed) with the 90 | # value returned from its on_read function (a formatted string of the current time). Note this object's 91 | # initial value is None, it will be initialized by calling the on_read function. 92 | client.register("clk", value=None, on_read=lambda x: strftime("%H:%M:%S", time.localtime()), interval=1.0) 93 | 94 | # This is an example of a composite cloud object (a cloud object with multiple variables). In this case 95 | # a colored light with switch, hue, saturation and brightness attributes. Once initialized, the object's 96 | # attributes can be accessed using dot notation. For example: client["clight"].swi = False. 97 | client.register(ColoredLight("clight", swi=True, on_write=on_clight_changed)) 98 | 99 | # This is another example of a composite cloud object, a map location with lat and long attributes. 100 | client.register(Location("treasureisland", lat=31.264694, lon=29.979987)) 101 | 102 | # This object allows scheduling recurring events from the cloud UI. On activation of the event, if the 103 | # on_active callback is provided, it gets called with the client object and the schedule object value. 104 | # Note: The activation status of the object can also be polled using client["schedule"].active. 105 | client.register(Schedule("schedule", on_active=lambda client, value: logging.info(f"Schedule activated {value}!"))) 106 | 107 | # The client can also schedule user code in a task and run it along with the other cloud objects. 108 | # To schedule a user function, use the Task object and pass the task name and function in "on_run" 109 | # to client.register(). 110 | client.register(Task("user_task", on_run=user_task, interval=1.0)) 111 | 112 | # Start the Arduino IoT cloud client. In synchronous mode, this function returns immediately 113 | # after connecting to the cloud. 114 | client.start() 115 | 116 | # In sync mode, start returns after connecting, and the client must be polled periodically. 117 | while True: 118 | client.update() 119 | time.sleep(0.100) 120 | -------------------------------------------------------------------------------- /examples/micropython_basic.py: -------------------------------------------------------------------------------- 1 | # This file is part of the Python Arduino IoT Cloud. 2 | # Any copyright is dedicated to the Public Domain. 3 | # https://creativecommons.org/publicdomain/zero/1.0/ 4 | import time 5 | import ssl # noqa 6 | import network 7 | import logging 8 | from time import strftime 9 | from arduino_iot_cloud import ArduinoCloudClient 10 | from arduino_iot_cloud import Location 11 | from arduino_iot_cloud import Schedule 12 | from arduino_iot_cloud import ColoredLight 13 | from arduino_iot_cloud import Task 14 | from arduino_iot_cloud import CADATA # noqa 15 | from random import uniform 16 | from secrets import WIFI_SSID 17 | from secrets import WIFI_PASS 18 | from secrets import DEVICE_ID 19 | from secrets import SECRET_KEY # noqa 20 | 21 | 22 | def on_switch_changed(client, value): 23 | # This is a write callback for the switch that toggles the LED variable. The LED 24 | # variable can be accessed via the client object passed in the first argument. 25 | client["led"] = value 26 | 27 | 28 | def on_clight_changed(client, clight): 29 | logging.info(f"ColoredLight changed. Swi: {clight.swi} Bri: {clight.bri} Sat: {clight.sat} Hue: {clight.hue}") 30 | 31 | 32 | def user_task(client, args): 33 | # NOTE: this function should not block. 34 | # This is a user-defined task that updates the colored light. Note any registered 35 | # cloud object can be accessed using the client object passed to this function. 36 | # The composite ColoredLight object fields can be assigned to individually, using dot: 37 | client["clight"].hue = round(uniform(0, 100), 1) 38 | client["clight"].bri = round(uniform(0, 100), 1) 39 | 40 | 41 | def wdt_task(client, wdt): 42 | # Update the WDT to prevent it from resetting the system 43 | wdt.feed() 44 | 45 | 46 | def wifi_connect(): 47 | if not WIFI_SSID or not WIFI_PASS: 48 | raise (Exception("Network is not configured. Set SSID and passwords in secrets.py")) 49 | wlan = network.WLAN(network.STA_IF) 50 | wlan.active(True) 51 | wlan.connect(WIFI_SSID, WIFI_PASS) 52 | while not wlan.isconnected(): 53 | logging.info("Trying to connect. Note this may take a while...") 54 | time.sleep_ms(500) 55 | logging.info(f"WiFi Connected {wlan.ifconfig()}") 56 | 57 | 58 | if __name__ == "__main__": 59 | # Configure the logger. 60 | # All message equal or higher to the logger level are printed. 61 | # To see more debugging messages, set level=logging.DEBUG. 62 | logging.basicConfig( 63 | datefmt="%H:%M:%S", 64 | format="%(asctime)s.%(msecs)03d %(message)s", 65 | level=logging.INFO, 66 | ) 67 | 68 | # NOTE: Add networking code here or in boot.py 69 | wifi_connect() 70 | 71 | # Create a client object to connect to the Arduino IoT cloud. 72 | # The most basic authentication method uses a username and password. The username is the device 73 | # ID, and the password is the secret key obtained from the IoT cloud when provisioning a device. 74 | client = ArduinoCloudClient(device_id=DEVICE_ID, username=DEVICE_ID, password=SECRET_KEY, sync_mode=False) 75 | 76 | # Register cloud objects. 77 | # Note: The following objects must be created first in the dashboard and linked to the device. 78 | # This cloud object is initialized with its last known value from the cloud. When this object is updated 79 | # from the dashboard, the on_switch_changed function is called with the client object and the new value. 80 | client.register("sw1", value=None, on_write=on_switch_changed, interval=0.250) 81 | 82 | # This cloud object is updated manually in the switch's on_write_change callback. 83 | client.register("led", value=None) 84 | 85 | # This is a periodic cloud object that gets updated at fixed intervals (in this case 1 seconed) with the 86 | # value returned from its on_read function (a formatted string of the current time). Note this object's 87 | # initial value is None, it will be initialized by calling the on_read function. 88 | client.register("clk", value=None, on_read=lambda x: strftime("%H:%M:%S", time.localtime()), interval=1.0) 89 | 90 | # This is an example of a composite cloud object (a cloud object with multiple variables). In this case 91 | # a colored light with switch, hue, saturation and brightness attributes. Once initialized, the object's 92 | # attributes can be accessed using dot notation. For example: client["clight"].swi = False. 93 | client.register(ColoredLight("clight", swi=True, on_write=on_clight_changed)) 94 | 95 | # This is another example of a composite cloud object, a map location with lat and long attributes. 96 | client.register(Location("treasureisland", lat=31.264694, lon=29.979987)) 97 | 98 | # This object allows scheduling recurring events from the cloud UI. On activation of the event, if the 99 | # on_active callback is provided, it gets called with the client object and the schedule object value. 100 | # Note: The activation status of the object can also be polled using client["schedule"].active. 101 | client.register(Schedule("schedule", on_active=lambda client, value: logging.info(f"Schedule activated {value}!"))) 102 | 103 | # The client can also schedule user code in a task and run it along with the other cloud objects. 104 | # To schedule a user function, use the Task object and pass the task name and function in "on_run" 105 | # to client.register(). 106 | client.register(Task("user_task", on_run=user_task, interval=1.0)) 107 | 108 | # If a Watchdog timer is available, it can be used to recover the system by resetting it, if it ever 109 | # hangs or crashes for any reason. NOTE: once the WDT is enabled it must be reset periodically to 110 | # prevent it from resetting the system, which is done in another user task. 111 | # NOTE: Change the following to True to enable the WDT. 112 | if False: 113 | try: 114 | from machine import WDT 115 | # Enable the WDT with a timeout of 5s (1s is the minimum) 116 | wdt = WDT(timeout=7500) 117 | client.register(Task("watchdog_task", on_run=wdt_task, interval=1.0, args=wdt)) 118 | except (ImportError, AttributeError): 119 | pass 120 | 121 | # Start the Arduino IoT cloud client. In synchronous mode, this function returns immediately 122 | # after connecting to the cloud. 123 | client.start() 124 | 125 | # In sync mode, start returns after connecting, and the client must be polled periodically. 126 | while True: 127 | client.update() 128 | time.sleep(0.100) 129 | -------------------------------------------------------------------------------- /src/arduino_iot_cloud/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of the Arduino IoT Cloud Python client. 2 | # Copyright (c) 2022 Arduino SA 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | 7 | import binascii 8 | from .ucloud import ArduinoCloudClient # noqa 9 | from .ucloud import ArduinoCloudObject 10 | from .ucloud import ArduinoCloudObject as Task # noqa 11 | from .ucloud import timestamp 12 | 13 | 14 | CADATA = binascii.unhexlify( 15 | b"308201d030820176a00302010202146fad9e2bf56fd5b69a3c0698e43003" 16 | b"0546f1075a300a06082a8648ce3d0403023045310b300906035504061302" 17 | b"555331173015060355040a130e41726475696e6f204c4c43205553310b30" 18 | b"09060355040b130249543110300e0603550403130741726475696e6f3020" 19 | b"170d3235303131303130353332325a180f32303535303130333130353332" 20 | b"325a3045310b300906035504061302555331173015060355040a130e4172" 21 | b"6475696e6f204c4c43205553310b3009060355040b130249543110300e06" 22 | b"03550403130741726475696e6f3059301306072a8648ce3d020106082a86" 23 | b"48ce3d03010703420004a1e1536c35521a330de82bac5b12c18f5037b33e" 24 | b"649ba0ee270235c78d5a1045d0caf552ec97f29aff81c6e279973fd339c6" 25 | b"d7a1cc6b618570f63bae621d71c8a3423040300f0603551d130101ff0405" 26 | b"30030101ff300e0603551d0f0101ff040403020106301d0603551d0e0416" 27 | b"041442652984d00488d1c603f409b378e33b1228e03e300a06082a8648ce" 28 | b"3d0403020348003045022100cfa4cb60ff5e8953abfdc554ff5d73c06a1f" 29 | b"7bf16835ee29d41973325ec6555002206f2d2f74dad966c839a515a412f6" 30 | b"81f2ee23d5925b5b11fbecd73fecc58667a7" 31 | ) 32 | 33 | 34 | class Location(ArduinoCloudObject): 35 | def __init__(self, name, **kwargs): 36 | super().__init__(name, keys={"lat", "lon"}, **kwargs) 37 | 38 | 39 | class Color(ArduinoCloudObject): 40 | def __init__(self, name, **kwargs): 41 | super().__init__(name, keys={"hue", "sat", "bri"}, **kwargs) 42 | 43 | 44 | class ColoredLight(ArduinoCloudObject): 45 | def __init__(self, name, **kwargs): 46 | super().__init__(name, keys={"swi", "hue", "sat", "bri"}, **kwargs) 47 | 48 | 49 | class DimmedLight(ArduinoCloudObject): 50 | def __init__(self, name, **kwargs): 51 | super().__init__(name, keys={"swi", "bri"}, **kwargs) 52 | 53 | 54 | class Schedule(ArduinoCloudObject): 55 | def __init__(self, name, **kwargs): 56 | kwargs.update({("on_run", self.on_run)}) 57 | self.on_active = kwargs.pop("on_active", None) 58 | # Uncomment to allow the schedule to change in runtime. 59 | # kwargs["on_write"] = kwargs.get("on_write", lambda aiot, value: None) 60 | self.active = False 61 | super().__init__(name, keys={"frm", "to", "len", "msk"}, **kwargs) 62 | 63 | def on_run(self, aiot, args=None): 64 | if self.initialized: 65 | ts = timestamp() + aiot.get("tz_offset", 0) 66 | if ts > self.frm and ts < (self.frm + self.len): 67 | if not self.active and self.on_active is not None: 68 | self.on_active(aiot, self.value) 69 | self.active = True 70 | else: 71 | self.active = False 72 | 73 | 74 | class Television(ArduinoCloudObject): 75 | PLAYBACK_FASTFORWARD = 0 76 | PLAYBACK_NEXT = 1 77 | PLAYBACK_PAUSE = 2 78 | PLAYBACK_PLAY = 3 79 | PLAYBACK_PREVIOUS = 4 80 | PLAYBACK_REWIND = 5 81 | PLAYBACK_STARTOVER = 6 82 | PLAYBACK_STOP = 7 83 | PLAYBACK_NONE = 255 84 | INPUT_AUX1 = 0 85 | INPUT_AUX2 = 1 86 | INPUT_AUX3 = 2 87 | INPUT_AUX4 = 3 88 | INPUT_AUX5 = 4 89 | INPUT_AUX6 = 5 90 | INPUT_AUX7 = 6 91 | INPUT_BLUERAY = 7 92 | INPUT_CABLE = 8 93 | INPUT_CD = 9 94 | INPUT_COAX1 = 10 95 | INPUT_COAX2 = 11 96 | INPUT_COMPOSITE1 = 12 97 | INPUT_DVD = 13 98 | INPUT_GAME = 14 99 | INPUT_HDRADIO = 15 100 | INPUT_HDMI1 = 16 101 | INPUT_HDMI2 = 17 102 | INPUT_HDMI3 = 18 103 | INPUT_HDMI4 = 19 104 | INPUT_HDMI5 = 20 105 | INPUT_HDMI6 = 21 106 | INPUT_HDMI7 = 22 107 | INPUT_HDMI8 = 23 108 | INPUT_HDMI9 = 24 109 | INPUT_HDMI10 = 25 110 | INPUT_HDMIARC = 26 111 | INPUT_INPUT1 = 27 112 | INPUT_INPUT2 = 28 113 | INPUT_INPUT3 = 29 114 | INPUT_INPUT4 = 30 115 | INPUT_INPUT5 = 31 116 | INPUT_INPUT6 = 32 117 | INPUT_INPUT7 = 33 118 | INPUT_INPUT8 = 34 119 | INPUT_INPUT9 = 35 120 | INPUT_INPUT10 = 36 121 | INPUT_IPOD = 37 122 | INPUT_LINE1 = 38 123 | INPUT_LINE2 = 39 124 | INPUT_LINE3 = 40 125 | INPUT_LINE4 = 41 126 | INPUT_LINE5 = 42 127 | INPUT_LINE6 = 43 128 | INPUT_LINE7 = 44 129 | INPUT_MEDIAPLAYER = 45 130 | INPUT_OPTICAL1 = 46 131 | INPUT_OPTICAL2 = 47 132 | INPUT_PHONO = 48 133 | INPUT_PLAYSTATION = 49 134 | INPUT_PLAYSTATION3 = 50 135 | INPUT_PLAYSTATION4 = 51 136 | INPUT_SATELLITE = 52 137 | INPUT_SMARTCAST = 53 138 | INPUT_TUNER = 54 139 | INPUT_TV = 55 140 | INPUT_USBDAC = 56 141 | INPUT_VIDEO1 = 57 142 | INPUT_VIDEO2 = 58 143 | INPUT_VIDEO3 = 59 144 | INPUT_XBOX = 60 145 | 146 | def __init__(self, name, **kwargs): 147 | super().__init__( 148 | name, keys={"swi", "vol", "mut", "pbc", "inp", "cha"}, **kwargs 149 | ) 150 | 151 | 152 | def async_wifi_connection(client=None, args=None, connecting=[False]): 153 | import time 154 | import network 155 | import logging 156 | 157 | try: 158 | from secrets import WIFI_SSID 159 | from secrets import WIFI_PASS 160 | except Exception: 161 | raise ( 162 | Exception("Network is not configured. Set SSID and passwords in secrets.py") 163 | ) 164 | 165 | wlan = network.WLAN(network.STA_IF) 166 | 167 | if wlan.isconnected(): 168 | if connecting[0]: 169 | connecting[0] = False 170 | logging.info(f"WiFi connected {wlan.ifconfig()}") 171 | if client is not None: 172 | client.update_systime() 173 | elif connecting[0]: 174 | logging.info("WiFi is down. Trying to reconnect.") 175 | else: 176 | wlan.active(True) 177 | wlan.connect(WIFI_SSID, WIFI_PASS) 178 | connecting[0] = True 179 | logging.info("WiFi is down. Trying to reconnect.") 180 | 181 | # Running in sync mode, block until WiFi is connected. 182 | if client is None: 183 | while not wlan.isconnected(): 184 | logging.info("Trying to connect to WiFi.") 185 | time.sleep(1.0) 186 | connecting[0] = False 187 | logging.info(f"WiFi Connected {wlan.ifconfig()}") 188 | -------------------------------------------------------------------------------- /examples/micropython_advanced.py: -------------------------------------------------------------------------------- 1 | # This file is part of the Python Arduino IoT Cloud. 2 | # Any copyright is dedicated to the Public Domain. 3 | # https://creativecommons.org/publicdomain/zero/1.0/ 4 | import time 5 | import ssl # noqa 6 | import network 7 | import logging 8 | from time import strftime 9 | from arduino_iot_cloud import ArduinoCloudClient 10 | from arduino_iot_cloud import Location 11 | from arduino_iot_cloud import Schedule 12 | from arduino_iot_cloud import ColoredLight 13 | from arduino_iot_cloud import Task 14 | from arduino_iot_cloud import CADATA # noqa 15 | from random import uniform 16 | from secrets import WIFI_SSID 17 | from secrets import WIFI_PASS 18 | from secrets import DEVICE_ID 19 | 20 | 21 | # Provisioned boards with secure elements can provide key and 22 | # certificate URIs in the SE, in following format: 23 | KEY_PATH = "se05x:token=0x00000064" # noqa 24 | CERT_PATH = "se05x:token=0x00000065" # noqa 25 | 26 | # Alternatively, the key and certificate files can be stored 27 | # on the internal filesystem in DER format: 28 | #KEY_PATH = "key.der" # noqa 29 | #CERT_PATH = "cert.der" # noqa 30 | 31 | 32 | def on_switch_changed(client, value): 33 | # This is a write callback for the switch that toggles the LED variable. The LED 34 | # variable can be accessed via the client object passed in the first argument. 35 | client["led"] = value 36 | 37 | 38 | def on_clight_changed(client, clight): 39 | logging.info(f"ColoredLight changed. Swi: {clight.swi} Bri: {clight.bri} Sat: {clight.sat} Hue: {clight.hue}") 40 | 41 | 42 | def user_task(client, args): 43 | # NOTE: this function should not block. 44 | # This is a user-defined task that updates the colored light. Note any registered 45 | # cloud object can be accessed using the client object passed to this function. 46 | # The composite ColoredLight object fields can be assigned to individually, using dot: 47 | client["clight"].hue = round(uniform(0, 100), 1) 48 | client["clight"].bri = round(uniform(0, 100), 1) 49 | 50 | 51 | def wdt_task(client, wdt): 52 | # Update the WDT to prevent it from resetting the system 53 | wdt.feed() 54 | 55 | 56 | def wifi_connect(): 57 | if not WIFI_SSID or not WIFI_PASS: 58 | raise (Exception("Network is not configured. Set SSID and passwords in secrets.py")) 59 | wlan = network.WLAN(network.STA_IF) 60 | wlan.active(True) 61 | wlan.connect(WIFI_SSID, WIFI_PASS) 62 | while not wlan.isconnected(): 63 | logging.info("Trying to connect. Note this may take a while...") 64 | time.sleep_ms(500) 65 | logging.info(f"WiFi Connected {wlan.ifconfig()}") 66 | 67 | 68 | if __name__ == "__main__": 69 | # Configure the logger. 70 | # All message equal or higher to the logger level are printed. 71 | # To see more debugging messages, set level=logging.DEBUG. 72 | logging.basicConfig( 73 | datefmt="%H:%M:%S", 74 | format="%(asctime)s.%(msecs)03d %(message)s", 75 | level=logging.INFO, 76 | ) 77 | 78 | # NOTE: Add networking code here or in boot.py 79 | wifi_connect() 80 | 81 | # Create a client object to connect to the Arduino IoT cloud. 82 | # For mTLS authentication, "keyfile" and "certfile" can be paths to a DER-encoded key and 83 | # a DER-encoded certificate, or secure element (SE) URIs in the format: provider:token=slot 84 | client = ArduinoCloudClient( 85 | device_id=DEVICE_ID, 86 | ssl_params={ 87 | "keyfile": KEY_PATH, "certfile": CERT_PATH, "cadata": CADATA, 88 | "verify_mode": ssl.CERT_REQUIRED, "server_hostname": "iot.arduino.cc" 89 | }, 90 | sync_mode=False, 91 | ) 92 | 93 | # Register cloud objects. 94 | # Note: The following objects must be created first in the dashboard and linked to the device. 95 | # This cloud object is initialized with its last known value from the cloud. When this object is updated 96 | # from the dashboard, the on_switch_changed function is called with the client object and the new value. 97 | client.register("sw1", value=None, on_write=on_switch_changed, interval=0.250) 98 | 99 | # This cloud object is updated manually in the switch's on_write_change callback. 100 | client.register("led", value=None) 101 | 102 | # This is a periodic cloud object that gets updated at fixed intervals (in this case 1 seconed) with the 103 | # value returned from its on_read function (a formatted string of the current time). Note this object's 104 | # initial value is None, it will be initialized by calling the on_read function. 105 | client.register("clk", value=None, on_read=lambda x: strftime("%H:%M:%S", time.localtime()), interval=1.0) 106 | 107 | # This is an example of a composite cloud object (a cloud object with multiple variables). In this case 108 | # a colored light with switch, hue, saturation and brightness attributes. Once initialized, the object's 109 | # attributes can be accessed using dot notation. For example: client["clight"].swi = False. 110 | client.register(ColoredLight("clight", swi=True, on_write=on_clight_changed)) 111 | 112 | # This is another example of a composite cloud object, a map location with lat and long attributes. 113 | client.register(Location("treasureisland", lat=31.264694, lon=29.979987)) 114 | 115 | # This object allows scheduling recurring events from the cloud UI. On activation of the event, if the 116 | # on_active callback is provided, it gets called with the client object and the schedule object value. 117 | # Note: The activation status of the object can also be polled using client["schedule"].active. 118 | client.register(Schedule("schedule", on_active=lambda client, value: logging.info(f"Schedule activated {value}!"))) 119 | 120 | # The client can also schedule user code in a task and run it along with the other cloud objects. 121 | # To schedule a user function, use the Task object and pass the task name and function in "on_run" 122 | # to client.register(). 123 | client.register(Task("user_task", on_run=user_task, interval=1.0)) 124 | 125 | # If a Watchdog timer is available, it can be used to recover the system by resetting it, if it ever 126 | # hangs or crashes for any reason. NOTE: once the WDT is enabled it must be reset periodically to 127 | # prevent it from resetting the system, which is done in another user task. 128 | # NOTE: Change the following to True to enable the WDT. 129 | if False: 130 | try: 131 | from machine import WDT 132 | # Enable the WDT with a timeout of 5s (1s is the minimum) 133 | wdt = WDT(timeout=7500) 134 | client.register(Task("watchdog_task", on_run=wdt_task, interval=1.0, args=wdt)) 135 | except (ImportError, AttributeError): 136 | pass 137 | 138 | # Start the Arduino IoT cloud client. In synchronous mode, this function returns immediately 139 | # after connecting to the cloud. 140 | client.start() 141 | 142 | # In sync mode, start returns after connecting, and the client must be polled periodically. 143 | while True: 144 | client.update() 145 | time.sleep(0.100) 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Arduino IoT Cloud Python client ☁️🐍☁️ 2 | This is a Python client for the Arduino IoT Cloud, which runs on both CPython and MicroPython. The client supports basic and advanced authentication methods, synchronous and asynchronous modes and provides a user-friendly API that allows users to connect to the cloud and create and link local objects to cloud objects with just a few lines of code. 3 | 4 | ## Minimal Example 5 | The following basic example shows how to connect to the Arduino IoT cloud using basic username and password authentication, and control an LED from a dashboard's switch widget. 6 | ```python 7 | from secrets import DEVICE_ID 8 | from secrets import SECRET_KEY 9 | 10 | # Switch callback, toggles the LED. 11 | def on_switch_changed(client, value): 12 | # Note the client object passed to this function can be used to access 13 | # and modify any registered cloud object. The following line updates 14 | # the LED value. 15 | client["led"] = value 16 | 17 | # 1. Create a client object, which is used to connect to the IoT cloud and link local 18 | # objects to cloud objects. Note a username and password can be used for basic authentication 19 | # on both CPython and MicroPython. For more advanced authentication methods, please see the examples. 20 | client = ArduinoCloudClient(device_id=DEVICE_ID, username=DEVICE_ID, password=SECRET_KEY) 21 | 22 | # 2. Register cloud objects. 23 | # Note: The following objects must be created first in the dashboard and linked to the device. 24 | # When the switch is toggled from the dashboard, the on_switch_changed function is called with 25 | # the client object and new value args. 26 | client.register("sw1", value=None, on_write=on_switch_changed) 27 | 28 | # The LED object is updated in the switch's on_write callback. 29 | client.register("led", value=None) 30 | 31 | # 3. Start the Arduino cloud client. 32 | client.start() 33 | ``` 34 | 35 | Your `secrets.py` file should look like this: 36 | 37 | ```python 38 | WIFI_SSID = "" # WiFi network SSID (for MicroPython) 39 | WIFI_PASS = "" # WiFi network key (for MicroPython) 40 | DEVICE_ID = "" # Provided by Arduino cloud when creating a device. 41 | SECRET_KEY = "" # Provided by Arduino cloud when creating a device. 42 | ``` 43 | 44 | Note that by default, the client runs in asynchronous mode. In this mode, the client runs an asyncio loop that updates tasks and records, polls networking events, etc. The client also supports a synchronous mode, which requires periodic client polling. To run the client in synchronous mode, pass `sync_mode=True` when creating a client object and call `client.update()` periodically after connecting. For example: 45 | 46 | ```Python 47 | # Run the client in synchronous mode. 48 | client = ArduinoCloudClient(device_id=DEVICE_ID, ..., sync_mode=True) 49 | .... 50 | client.register("led", value=None) 51 | .... 52 | # In synchronous mode, this function returns immediately after connecting to the cloud. 53 | client.start() 54 | 55 | # Update the client periodically. 56 | while True: 57 | client.update() 58 | time.sleep(0.100) 59 | ``` 60 | 61 | For more detailed examples and advanced API features, please see the [examples](https://github.com/arduino/arduino-iot-cloud-py/tree/main/examples). 62 | 63 | ## Testing on CPython/Linux 64 | The client supports basic authentication using a username and password, and the more advanced key/cert pair stored on filesystem or in a crypto device. To test this functionality, the following steps can be used to emulate a crypto device (if one is not available) using SoftHSM on Linux. 65 | 66 | #### Create softhsm token 67 | Using the first available slot, in this case 0 68 | ```bash 69 | softhsm2-util --init-token --slot 0 --label "arduino" --pin 1234 --so-pin 1234 70 | ``` 71 | 72 | #### Import the key and certificate using p11tool 73 | ```bash 74 | p11tool --provider=/usr/lib/softhsm/libsofthsm2.so --login --set-pin=1234 --write "pkcs11:token=arduino" --load-privkey key.pem --label "Mykey" 75 | p11tool --provider=/usr/lib/softhsm/libsofthsm2.so --login --set-pin=1234 --write "pkcs11:token=arduino" --load-certificate cert.pem --label "Mykey" 76 | ``` 77 | 78 | #### List objects 79 | This should print the key and certificate 80 | ```bash 81 | p11tool --provider=/usr/lib/softhsm/libsofthsm2.so --login --set-pin=1234 --list-all pkcs11:token=arduino 82 | 83 | Object 0: 84 | URL: pkcs11:model=SoftHSM%20v2;manufacturer=SoftHSM%20project;serial=841b431f98150134;token=arduino;id=%67%A2%AD%13%53%B1%CE%4F%0E%CB%74%34%B8%C6%1C%F3%33%EA%67%31;object=mykey;type=private 85 | Type: Private key (EC/ECDSA) 86 | Label: mykey 87 | Flags: CKA_WRAP/UNWRAP; CKA_PRIVATE; CKA_SENSITIVE; 88 | ID: 67:a2:ad:13:53:b1:ce:4f:0e:cb:74:34:b8:c6:1c:f3:33:ea:67:31 89 | 90 | Object 1: 91 | URL: pkcs11:model=SoftHSM%20v2;manufacturer=SoftHSM%20project;serial=841b431f98150134;token=arduino;id=%67%A2%AD%13%53%B1%CE%4F%0E%CB%74%34%B8%C6%1C%F3%33%EA%67%31;object=Mykey;type=cert 92 | Type: X.509 Certificate (EC/ECDSA-SECP256R1) 93 | Expires: Sat May 31 12:00:00 2053 94 | Label: Mykey 95 | ID: 67:a2:ad:13:53:b1:ce:4f:0e:cb:74:34:b8:c6:1c:f3:33:ea:67:31 96 | ``` 97 | 98 | #### Deleting Token: 99 | When done with the token it can be deleted with the following command: 100 | ```bash 101 | softhsm2-util --delete-token --token "arduino" 102 | ``` 103 | 104 | #### Run the example script 105 | * Set `KEY_PATH`, `CERT_PATH` and `DEVICE_ID` in `examples/example.py`. 106 | * Provide a CA certificate in a `ca-root.pem` file or set `CA_PATH` to `None` if it's not used. 107 | * Override the default `pin` and provide `ENGINE_PATH` and `MODULE_PATH` in `ssl_params` if needed. 108 | * Run the example: 109 | ```bash 110 | python examples/example.py 111 | ``` 112 | 113 | ## Testing on MicroPython 114 | MicroPython supports both modes of authentication: basic mode, using a username and password, and mTLS with the key and certificate stored on the filesystem or a secure element (for provisioned boards). To use key and certificate files stored on the filesystem, they must first be converted to DER format. The following commands can be used to convert from PEM to DER: 115 | ```bash 116 | openssl ec -in key.pem -out key.der -outform DER 117 | openssl x509 -in cert.pem -out cert.der -outform DER 118 | ``` 119 | 120 | In this case `KEY_PATH`, `CERT_PATH`, can be set to the key and certificate DER paths, respectively: 121 | ```Python 122 | KEY_PATH = "path/to/key.der" 123 | CERT_PATH = "path/to/cert.der" 124 | ``` 125 | 126 | Alternatively, if the key and certificate are stored on the SE, their URIs can be specified in the following format: 127 | ```Python 128 | KEY_PATH = "se05x:token=0x00000064" 129 | CERT_PATH = "se05x:token=0x00000065" 130 | ``` 131 | 132 | With the key and certificate set, the example can be run with the following command `examples/micropython_advanced.py` 133 | 134 | ## Useful links 135 | 136 | * [micropython-senml library](https://github.com/micropython/micropython-lib/tree/master/micropython/senml) 137 | * [micropython-cbor2 library](https://github.com/micropython/micropython-lib/tree/master/python-ecosys/cbor2) 138 | * [m2crypto](https://github.com/m2crypto/m2crypto) 139 | * [umqtt.simple](https://github.com/micropython/micropython-lib/tree/master/micropython/umqtt.simple) 140 | * [openssl docs/man](https://www.openssl.org/docs/man1.0.2/man3/) 141 | -------------------------------------------------------------------------------- /src/arduino_iot_cloud/umqtt.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2013, 2014 micropython-lib contributors 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | # 23 | # Based on: https://github.com/micropython/micropython-lib/tree/master/micropython/umqtt.simple 24 | 25 | import socket 26 | import struct 27 | import select 28 | import logging 29 | import arduino_iot_cloud.ussl as ssl 30 | import sys 31 | 32 | 33 | class MQTTException(Exception): 34 | pass 35 | 36 | 37 | class MQTTClient: 38 | def __init__( 39 | self, 40 | client_id, 41 | server, 42 | port, 43 | ssl_params, 44 | user=None, 45 | password=None, 46 | keepalive=0, 47 | callback=None, 48 | ): 49 | self.client_id = client_id 50 | self.server = server 51 | self.port = port 52 | self.ssl_params = ssl_params 53 | self.user = user 54 | self.pswd = password 55 | self.keepalive = keepalive 56 | self.cb = callback 57 | self.sock = None 58 | self.pid = 0 59 | self.lw_topic = None 60 | self.lw_msg = None 61 | self.lw_qos = 0 62 | self.lw_retain = False 63 | 64 | def _send_str(self, s): 65 | self.sock.write(struct.pack("!H", len(s))) 66 | self.sock.write(s) 67 | 68 | def _recv_len(self): 69 | n = 0 70 | sh = 0 71 | while 1: 72 | b = self.sock.read(1)[0] 73 | n |= (b & 0x7F) << sh 74 | if not b & 0x80: 75 | return n 76 | sh += 7 77 | 78 | def set_callback(self, f): 79 | self.cb = f 80 | 81 | def set_last_will(self, topic, msg, retain=False, qos=0): 82 | assert 0 <= qos <= 2 83 | assert topic 84 | self.lw_topic = topic 85 | self.lw_msg = msg 86 | self.lw_qos = qos 87 | self.lw_retain = retain 88 | 89 | def connect(self, clean_session=True, timeout=5.0): 90 | addr = socket.getaddrinfo(self.server, self.port)[0][-1] 91 | 92 | if self.sock is not None: 93 | self.sock.close() 94 | self.sock = None 95 | 96 | self.sock = socket.socket() 97 | self.sock.settimeout(timeout) 98 | if sys.implementation.name == "micropython": 99 | self.sock.connect(addr) 100 | self.sock = ssl.wrap_socket(self.sock, self.ssl_params) 101 | else: 102 | self.sock = ssl.wrap_socket(self.sock, self.ssl_params) 103 | self.sock.connect(addr) 104 | 105 | premsg = bytearray(b"\x10\0\0\0\0\0") 106 | msg = bytearray(b"\x04MQTT\x04\x02\0\0") 107 | 108 | sz = 10 + 2 + len(self.client_id) 109 | msg[6] = clean_session << 1 110 | if self.user is not None: 111 | sz += 2 + len(self.user) + 2 + len(self.pswd) 112 | msg[6] |= 0xC0 113 | if self.keepalive: 114 | assert self.keepalive < 65536 115 | msg[7] |= self.keepalive >> 8 116 | msg[8] |= self.keepalive & 0x00FF 117 | if self.lw_topic: 118 | sz += 2 + len(self.lw_topic) + 2 + len(self.lw_msg) 119 | msg[6] |= 0x4 | (self.lw_qos & 0x1) << 3 | (self.lw_qos & 0x2) << 3 120 | msg[6] |= self.lw_retain << 5 121 | 122 | i = 1 123 | while sz > 0x7F: 124 | premsg[i] = (sz & 0x7F) | 0x80 125 | sz >>= 7 126 | i += 1 127 | premsg[i] = sz 128 | 129 | self.sock.write(premsg[0:i + 2]) 130 | self.sock.write(msg) 131 | self._send_str(self.client_id) 132 | if self.lw_topic: 133 | self._send_str(self.lw_topic) 134 | self._send_str(self.lw_msg) 135 | if self.user is not None: 136 | self._send_str(self.user) 137 | self._send_str(self.pswd) 138 | resp = self.sock.read(4) 139 | assert resp[0] == 0x20 and resp[1] == 0x02 140 | if resp[3] != 0: 141 | raise MQTTException(resp[3]) 142 | return resp[2] & 1 143 | 144 | def disconnect(self): 145 | self.sock.write(b"\xe0\0") 146 | self.sock.close() 147 | 148 | def ping(self): 149 | self.sock.write(b"\xc0\0") 150 | 151 | def publish(self, topic, msg, retain=False, qos=0): 152 | pkt = bytearray(b"\x30\0\0\0") 153 | pkt[0] |= qos << 1 | retain 154 | sz = 2 + len(topic) + len(msg) 155 | if qos > 0: 156 | sz += 2 157 | assert sz < 2097152 158 | i = 1 159 | while sz > 0x7F: 160 | pkt[i] = (sz & 0x7F) | 0x80 161 | sz >>= 7 162 | i += 1 163 | pkt[i] = sz 164 | # print(hex(len(pkt)), hexlify(pkt, ":")) 165 | self.sock.write(pkt[0:i + 1]) 166 | self._send_str(topic) 167 | if qos > 0: 168 | self.pid += 1 169 | pid = self.pid 170 | struct.pack_into("!H", pkt, 0, pid) 171 | self.sock.write(pkt[0:2]) 172 | self.sock.write(msg) 173 | if qos == 1: 174 | while 1: 175 | op = self.wait_msg() 176 | if op == 0x40: 177 | sz = self.sock.read(1) 178 | assert sz == b"\x02" 179 | rcv_pid = self.sock.read(2) 180 | rcv_pid = rcv_pid[0] << 8 | rcv_pid[1] 181 | if pid == rcv_pid: 182 | return 183 | elif qos == 2: 184 | assert 0 185 | 186 | def subscribe(self, topic, qos=0): 187 | if logging.getLogger().isEnabledFor(logging.INFO): 188 | logging.info(f"Subscribe: {topic}.") 189 | assert self.cb is not None, "Subscribe callback is not set" 190 | pkt = bytearray(b"\x82\0\0\0") 191 | self.pid += 1 192 | struct.pack_into("!BH", pkt, 1, 2 + 2 + len(topic) + 1, self.pid) 193 | # print(hex(len(pkt)), hexlify(pkt, ":")) 194 | self.sock.write(pkt) 195 | self._send_str(topic) 196 | self.sock.write(qos.to_bytes(1, "little")) 197 | while 1: 198 | op = self.wait_msg() 199 | if op == 0x90: 200 | resp = self.sock.read(4) 201 | # print(resp) 202 | assert resp[1] == pkt[2] and resp[2] == pkt[3] 203 | if resp[3] == 0x80: 204 | raise MQTTException(resp[3]) 205 | return 206 | 207 | # Wait for a single incoming MQTT message and process it. 208 | # Subscribed messages are delivered to a callback previously 209 | # set by .set_callback() method. Other (internal) MQTT 210 | # messages processed internally. 211 | def wait_msg(self): 212 | res = self.sock.read(1) 213 | if res is None or res == b"": 214 | return None 215 | if res == b"\xd0": # PINGRESP 216 | sz = self.sock.read(1)[0] 217 | assert sz == 0 218 | return None 219 | op = res[0] 220 | if op & 0xF0 != 0x30: 221 | return op 222 | sz = self._recv_len() 223 | topic_len = self.sock.read(2) 224 | topic_len = (topic_len[0] << 8) | topic_len[1] 225 | topic = self.sock.read(topic_len) 226 | sz -= topic_len + 2 227 | if op & 6: 228 | pid = self.sock.read(2) 229 | pid = pid[0] << 8 | pid[1] 230 | sz -= 2 231 | msg = self.sock.read(sz) 232 | self.cb(topic, msg) 233 | if op & 6 == 2: 234 | pkt = bytearray(b"\x40\x02\0\0") 235 | struct.pack_into("!H", pkt, 2, pid) 236 | self.sock.write(pkt) 237 | elif op & 6 == 4: 238 | assert 0 239 | 240 | # Checks whether a pending message from server is available. 241 | # If not, returns immediately with None. Otherwise, does 242 | # the same processing as wait_msg. 243 | def check_msg(self): 244 | r, w, e = select.select([self.sock], [], [], 0.05) 245 | if len(r): 246 | return self.wait_msg() 247 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /src/arduino_iot_cloud/ucloud.py: -------------------------------------------------------------------------------- 1 | # This file is part of the Arduino IoT Cloud Python client. 2 | # Copyright (c) 2022 Arduino SA 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | 7 | import time 8 | import logging 9 | import cbor2 10 | from senml import SenmlPack 11 | from senml import SenmlRecord 12 | from arduino_iot_cloud.umqtt import MQTTClient 13 | import asyncio 14 | from asyncio import CancelledError 15 | try: 16 | from asyncio import InvalidStateError 17 | except (ImportError, AttributeError): 18 | # MicroPython doesn't have this exception 19 | class InvalidStateError(Exception): 20 | pass 21 | try: 22 | from arduino_iot_cloud._version import __version__ 23 | except (ImportError, AttributeError): 24 | __version__ = "1.4.1" 25 | 26 | # Server/port for basic auth. 27 | _DEFAULT_SERVER = "iot.arduino.cc" 28 | 29 | # Default port for cert based auth and basic auth. 30 | _DEFAULT_PORT = (8885, 8884) 31 | 32 | 33 | class DoneException(Exception): 34 | pass 35 | 36 | 37 | def timestamp(): 38 | return int(time.time()) 39 | 40 | 41 | def timestamp_ms(): 42 | return time.time_ns() // 1000000 43 | 44 | 45 | def log_level_enabled(level): 46 | return logging.getLogger().isEnabledFor(level) 47 | 48 | 49 | class ArduinoCloudObject(SenmlRecord): 50 | def __init__(self, name, **kwargs): 51 | self.on_read = kwargs.pop("on_read", None) 52 | self.on_write = kwargs.pop("on_write", None) 53 | self.on_run = kwargs.pop("on_run", None) 54 | self.interval = kwargs.pop("interval", 1.0) 55 | self.backoff = kwargs.pop("backoff", None) 56 | self.args = kwargs.pop("args", None) 57 | value = kwargs.pop("value", None) 58 | if keys := kwargs.pop("keys", {}): 59 | value = { # Create a complex object (with sub-records). 60 | k: ArduinoCloudObject(f"{name}:{k}", value=v, callback=self.senml_callback) 61 | for (k, v) in {k: kwargs.pop(k, None) for k in keys}.items() 62 | } 63 | self._updated = False 64 | self.on_write_scheduled = False 65 | self.timestamp = timestamp() 66 | self.last_poll = timestamp_ms() 67 | self.runnable = any((self.on_run, self.on_read, self.on_write)) 68 | callback = kwargs.pop("callback", self.senml_callback) 69 | for key in kwargs: # kwargs should be empty by now, unless a wrong attr was used. 70 | raise TypeError(f"'{self.__class__.__name__}' got an unexpected keyword argument '{key}'") 71 | super().__init__(name, value=value, callback=callback) 72 | 73 | def __repr__(self): 74 | return f"{self.value}" 75 | 76 | def __contains__(self, key): 77 | return isinstance(self.value, dict) and key in self._value 78 | 79 | @property 80 | def updated(self): 81 | if isinstance(self.value, dict): 82 | return any(r._updated for r in self.value.values()) 83 | return self._updated 84 | 85 | @updated.setter 86 | def updated(self, value): 87 | if isinstance(self.value, dict): 88 | for r in self.value.values(): 89 | r._updated = value 90 | self._updated = value 91 | 92 | @property 93 | def initialized(self): 94 | if isinstance(self.value, dict): 95 | return all(r.initialized for r in self.value.values()) 96 | return self.value is not None 97 | 98 | @SenmlRecord.value.setter 99 | def value(self, value): 100 | if value is not None: 101 | if self.value is not None: 102 | # This is a workaround for the cloud float/int conversion bug. 103 | if isinstance(self.value, float) and isinstance(value, int): 104 | value = float(value) 105 | if not isinstance(self.value, type(value)): 106 | raise TypeError( 107 | f"{self.name} set to invalid data type, expected: {type(self.value)} got: {type(value)}" 108 | ) 109 | self._updated = True 110 | self.timestamp = timestamp() 111 | if log_level_enabled(logging.DEBUG): 112 | logging.debug( 113 | f"%s: {self.name} value: {value} ts: {self.timestamp}" 114 | % ("Init" if self.value is None else "Update") 115 | ) 116 | self._value = value 117 | 118 | def __getattr__(self, attr): 119 | if isinstance(self.__dict__.get("_value", None), dict) and attr in self._value: 120 | return self._value[attr].value 121 | raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{attr}'") 122 | 123 | def __setattr__(self, attr, value): 124 | if isinstance(self.__dict__.get("_value", None), dict) and attr in self._value: 125 | self._value[attr].value = value 126 | else: 127 | super().__setattr__(attr, value) 128 | 129 | def _build_rec_dict(self, naming_map, appendTo): 130 | # This function builds a dict of records from a pack, which gets converted to CBOR and 131 | # pushed to the cloud on the next update. 132 | if isinstance(self.value, dict): 133 | for r in self.value.values(): 134 | r._build_rec_dict(naming_map, appendTo) 135 | else: 136 | super()._build_rec_dict(naming_map, appendTo) 137 | 138 | def add_to_pack(self, pack, push=False): 139 | # This function adds records that will be pushed to (or updated from) the cloud, to the SenML pack. 140 | # NOTE: When pushing records to the cloud (push==True) only fully initialized records are added to 141 | # the pack. And when updating records from the cloud (push==False), partially initialized records 142 | # are allowed in the pack, so they can be initialized from the cloud. 143 | # NOTE: all initialized sub-records are added to the pack whether they changed their state since the 144 | # last update or not, because the cloud currently does not support partial objects updates. 145 | if isinstance(self._value, dict): 146 | if not push or self.initialized: 147 | for r in self._value.values(): 148 | pack.add(r) 149 | elif not push or self._value is not None: 150 | pack.add(self) 151 | self.updated = False 152 | 153 | def senml_callback(self, record, **kwargs): 154 | # This function gets called after a record is updated from the cloud (from_cbor). 155 | # The updated flag is cleared to avoid sending the same value again to the cloud, 156 | # and the on_write function flag is set to so it gets called on the next run. 157 | self.updated = False 158 | self.on_write_scheduled = True 159 | 160 | async def run(self, client): 161 | while True: 162 | self.run_sync(client) 163 | await asyncio.sleep(self.interval) 164 | if self.backoff is not None: 165 | self.interval = min(self.interval * self.backoff, 5.0) 166 | 167 | def run_sync(self, client): 168 | if self.on_run is not None: 169 | self.on_run(client, self.args) 170 | if self.on_read is not None: 171 | self.value = self.on_read(client) 172 | if self.on_write is not None and self.on_write_scheduled: 173 | self.on_write_scheduled = False 174 | self.on_write(client, self if isinstance(self.value, dict) else self.value) 175 | 176 | 177 | class ArduinoCloudClient: 178 | def __init__( 179 | self, 180 | device_id, 181 | username=None, 182 | password=None, 183 | ssl_params={}, 184 | server=None, 185 | port=None, 186 | keepalive=10, 187 | ntp_server="pool.ntp.org", 188 | ntp_timeout=3, 189 | sync_mode=False 190 | ): 191 | self.tasks = {} 192 | self.records = {} 193 | self.thing_id = None 194 | self.keepalive = keepalive 195 | self.last_ping = timestamp() 196 | self.senmlpack = SenmlPack("", self.senml_generic_callback) 197 | self.ntp_server = ntp_server 198 | self.ntp_timeout = ntp_timeout 199 | self.async_mode = not sync_mode 200 | self.connected = False 201 | 202 | # Convert args to bytes if they are passed as strings. 203 | if isinstance(device_id, str): 204 | device_id = bytes(device_id, "utf-8") 205 | if username is not None and isinstance(username, str): 206 | username = bytes(username, "utf-8") 207 | if password is not None and isinstance(password, str): 208 | password = bytes(password, "utf-8") 209 | 210 | self.device_topic = b"/a/d/" + device_id + b"/e/i" 211 | self.command_topic = b"/a/d/" + device_id + b"/c/up" 212 | 213 | # Update RTC from NTP server on MicroPython. 214 | self.update_systime() 215 | 216 | # If no server/port were passed in args, set the default server/port 217 | # based on authentication type. 218 | if server is None: 219 | server = _DEFAULT_SERVER 220 | if port is None: 221 | port = _DEFAULT_PORT[0] if password is None else _DEFAULT_PORT[1] 222 | 223 | # Create MQTT client. 224 | self.mqtt = MQTTClient( 225 | device_id, server, port, ssl_params, username, password, keepalive, self.mqtt_callback 226 | ) 227 | 228 | # Add internal objects initialized by the cloud. 229 | for name in ["thing_id", "tz_offset", "tz_dst_until"]: 230 | self.register(name, value=None) 231 | 232 | def __getitem__(self, key): 233 | if isinstance(self.records[key].value, dict): 234 | return self.records[key] 235 | return self.records[key].value 236 | 237 | def __setitem__(self, key, value): 238 | self.records[key].value = value 239 | 240 | def __contains__(self, key): 241 | return key in self.records 242 | 243 | def get(self, key, default=None): 244 | if key in self and self[key] is not None: 245 | return self[key] 246 | return default 247 | 248 | def update_systime(self, server=None, timeout=None): 249 | try: 250 | import ntptime 251 | ntptime.host = self.ntp_server if server is None else server 252 | ntptime.timeout = self.ntp_timeout if timeout is None else timeout 253 | ntptime.settime() 254 | logging.info("RTC time set from NTP.") 255 | except ImportError: 256 | pass # No ntptime module. 257 | except Exception as e: 258 | if log_level_enabled(logging.ERROR): 259 | logging.error(f"Failed to set RTC time from NTP: {e}.") 260 | 261 | def create_task(self, name, coro, *args, **kwargs): 262 | if callable(coro): 263 | coro = coro(*args) 264 | try: 265 | asyncio.get_event_loop() 266 | self.tasks[name] = asyncio.create_task(coro) 267 | if log_level_enabled(logging.INFO): 268 | logging.info(f"task: {name} created.") 269 | except Exception: 270 | # Defer task creation until there's a running event loop. 271 | self.tasks[name] = coro 272 | 273 | def create_topic(self, topic, inout): 274 | return bytes(f"/a/t/{self.thing_id}/{topic}/{inout}", "utf-8") 275 | 276 | def register(self, aiotobj, coro=None, **kwargs): 277 | if isinstance(aiotobj, str): 278 | if kwargs.get("value", None) is None and kwargs.get("on_read", None) is not None: 279 | kwargs["value"] = kwargs.get("on_read")(self) 280 | aiotobj = ArduinoCloudObject(aiotobj, **kwargs) 281 | 282 | # Register the ArduinoCloudObject 283 | self.records[aiotobj.name] = aiotobj 284 | 285 | # Check if object needs to be initialized from the cloud. 286 | if not aiotobj.initialized and "r:m" not in self.records: 287 | self.register("r:m", value="getLastValues") 288 | 289 | # Create a task for this object if it has any callbacks. 290 | if self.async_mode and aiotobj.runnable: 291 | self.create_task(aiotobj.name, aiotobj.run, self) 292 | 293 | def senml_generic_callback(self, record, **kwargs): 294 | # This callback catches all unknown/umatched sub/records that were not part of the pack. 295 | rname, sname = record.name.split(":") if ":" in record.name else [record.name, None] 296 | if rname in self.records: 297 | if log_level_enabled(logging.INFO): 298 | logging.info(f"Ignoring cloud initialization for record: {record.name}") 299 | else: 300 | if log_level_enabled(logging.WARNING): 301 | logging.warning(f"Unkown record found: {record.name} value: {record.value}") 302 | 303 | def mqtt_callback(self, topic, message): 304 | if log_level_enabled(logging.DEBUG): 305 | logging.debug(f"mqtt topic: {topic[-8:]}... message: {message[:8]}...") 306 | self.senmlpack.clear() 307 | for record in self.records.values(): 308 | # If the object is uninitialized, updates are always allowed even if it's a read-only 309 | # object. Otherwise, for initialized objects, updates are only allowed if the object 310 | # is writable (on_write function is set) and the value is received from the out topic. 311 | if not record.initialized or (record.on_write is not None and b"shadow" not in topic): 312 | record.add_to_pack(self.senmlpack) 313 | self.senmlpack.from_cbor(message) 314 | self.senmlpack.clear() 315 | 316 | def ts_expired(self, ts, last_ts_ms, interval_s): 317 | return last_ts_ms == 0 or (ts - last_ts_ms) > int(interval_s * 1000) 318 | 319 | def poll_records(self): 320 | ts = timestamp_ms() 321 | try: 322 | for record in self.records.values(): 323 | if record.runnable and self.ts_expired(ts, record.last_poll, record.interval): 324 | record.run_sync(self) 325 | record.last_poll = ts 326 | except Exception as e: 327 | self.records.pop(record.name) 328 | if log_level_enabled(logging.ERROR): 329 | logging.error(f"task: {record.name} raised exception: {str(e)}.") 330 | 331 | def poll_connect(self, aiot=None, args=None): 332 | logging.info("Connecting to Arduino IoT cloud...") 333 | try: 334 | self.mqtt.connect() 335 | except Exception as e: 336 | if log_level_enabled(logging.WARNING): 337 | logging.warning(f"Connection failed {e}, retrying...") 338 | return 339 | 340 | if self.thing_id is None: 341 | self.mqtt.subscribe(self.device_topic, qos=1) 342 | else: 343 | self.mqtt.subscribe(self.create_topic("e", "i")) 344 | 345 | if self.async_mode: 346 | if self.thing_id is None: 347 | self.register("discovery", on_run=self.poll_discovery, interval=0.500) 348 | self.register("mqtt_task", on_run=self.poll_mqtt, interval=1.0) 349 | raise DoneException() 350 | self.connected = True 351 | 352 | def poll_discovery(self, aiot=None, args=None): 353 | self.mqtt.check_msg() 354 | if self.records.get("thing_id").value is not None: 355 | self.thing_id = self.records.pop("thing_id").value 356 | if not self.thing_id: # Empty thing ID should not happen. 357 | raise Exception("Device is not linked to a Thing ID.") 358 | 359 | self.topic_out = self.create_topic("e", "o") 360 | self.mqtt.subscribe(self.create_topic("e", "i")) 361 | 362 | if lastval_record := self.records.pop("r:m", None): 363 | lastval_record.add_to_pack(self.senmlpack) 364 | self.mqtt.subscribe(self.create_topic("shadow", "i"), qos=1) 365 | self.mqtt.publish(self.create_topic("shadow", "o"), self.senmlpack.to_cbor(), qos=1) 366 | 367 | if hasattr(cbor2, "dumps"): 368 | # Push library version and mode. 369 | libv = "%s-%s" % (__version__, "async" if self.async_mode else "sync") 370 | # Note we have to add the tag manually because python-ecosys's cbor2 doesn't suppor CBORTags. 371 | self.mqtt.publish(self.command_topic, b"\xda\x00\x01\x07\x00" + cbor2.dumps([libv]), qos=1) 372 | logging.info("Device configured via discovery protocol.") 373 | if self.async_mode: 374 | raise DoneException() 375 | 376 | def poll_mqtt(self, aiot=None, args=None): 377 | self.mqtt.check_msg() 378 | if self.thing_id is not None: 379 | self.senmlpack.clear() 380 | for record in self.records.values(): 381 | if record.updated: 382 | record.add_to_pack(self.senmlpack, push=True) 383 | if len(self.senmlpack._data): 384 | logging.debug("Pushing records to Arduino IoT cloud:") 385 | if log_level_enabled(logging.DEBUG): 386 | for record in self.senmlpack._data: 387 | logging.debug(f" ==> record: {record.name} value: {str(record.value)[:48]}...") 388 | self.mqtt.publish(self.topic_out, self.senmlpack.to_cbor(), qos=1) 389 | self.last_ping = timestamp() 390 | elif self.keepalive and (timestamp() - self.last_ping) > self.keepalive: 391 | self.mqtt.ping() 392 | self.last_ping = timestamp() 393 | logging.debug("No records to push, sent a ping request.") 394 | 395 | async def run(self, interval, backoff): 396 | # Creates tasks from coros here manually before calling 397 | # gather, so we can keep track of tasks in self.tasks dict. 398 | for name, coro in self.tasks.items(): 399 | self.create_task(name, coro) 400 | 401 | # Create connection task. 402 | self.register("connection_task", on_run=self.poll_connect, interval=interval, backoff=backoff) 403 | 404 | while True: 405 | task_except = None 406 | try: 407 | await asyncio.gather(*self.tasks.values(), return_exceptions=False) 408 | break # All tasks are done, not likely. 409 | except KeyboardInterrupt as e: 410 | raise e 411 | except Exception as e: 412 | task_except = e 413 | pass # import traceback; traceback.print_exc() 414 | 415 | for name in list(self.tasks): 416 | task = self.tasks[name] 417 | try: 418 | if task.done(): 419 | self.tasks.pop(name) 420 | self.records.pop(name, None) 421 | if isinstance(task_except, DoneException) and log_level_enabled(logging.INFO): 422 | logging.info(f"task: {name} complete.") 423 | elif task_except is not None and log_level_enabled(logging.ERROR): 424 | logging.error(f"task: {name} raised exception: {str(task_except)}.") 425 | if name == "mqtt_task": 426 | self.register( 427 | "connection_task", 428 | on_run=self.poll_connect, 429 | interval=interval, 430 | backoff=backoff 431 | ) 432 | break # Break after the first task is removed. 433 | except (CancelledError, InvalidStateError): 434 | pass 435 | 436 | def start(self, interval=1.0, backoff=1.2): 437 | if self.async_mode: 438 | asyncio.run(self.run(interval, backoff)) 439 | return 440 | 441 | last_conn_ms = 0 442 | last_disc_ms = 0 443 | 444 | while True: 445 | ts = timestamp_ms() 446 | if not self.connected and self.ts_expired(ts, last_conn_ms, interval): 447 | self.poll_connect() 448 | if last_conn_ms != 0: 449 | interval = min(interval * backoff, 5.0) 450 | last_conn_ms = ts 451 | 452 | if self.connected and self.thing_id is None and self.ts_expired(ts, last_disc_ms, 0.250): 453 | self.poll_discovery() 454 | last_disc_ms = ts 455 | 456 | if self.connected and self.thing_id is not None: 457 | break 458 | self.poll_records() 459 | 460 | def update(self): 461 | if self.async_mode: 462 | raise RuntimeError("This function can't be called in asyncio mode.") 463 | 464 | if not self.connected: 465 | try: 466 | self.start() 467 | except Exception as e: 468 | raise e 469 | 470 | self.poll_records() 471 | 472 | try: 473 | self.poll_mqtt() 474 | except Exception as e: 475 | self.connected = False 476 | if log_level_enabled(logging.WARNING): 477 | logging.warning(f"Connection lost {e}") 478 | --------------------------------------------------------------------------------