diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b6977a9..827258f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,11 +45,11 @@ jobs: run: git describe --dirty --always --tags - name: Check formatting run: | - black --check --target-version=py35 . + black --check --target-version=py35 --line-length=140 . - name: PyLint run: | - pylint $( find . -path './adafruit*.py' ) - ([[ ! -d "examples" ]] || pylint --disable=missing-docstring,invalid-name,bad-whitespace $( find . -path "./examples/*.py" )) + pylint $( find . -path './adafruit_azureiot/*.py' ) + ([[ ! -d "examples" ]] || pylint --disable=missing-docstring,invalid-name,bad-whitespace,wrong-import-position $( find . -path "./examples/*.py" )) - name: Build assets run: circuitpython-build-bundles --filename_prefix ${{ steps.repo-name.outputs.repo-name }} --library_location . - name: Build docs diff --git a/.gitignore b/.gitignore index c83f8b7..df3a832 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ bundles *.DS_Store .eggs dist -**/*.egg-info \ No newline at end of file +**/*.egg-info +.vscode/settings.json diff --git a/.pylintrc b/.pylintrc index d8f0ee8..7eb328d 100644 --- a/.pylintrc +++ b/.pylintrc @@ -217,7 +217,7 @@ indent-after-paren=4 indent-string=' ' # Maximum number of characters on a single line. -max-line-length=100 +max-line-length=140 # Maximum number of lines in a module max-module-lines=1000 @@ -395,11 +395,11 @@ valid-metaclass-classmethod-first-arg=mcs [DESIGN] # Maximum number of arguments for function / method -max-args=5 +max-args=6 # Maximum number of attributes for a class (see R0902). # max-attributes=7 -max-attributes=11 +max-attributes=13 # Maximum number of boolean expressions in a if statement max-bool-expr=5 diff --git a/LICENSE b/LICENSE index d4fbf1d..f5d21b6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019 Brent Rubell for Adafruit Industries +Copyright (c) 2020 Brent Rubell for Adafruit Industries, Jim Bennett, Elena Horton Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.rst b/README.rst index 3c77ee6..1c7bab7 100644 --- a/README.rst +++ b/README.rst @@ -13,8 +13,7 @@ Adafruit_CircuitPython_AzureIoT :target: https://github.com/adafruit/Adafruit_CircuitPython_AzureIoT/actions/ :alt: Build Status -Access to `Microsoft Azure IoT `_ from a CircuitPython device. This library can perform device -messaging services (cloud-to-device, device-to-cloud), device services, and job services. +A CircuitPython device library for `Microsoft Azure IoT Services `_ from a CircuitPython device. This library only supports key-base authentication, it currently doesn't support X.509 certificates. Installing from PyPI ===================== @@ -45,49 +44,222 @@ Dependencies This driver depends on: * `Adafruit CircuitPython `_ +* `Adafruit CircuitPython MiniMQTT `_ + +* `CircuitPython Base64 `_ +* `CircuitPython HMAC `_ +* `CircuitPython Parse `_ Please ensure all dependencies are available on the CircuitPython filesystem. This is easily achieved by downloading -`the Adafruit library and driver bundle `_. +`the Adafruit library and driver bundle `_ +and +`the CircuitPython community library and driver bundle `_ Usage Example ============= -Create an instance of an Azure IoT Hub (you'll need your SAS Token). +This library supports both `Azure IoT Hub `_ and `Azure IoT Central `__. + +To create an Azure IoT Hub instance or an Azure IoT Central app, you will need an Azure subscription. If you don't have an Azure subscription, you can sign up for free: + +- If you are a student 18 or over, head to `aka.ms/FreeStudentAzure `_ and sign up, validating with your student email address. This will give you $100 of Azure credit and free tiers of a load of service, renewable each year you are a student. You will not need a credit card. + +- If you are not a student, head to `aka.ms/FreeAz `_ and sign up to get $200 of credit for 30 days, as well as free tiers of a load of services. You will need a credit card for validation only, your card will not be charged. + +To use this library, you will need to create an ESP32_SPI WifiManager, connected to WiFi. You will also need to set the current time, as this is used to generate time-based authentication keys. One way to do this is via the `Adafruit CircuitPython NTP `_ library with the following code: + +.. code-block:: python + + ntp = NTP(esp) + + # Wait for a valid time to be received + while not ntp.valid_time: + time.sleep(5) + ntp.set_time() + +Azure IoT Hub +------------- + +To interact with Azure IoT Hub, you will need to create a hub, and a register a device inside that hub. There is a free tier available, and this free tier allows up to 8,000 messages a day, so try not to send messages too often if you are using this tier. + +- Open the `Azure Portal `_. +- Follow the instructions in `Microsoft Docs `_ to create an Azure IoT Hub and register a device. +- Copy the devices Primary or secondary connection string, and add this to your ``secrets.py`` file. + +You can find the device connection string by selecting the IoT Hub in the `Azure Portal `_, *selecting Explorer -> IoT devices*, then selecting your device. + +.. image:: iot-hub-device.png + :alt: Locating the device in the IoT hub blade + +*Locating the device in the IoT hub blade* + +Then copy either the primary or secondary connection string using the copy button next to the value. + +.. image:: iot-hub-device-keys.png + :alt: Copy the primary connection string + +*Copy the primary connection string* + +**Connect your device to Azure IoT Hub** + +.. code-block:: python + + from adafruit_azureiot import IoTHubDevice + + device = IoTHubDevice(wifi, secrets["device_connection_string"]) + device.connect() + +Once the device is connected, you will regularly need to run a ``loop`` to poll for messages from the cloud. + +.. code-block:: python + + while True: + device.loop() + time.sleep(1) + +**Send a device to cloud message** + +.. code-block:: python + + message = {"Temperature": temp} + device.send_device_to_cloud_message(json.dumps(message)) + +**Receive device to cloud messages** + +.. code-block:: python + + def cloud_to_device_message_received(body: str, properties: dict): + print("Received message with body", body, "and properties", json.dumps(properties)) + + # Subscribe to cloud to device messages + device.on_cloud_to_device_message_received = cloud_to_device_message_received + +**Receive direct methods** .. code-block:: python - my_hub = IOT_HUB(wifi, 'Azure_IOT_Hub_Name', 'Azure_IOT_Hub_SAS_Token', 'Azure_Device_Identifier') + def direct_method_invoked(method_name: str, payload) -> IoTResponse: + print("Received direct method", method_name, "with data", str(payload)) + # return a status code and message to indicate if the direct method was handled correctly + return IoTResponse(200, "OK") + + # Subscribe to direct methods + device.on_direct_method_invoked = direct_method_invoked + +**Update reported properties on the device twin** + +*This is not supported on Basic tier IoT Hubs, only on the free and standard tiers.* + +.. code-block:: python + + patch = {"Temperature": temp} + device.update_twin(patch) + +**Subscribe to desired property changes on the device twin** -Send a device-to-cloud message +*This is not supported on Basic tier IoT Hubs, only on the free and standard tiers.* .. code-block:: python - my_hub.send_device_message('Hello Azure IoT!') + def device_twin_desired_updated(desired_property_name: str, desired_property_value, desired_version: int): + print("Property", desired_property_name, "updated to", str(desired_property_value), "version", desired_version) -Enumerate all devices on an Azure IOT Hub + # Subscribe to desired property changes + device.on_device_twin_desired_updated = device_twin_desired_updated + +Azure IoT Central +----------------- + +To use Azure IoT Central, you will need to create an Azure IoT Central app, create a device template and register a device against the template. + +- Head to `Azure IoT Central `__ +- Follow the instructions in the `Microsoft Docs `__ to create an application. Every tier is free for up to 2 devices. +- Follow the instructions in the `Microsoft Docs `__ to create a device template. +- Create a device based off the template, and select **Connect** to get the device connection details. Store the ID Scope, Device ID and either the Primary or secondary Key in your ``secrets.py`` file. + +.. image:: iot-central-connect-button.png + :alt: The IoT Central connect button + +*The connect button* + +.. image:: iot-central-connect-dialog.png + :alt: The IoT Central connection details dialog + +*The connection details dialog* .. code-block:: python - hub_devices = my_hub.get_devices() + secrets = { + # WiFi settings + "ssid": "", + "password": "", -Get information about the current device on an Azure IoT Hub + # Azure IoT Central settings + "id_scope": "", + "device_id": "", + "key": "" + } + +**Connect your device to your Azure IoT Central app** .. code-block:: python - device_info = my_hub.get_device() + from adafruit_azureiot import IoTCentralDevice + + device = IoTCentralDevice(wifi, secrets["id_scope"], secrets["device_id"], secrets["key"]) + device.connect() -Get information about the current device's device twin +Once the device is connected, you will regularly need to run a ``loop`` to poll for messages from the cloud. .. code-block:: python - twin_info = my_hub.get_device_twin() + while True: + device.loop() + time.sleep(1) -Update the current device's device twin properties +**Send telemetry** .. code-block:: python - my_hub.update_device_twin(device_properties) + message = {"Temperature": temp} + device.send_telemetry(json.dumps(message)) + +**Listen for commands** + +.. code-block:: python + + def command_executed(command_name: str, payload) -> IoTResponse: + print("Command", command_name, "executed with payload", str(payload)) + # return a status code and message to indicate if the command was handled correctly + return IoTResponse(200, "OK") + + # Subscribe to commands + device.on_command_executed = command_executed + +**Update properties** + +.. code-block:: python + + device.send_property("Desired_Temperature", temp) + +**Listen for property updates** + +.. code-block:: python + + def property_changed(property_name, property_value, version): + print("Property", property_name, "updated to", str(property_value), "version", str(version)) + + # Subscribe to property updates + device.on_property_changed = property_changed + +Learning more about Azure IoT services +-------------------------------------- + +If you want to learn more about setting up or using Azure IoT Services, check out the following resources: + +- `Azure IoT documentation on Microsoft Docs `_ +- `IoT learning paths and modules on Microsoft Learn `_ - Free, online, self-guided hands on learning with Azure IoT services Contributing ============ diff --git a/adafruit_azureiot.py b/adafruit_azureiot.py deleted file mode 100644 index f78d5d6..0000000 --- a/adafruit_azureiot.py +++ /dev/null @@ -1,240 +0,0 @@ -# The MIT License (MIT) -# -# Copyright (c) 2019 Brent Rubell for Adafruit Industries -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -""" -`adafruit_azureiot` -================================================================================ - -Microsoft Azure IoT for CircuitPython - -* Author(s): Brent Rubell - -Implementation Notes --------------------- - -**Software and Dependencies:** - -* Adafruit CircuitPython firmware for the supported boards: - https://github.com/adafruit/circuitpython/releases - -* Adafruit's Bus Device library: https://github.com/adafruit/Adafruit_CircuitPython_BusDevice -* Adafruit's ESP32SPI library: https://github.com/adafruit/Adafruit_CircuitPython_ESP32SPI -""" - -__version__ = "0.0.0-auto.0" -__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_AzureIoT.git" - -AZ_API_VER = "2018-06-30" # Azure URI API Version Identifier -AZURE_HTTP_ERROR_CODES = [400, 401, 404, 403, 412, 429, 500] # Azure HTTP Status Codes - - -class IOT_Hub: - """ - Provides access to a Microsoft Azure IoT Hub. - https://docs.microsoft.com/en-us/rest/api/iothub/ - """ - - def __init__(self, wifi_manager, iot_hub_name, sas_token, device_id): - """ Creates an instance of an Azure IoT Hub Client. - :param wifi_manager: WiFiManager object from ESPSPI_WiFiManager. - :param str iot_hub_name: Name of your IoT Hub. - :param str sas_token: Azure IoT Hub SAS Token Identifier. - :param str device_id: Unique Azure IoT Device Identifier. - """ - _wifi_type = str(type(wifi_manager)) - if "ESPSPI_WiFiManager" in _wifi_type: - self._wifi = wifi_manager - else: - raise TypeError("This library requires a WiFiManager object.") - self._iot_hub_url = "https://{0}.azure-devices.net".format(iot_hub_name) - self._sas_token = sas_token - self._device_id = device_id - self._azure_header = {"Authorization": self._sas_token} - - @property - def device_id(self): - """Returns the current device identifier""" - return self._device_id - - @device_id.setter - def device_id(self, device_identifier): - """Sets the current device identifier - :param str device_identifier: Unique device identifier. - """ - self._device_id = device_identifier - - @staticmethod - def _parse_http_status(status_code, status_reason): - """Parses status code, throws error based on Azure IoT Common Error Codes. - :param int status_code: HTTP status code. - :param str status_reason: Description of HTTP status. - """ - for error in AZURE_HTTP_ERROR_CODES: - if error == status_code: - raise TypeError("Error {0}: {1}".format(status_code, status_reason)) - - # Cloud-to-Device Messaging - def get_hub_message(self): - """Returns a message from a Microsoft Azure IoT Hub (Cloud-to-Device). - Returns None if the message queue is empty. - NOTE: HTTP Cloud-to-Device messages are throttled. Poll every 25+ minutes. - """ - reject_message = True - # get a device-bound notification - path = "{0}/devices/{1}/messages/deviceBound?api-version={2}".format( - self._iot_hub_url, self._device_id, AZ_API_VER - ) - data = self._get(path, is_c2d=True) - if data == 204: # device's message queue is empty - return None - etag = data[1]["etag"] - if etag: # either complete or nack the message - reject_message = False - path_complete = "{0}/devices/{1}/messages/deviceBound/{2}?api-version={3}".format( - self._iot_hub_url, self._device_id, etag.strip("'\""), AZ_API_VER - ) - if reject_message: - path_complete += "&reject" - del_status = self._delete(path_complete) - if del_status == 204: - return data[0] - return None - - # Device-to-Cloud Messaging - def send_device_message(self, message): - """Sends a device-to-cloud message. - :param string message: Message to send to Azure IoT. - """ - path = "{0}/devices/{1}/messages/events?api-version={2}".format( - self._iot_hub_url, self._device_id, AZ_API_VER - ) - self._post(path, message, return_response=False) - - # Device Twin - def get_device_twin(self): - """Returns the device's device twin information in JSON format. - """ - path = "{0}/twins/{1}?api-version={2}".format( - self._iot_hub_url, self._device_id, AZ_API_VER - ) - return self._get(path) - - def update_device_twin(self, properties): - """Updates tags and desired properties of the device's device twin. - :param str properties: Device Twin Properties - (https://docs.microsoft.com/en-us/rest/api/iothub/service/updatetwin#twinproperties) - """ - path = "{0}/twins/{1}?api-version={2}".format( - self._iot_hub_url, self._device_id, AZ_API_VER - ) - return self._patch(path, properties) - - def replace_device_twin(self, properties): - """Replaces tags and desired properties of a device twin. - :param str properties: Device Twin Properties. - """ - path = "{0}/twins/{1}?api-version-{2}".format( - self._iot_hub_url, self._device_id, AZ_API_VER - ) - return self._put(path, properties) - - # IoT Hub Service - def get_devices(self): - """Enumerate devices from the identity registry of the IoT Hub. - """ - path = "{0}/devices/?api-version={1}".format(self._iot_hub_url, AZ_API_VER) - return self._get(path) - - def get_device(self): - """Gets device information from the identity - registry of an IoT Hub. - """ - path = "{0}/devices/{1}?api-version={2}".format( - self._iot_hub_url, self._device_id, AZ_API_VER - ) - return self._get(path) - - # HTTP Helper Methods - def _post(self, path, payload, return_response=True): - """HTTP POST - :param str path: Formatted Azure IOT Hub Path. - :param str payload: JSON-formatted Data Payload. - """ - response = self._wifi.post(path, json=payload, headers=self._azure_header) - self._parse_http_status(response.status_code, response.reason) - if return_response: - return response.json() - return response.text - - def _get(self, path, is_c2d=False): - """HTTP GET - :param str path: Formatted Azure IOT Hub Path. - :param bool is_c2d: Cloud-to-device get request. - """ - response = self._wifi.get(path, headers=self._azure_header) - status_code = response.status_code - if is_c2d: - if status_code == 200: - data = response.text - headers = response.headers - response.close() - return data, headers - response.close() - return status_code - json = response.json() - response.close() - return json - - def _delete(self, path, etag=None): - """HTTP DELETE - :param str path: Formatted Azure IOT Hub Path. - """ - if etag: - data_headers = {"Authorization": self._sas_token, "If-Match": '"%s"' % etag} - else: - data_headers = self._azure_header - response = self._wifi.delete(path, headers=data_headers) - self._parse_http_status(response.status_code, response.reason) - status_code = response.status_code - response.close() - return status_code - - def _patch(self, path, payload): - """HTTP PATCH - :param str path: Formatted Azure IOT Hub Path. - :param str payload: JSON-formatted payload. - """ - response = self._wifi.patch(path, json=payload, headers=self._azure_header) - self._parse_http_status(response.status_code, response.reason) - json_data = response.json() - response.close() - return json_data - - def _put(self, path, payload=None): - """HTTP PUT - :param str path: Formatted Azure IOT Hub Path. - :param str payload: JSON-formatted payload. - """ - response = self._wifi.put(path, json=payload, headers=self._azure_header) - self._parse_http_status(response.status_code, response.reason) - json_data = response.json() - response.close() - return json_data diff --git a/adafruit_azureiot/__init__.py b/adafruit_azureiot/__init__.py new file mode 100644 index 0000000..5f7f04a --- /dev/null +++ b/adafruit_azureiot/__init__.py @@ -0,0 +1,53 @@ +# The MIT License (MIT) +# +# Copyright (c) 2020 Jim Bennett +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_azureiot` +================================================================================ + +Microsoft Azure IoT for CircuitPython + +* Author(s): Jim Bennett, Elena Horton + +Implementation Notes +-------------------- + +**Software and Dependencies:** + +* Adafruit CircuitPython firmware for the supported boards: + https://github.com/adafruit/circuitpython/releases + +* Adafruit's Bus Device library: https://github.com/adafruit/Adafruit_CircuitPython_BusDevice +* Adafruit's ESP32SPI library: https://github.com/adafruit/Adafruit_CircuitPython_ESP32SPI +* Community HMAC library: https://github.com/jimbobbennett/CircuitPython_HMAC +* Community base64 library: https://github.com/jimbobbennett/CircuitPython_Base64 +* Community Parse library: https://github.com/jimbobbennett/CircuitPython_Parse +""" + +from .iot_error import IoTError +from .iot_mqtt import IoTResponse +from .iotcentral_device import IoTCentralDevice +from .iothub_device import IoTHubDevice + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_AzureIoT.git" + +__all__ = ["IoTHubDevice", "IoTCentralDevice", "IoTResponse", "IoTError"] diff --git a/adafruit_azureiot/constants.py b/adafruit_azureiot/constants.py new file mode 100644 index 0000000..5770342 --- /dev/null +++ b/adafruit_azureiot/constants.py @@ -0,0 +1,38 @@ +# The MIT License (MIT) +# +# Copyright (c) 2020 Jim Bennett +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`constants` +================================================================================ + +This file is for maintaining Microsoft Azure IoT constants that could be changed or added to over time for different scenarios + +* Author(s): Jim Bennett, Elena Horton +""" + +# The version of the IoT Central MQTT API this code is built against +IOTC_API_VERSION = "2016-11-14" + +# The version of the Azure Device Provisioning Service this code is built against +DPS_API_VERSION = "2018-11-01" + +# The Azure Device Provisioning service endpoint that this library uses to provision IoT Central devices +DPS_END_POINT = "global.azure-devices-provisioning.net" diff --git a/adafruit_azureiot/device_registration.py b/adafruit_azureiot/device_registration.py new file mode 100644 index 0000000..dc83ab3 --- /dev/null +++ b/adafruit_azureiot/device_registration.py @@ -0,0 +1,251 @@ +# The MIT License (MIT) +# +# Copyright (c) 2020 Jim Bennett +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`device_registration` +===================== + +Handles registration of IoT Central devices, and gets the hostname to use when connecting +to IoT Central over MQTT + +* Author(s): Jim Bennett, Elena Horton +""" + +import gc +import json +import time +import circuitpython_base64 as base64 +import circuitpython_hmac as hmac +import circuitpython_parse as parse +import adafruit_requests as requests +import adafruit_logging as logging +from adafruit_logging import Logger +import adafruit_hashlib as hashlib +from . import constants + +# Azure HTTP error status codes +AZURE_HTTP_ERROR_CODES = [400, 401, 404, 403, 412, 429, 500] + + +class DeviceRegistrationError(Exception): + """ + An error from the device registration + """ + + def __init__(self, message): + super(DeviceRegistrationError, self).__init__(message) + self.message = message + + +class DeviceRegistration: + """ + Handles registration of IoT Central devices, and gets the hostname to use when connecting + to IoT Central over MQTT + """ + + _loop_interval = 2 + + @staticmethod + def _parse_http_status(status_code: int, status_reason: str) -> None: + """Parses status code, throws error based on Azure IoT Common Error Codes. + :param int status_code: HTTP status code. + :param str status_reason: Description of HTTP status. + :raises DeviceRegistrationError: if the status code is an error code + """ + for error in AZURE_HTTP_ERROR_CODES: + if error == status_code: + raise DeviceRegistrationError("Error {0}: {1}".format(status_code, status_reason)) + + def __init__(self, socket, id_scope: str, device_id: str, key: str, logger: Logger = None): + """Creates an instance of the device registration service + :param socket: The network socket + :param str id_scope: The ID scope of the device to register + :param str device_id: The device ID of the device to register + :param str key: The primary or secondary key of the device to register + :param adafruit_logging.Logger logger: The logger to use to log messages + """ + self._id_scope = id_scope + self._device_id = device_id + self._key = key + self._logger = logger if logger is not None else logging.getLogger("log") + + requests.set_socket(socket) + + @staticmethod + def compute_derived_symmetric_key(secret: str, msg: str) -> bytes: + """Computes a derived symmetric key from a secret and a message + :param str secret: The secret to use for the key + :param str msg: The message to use for the key + :returns: The derived symmetric key + :rtype: bytes + """ + secret = base64.b64decode(secret) + return base64.b64encode(hmac.new(secret, msg=msg.encode("utf8"), digestmod=hashlib.sha256).digest()) + + def _loop_assign(self, operation_id, headers) -> str: + uri = "https://%s/%s/registrations/%s/operations/%s?api-version=%s" % ( + constants.DPS_END_POINT, + self._id_scope, + self._device_id, + operation_id, + constants.DPS_API_VERSION, + ) + self._logger.info("- iotc :: _loop_assign :: " + uri) + target = parse.urlparse(uri) + + response = self._run_get_request_with_retry(target.geturl(), headers) + + try: + data = response.json() + except ValueError as error: + err = "ERROR: " + str(error) + " => " + str(response) + self._logger.error(err) + raise DeviceRegistrationError(err) + + loop_try = 0 + + if data is not None and "status" in data: + if data["status"] == "assigning": + time.sleep(self._loop_interval) + if loop_try < 20: + loop_try = loop_try + 1 + return self._loop_assign(operation_id, headers) + + err = "ERROR: Unable to provision the device." + self._logger.error(err) + raise DeviceRegistrationError(err) + + if data["status"] == "assigned": + state = data["registrationState"] + return state["assignedHub"] + else: + data = str(data) + + err = "DPS L => " + str(data) + self._logger.error(err) + raise DeviceRegistrationError(err) + + def _run_put_request_with_retry(self, url, body, headers): + retry = 0 + response = None + + while True: + gc.collect() + try: + self._logger.debug("Trying to send...") + response = requests.put(url, json=body, headers=headers) + self._logger.debug("Sent!") + break + except RuntimeError as runtime_error: + self._logger.info("Could not send data, retrying after 0.5 seconds: " + str(runtime_error)) + retry = retry + 1 + + if retry >= 10: + self._logger.error("Failed to send data") + raise + + time.sleep(0.5) + continue + + gc.collect() + return response + + def _run_get_request_with_retry(self, url, headers): + retry = 0 + response = None + + while True: + gc.collect() + try: + self._logger.debug("Trying to send...") + response = requests.get(url, headers=headers) + self._logger.debug("Sent!") + break + except RuntimeError as runtime_error: + self._logger.info("Could not send data, retrying after 0.5 seconds: " + str(runtime_error)) + retry = retry + 1 + + if retry >= 10: + self._logger.error("Failed to send data") + raise + + time.sleep(0.5) + continue + + gc.collect() + return response + + def register_device(self, expiry: int) -> str: + """ + Registers the device with the IoT Central device registration service. + Returns the hostname of the IoT hub to use over MQTT + :param int expiry: The expiry time for the registration + :returns: The underlying IoT Hub that this device should connect to + :rtype: str + :raises DeviceRegistrationError: if the device cannot be registered successfully + :raises RuntimeError: if the internet connection is not responding or is unable to connect + """ + # pylint: disable=C0103 + sr = self._id_scope + "%2Fregistrations%2F" + self._device_id + sig_no_encode = DeviceRegistration.compute_derived_symmetric_key(self._key, sr + "\n" + str(expiry)) + sig_encoded = parse.quote(sig_no_encode, "~()*!.'") + auth_string = "SharedAccessSignature sr=" + sr + "&sig=" + sig_encoded + "&se=" + str(expiry) + "&skn=registration" + + headers = { + "content-type": "application/json; charset=utf-8", + "user-agent": "iot-central-client/1.0", + "Accept": "*/*", + } + + if auth_string is not None: + headers["authorization"] = auth_string + + body = {"registrationId": self._device_id} + + uri = "https://%s/%s/registrations/%s/register?api-version=%s" % ( + constants.DPS_END_POINT, + self._id_scope, + self._device_id, + constants.DPS_API_VERSION, + ) + target = parse.urlparse(uri) + + self._logger.info("Connecting...") + self._logger.info("URL: " + target.geturl()) + self._logger.info("body: " + json.dumps(body)) + + response = self._run_put_request_with_retry(target.geturl(), body, headers) + + data = None + try: + data = response.json() + except ValueError as error: + err = "ERROR: non JSON is received from " + constants.DPS_END_POINT + " => " + str(response) + " .. message : " + str(error) + self._logger.error(err) + raise DeviceRegistrationError(err) + + if "errorCode" in data: + err = "DPS => " + str(data) + self._logger.error(err) + raise DeviceRegistrationError(err) + + time.sleep(1) + return self._loop_assign(data["operationId"], headers) diff --git a/adafruit_azureiot/iot_error.py b/adafruit_azureiot/iot_error.py new file mode 100644 index 0000000..767351c --- /dev/null +++ b/adafruit_azureiot/iot_error.py @@ -0,0 +1,42 @@ +# The MIT License (MIT) +# +# Copyright (c) 2020 Jim Bennett +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`iot_error` +===================== + +An error from the IoT service + +* Author(s): Jim Bennett, Elena Horton +""" + + +class IoTError(Exception): + """ + An error from the IoT service + """ + + def __init__(self, message: str): + """Create the IoT Error + :param str message: The error message + """ + super(IoTError, self).__init__(message) + self.message = message diff --git a/adafruit_azureiot/iot_mqtt.py b/adafruit_azureiot/iot_mqtt.py new file mode 100644 index 0000000..62537cb --- /dev/null +++ b/adafruit_azureiot/iot_mqtt.py @@ -0,0 +1,488 @@ +# The MIT License (MIT) +# +# Copyright (c) 2020 Jim Bennett +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`iot_mqtt` +===================== + +An MQTT client for Azure IoT + +* Author(s): Jim Bennett, Elena Horton +""" + +import gc +import json +import time +import adafruit_minimqtt as minimqtt +from adafruit_minimqtt import MQTT +import circuitpython_parse as parse +import adafruit_logging as logging +from .device_registration import DeviceRegistration +from .iot_error import IoTError +from . import constants + +# pylint: disable=R0903 +class IoTResponse: + """A response from a direct method call + """ + + def __init__(self, code: int, message: str): + """Creates an IoT Response object + :param int code: The HTTP response code for this method call, for example 200 if the method was handled successfully + :param str message: The HTTP response message for this method call + """ + self.response_code = code + self.response_message = message + + +class IoTMQTTCallback: + """An interface for classes that can be called by MQTT events + """ + + def message_sent(self, data: str) -> None: + """Called when a message is sent to the cloud + :param str data: The data send with the message + """ + + def connection_status_change(self, connected: bool) -> None: + """Called when the connection status changes + :param bool connected: True if the device is connected, otherwise false + """ + + # pylint: disable=W0613, R0201 + def direct_method_invoked(self, method_name: str, payload: str) -> IoTResponse: + """Called when a direct method is invoked + :param str method_name: The name of the method that was invoked + :param str payload: The payload with the message + :returns: A response with a code and status to show if the method was correctly handled + :rtype: IoTResponse + """ + return IoTResponse(200, "") + + # pylint: disable=C0103 + def cloud_to_device_message_received(self, body: str, properties: dict) -> None: + """Called when a cloud to device message is received + :param str body: The body of the message + :param dict properties: The propreties sent with the mesage + """ + + def device_twin_desired_updated(self, desired_property_name: str, desired_property_value, desired_version: int) -> None: + """Called when the device twin desired properties are updated + :param str desired_property_name: The name of the desired property that was updated + :param desired_property_value: The value of the desired property that was updated + :param int desired_version: The version of the desired property that was updated + """ + + def device_twin_reported_updated(self, reported_property_name: str, reported_property_value, reported_version: int) -> None: + """Called when the device twin reported values are updated + :param str reported_property_name: The name of the reported property that was updated + :param reported_property_value: The value of the reported property that was updated + :param int reported_version: The version of the reported property that was updated + """ + + +# pylint: disable=R0902 +class IoTMQTT: + """MQTT client for Azure IoT + """ + + def _gen_sas_token(self) -> str: + token_expiry = int(time.time() + self._token_expires) + uri = self._hostname + "%2Fdevices%2F" + self._device_id + signed_hmac_sha256 = DeviceRegistration.compute_derived_symmetric_key(self._key, uri + "\n" + str(token_expiry)) + signature = parse.quote(signed_hmac_sha256, "~()*!.'") + if signature.endswith("\n"): # somewhere along the crypto chain a newline is inserted + signature = signature[:-1] + token = "SharedAccessSignature sr={}&sig={}&se={}".format(uri, signature, token_expiry) + return token + + # Workaround for https://github.com/adafruit/Adafruit_CircuitPython_MiniMQTT/issues/25 + def _try_create_mqtt_client(self, hostname: str) -> None: + minimqtt.set_socket(self._socket, self._iface) + + self._mqtts = MQTT( + broker=hostname, + username=self._username, + password=self._passwd, + port=8883, + keep_alive=120, + is_ssl=True, + client_id=self._device_id, + log=True, + ) + + self._mqtts.logger.setLevel(self._logger.getEffectiveLevel()) + + # set actions to take throughout connection lifecycle + self._mqtts.on_connect = self._on_connect + self._mqtts.on_message = self._on_message + self._mqtts.on_log = self._on_log + self._mqtts.on_publish = self._on_publish + self._mqtts.on_disconnect = self._on_disconnect + + # initiate the connection using the adafruit_minimqtt library + self._mqtts.last_will() + self._mqtts.connect() + + def _create_mqtt_client(self) -> None: + try: + self._try_create_mqtt_client(self._hostname) + except ValueError: + # Workaround for https://github.com/adafruit/Adafruit_CircuitPython_MiniMQTT/issues/25 + self._try_create_mqtt_client("https://" + self._hostname) + + # pylint: disable=C0103, W0613 + def _on_connect(self, client, userdata, _, rc) -> None: + self._logger.info("- iot_mqtt :: _on_connect :: rc = " + str(rc) + ", userdata = " + str(userdata)) + if rc == 0: + self._mqtt_connected = True + self._auth_response_received = True + self._callback.connection_status_change(True) + + # pylint: disable=C0103, W0613 + def _on_log(self, client, userdata, level, buf) -> None: + self._logger.info("mqtt-log : " + buf) + if level <= 8: + self._logger.error("mqtt-log : " + buf) + + def _on_disconnect(self, client, userdata, rc) -> None: + self._logger.info("- iot_mqtt :: _on_disconnect :: rc = " + str(rc)) + self._auth_response_received = True + + if rc == 5: + self._logger.error("on(disconnect) : Not authorized") + self.disconnect() + + if rc == 1: + self._mqtt_connected = False + + if rc != 5: + self._callback.connection_status_change(False) + + def _on_publish(self, client, data, topic, msg_id) -> None: + self._logger.info("- iot_mqtt :: _on_publish :: " + str(data) + " on topic " + str(topic)) + + # pylint: disable=W0703 + def _handle_device_twin_update(self, msg: str, topic: str) -> None: + self._logger.debug("- iot_mqtt :: _echo_desired :: " + topic) + twin = None + desired = None + + try: + twin = json.loads(msg) + except json.JSONDecodeError as e: + self._logger.error("ERROR: JSON parse for Device Twin message object has failed. => " + msg + " => " + str(e)) + return + + if "reported" in twin: + reported = twin["reported"] + + if "$version" in reported: + reported_version = reported["$version"] + reported.pop("$version") + else: + self._logger.error("ERROR: Unexpected payload for reported twin update => " + msg) + return + + for property_name, value in reported.items(): + self._callback.device_twin_reported_updated(property_name, value, reported_version) + + is_patch = "desired" not in twin + + if is_patch: + desired = twin + else: + desired = twin["desired"] + + if "$version" in desired: + desired_version = desired["$version"] + desired.pop("$version") + else: + self._logger.error("ERROR: Unexpected payload for desired twin update => " + msg) + return + + for property_name, value in desired.items(): + self._callback.device_twin_desired_updated(property_name, value, desired_version) + + def _handle_direct_method(self, msg: str, topic: str) -> None: + index = topic.find("$rid=") + method_id = 1 + method_name = "None" + if index == -1: + self._logger.error("ERROR: C2D doesn't include topic id") + else: + method_id = topic[index + 5 :] + topic_template = "$iothub/methods/POST/" + len_temp = len(topic_template) + method_name = topic[len_temp : topic.find("/", len_temp + 1)] + + ret = self._callback.direct_method_invoked(method_name, msg) + + ret_code = 200 + ret_message = "{}" + if ret.response_code is not None: + ret_code = ret.response_code + if ret.response_message is not None: + ret_message = ret.response_message + + # ret message must be JSON + if not ret_message.startswith("{") or not ret_message.endswith("}"): + ret_json = {"Value": ret_message} + ret_message = json.dumps(ret_json) + + next_topic = "$iothub/methods/res/{}/?$rid={}".format(ret_code, method_id) + self._logger.info("C2D: => " + next_topic + " with data " + ret_message + " and name => " + method_name) + self._send_common(next_topic, ret_message) + + def _handle_cloud_to_device_message(self, msg: str, topic: str) -> None: + parts = topic.split("&")[1:] + + properties = {} + for part in parts: + key_value = part.split("=") + properties[key_value[0]] = key_value[1] + + self._callback.cloud_to_device_message_received(msg, properties) + + # pylint: disable=W0702, R0912 + def _on_message(self, client, msg_topic, payload) -> None: + topic = "" + msg = None + + self._logger.info("- iot_mqtt :: _on_message :: payload(" + str(payload) + ")") + + if payload is not None: + try: + msg = payload.decode("utf-8") + except: + msg = str(payload) + + if msg_topic is not None: + try: + topic = msg_topic.decode("utf-8") + except: + topic = str(msg_topic) + + if topic.startswith("$iothub/"): + if topic.startswith("$iothub/twin/PATCH/properties/desired/") or topic.startswith("$iothub/twin/res/200/?$rid="): + self._handle_device_twin_update(str(msg), topic) + elif topic.startswith("$iothub/methods"): + self._handle_direct_method(str(msg), topic) + else: + if not topic.startswith("$iothub/twin/res/"): # not twin response + self._logger.error("ERROR: unknown twin! - {}".format(msg)) + elif topic.startswith("devices/{}/messages/devicebound".format(self._device_id)): + self._handle_cloud_to_device_message(str(msg), topic) + else: + self._logger.error("ERROR: (unknown message) - {}".format(msg)) + + def _send_common(self, topic: str, data) -> None: + # Convert data to a string + if isinstance(data, dict): + data = json.dumps(data) + + if not isinstance(data, str): + raise IoTError("Data must be a string or a dictionary") + + self._logger.debug("Sending message on topic: " + topic) + self._logger.debug("Sending message: " + str(data)) + + retry = 0 + + while True: + gc.collect() + try: + self._logger.debug("Trying to send...") + self._mqtts.publish(topic, data) + self._logger.debug("Data sent") + break + except RuntimeError as runtime_error: + self._logger.info("Could not send data, retrying after 0.5 seconds: " + str(runtime_error)) + retry = retry + 1 + + if retry >= 10: + self._logger.error("Failed to send data") + raise + + time.sleep(0.5) + continue + + gc.collect() + + def _get_device_settings(self) -> None: + self._logger.info("- iot_mqtt :: _get_device_settings :: ") + self.loop() + self._send_common("$iothub/twin/GET/?$rid=0", " ") + + # pylint: disable=R0913 + def __init__( + self, + callback: IoTMQTTCallback, + socket, + iface, + hostname: str, + device_id: str, + key: str, + token_expires: int = 21600, + logger: logging = None, + ): + """Create the Azure IoT MQTT client + :param IoTMQTTCallback callback: A callback class + :param socket: The socket to communicate over + :param iface: The network interface to communicate over + :param str hostname: The hostname of the MQTT broker to connect to, get this by registering the device + :param str device_id: The device ID of the device to register + :param str key: The primary or secondary key of the device to register + :param int token_expires: The number of seconds till the token expires, defaults to 6 hours + :param adafruit_logging logger: The logger + """ + self._callback = callback + self._socket = socket + self._iface = iface + self._mqtt_connected = False + self._auth_response_received = False + self._mqtts = None + self._device_id = device_id + self._hostname = hostname + self._key = key + self._token_expires = token_expires + self._username = "{}/{}/api-version={}".format(self._hostname, device_id, constants.IOTC_API_VERSION) + self._passwd = self._gen_sas_token() + self._logger = logger if logger is not None else logging.getLogger("log") + self._is_subscribed_to_twins = False + + def _subscribe_to_core_topics(self): + self._mqtts.subscribe("devices/{}/messages/events/#".format(self._device_id)) + self._mqtts.subscribe("devices/{}/messages/devicebound/#".format(self._device_id)) + self._mqtts.subscribe("$iothub/methods/#") + + def _subscribe_to_twin_topics(self): + self._mqtts.subscribe("$iothub/twin/PATCH/properties/desired/#") # twin desired property changes + self._mqtts.subscribe("$iothub/twin/res/#") # twin properties response + + def connect(self) -> bool: + """Connects to the MQTT broker + :returns: True if the connection is successful, otherwise False + :rtype: bool + """ + self._logger.info("- iot_mqtt :: connect :: " + self._hostname) + + self._create_mqtt_client() + + self._logger.info(" - iot_mqtt :: connect :: created mqtt client. connecting..") + while self._auth_response_received is None: + self.loop() + + self._logger.info(" - iot_mqtt :: connect :: on_connect must be fired. Connected ? " + str(self.is_connected())) + if not self.is_connected(): + return False + + self._mqtt_connected = True + self._auth_response_received = True + + self._subscribe_to_core_topics() + + return True + + def subscribe_to_twins(self) -> None: + """Subscribes to digital twin updates + Only call this if your tier of IoT Hub supports this + """ + if self._is_subscribed_to_twins: + return + + # do this separately as this is not supported in B1 hubs + self._subscribe_to_twin_topics() + + self._get_device_settings() + + self._is_subscribed_to_twins = True + + def disconnect(self) -> None: + """Disconnects from the MQTT broker + """ + if not self.is_connected(): + return + + self._logger.info("- iot_mqtt :: disconnect :: ") + self._mqtt_connected = False + self._mqtts.disconnect() + + def reconnect(self) -> None: + """Reconnects to the MQTT broker + """ + self._logger.info("- iot_mqtt :: reconnect :: ") + + self._mqtts.reconnect() + + def is_connected(self) -> bool: + """Gets if there is an open connection to the MQTT broker + :returns: True if there is an open connection, False if not + :rtype: bool + """ + return self._mqtt_connected + + def loop(self) -> None: + """Listens for MQTT messages + """ + if not self.is_connected(): + return + + self._mqtts.loop() + + def send_device_to_cloud_message(self, message, system_properties: dict = None) -> None: + """Send a device to cloud message from this device to Azure IoT Hub + :param message: The message data as a JSON string or a dictionary + :param system_properties: System properties to send with the message + :raises: ValueError if the message is not a string or dictionary + :raises RuntimeError: if the internet connection is not responding or is unable to connect + """ + self._logger.info("- iot_mqtt :: send_device_to_cloud_message :: " + message) + topic = "devices/{}/messages/events/".format(self._device_id) + + if system_properties is not None: + firstProp = True + for prop in system_properties: + if not firstProp: + topic += "&" + else: + firstProp = False + topic += prop + "=" + str(system_properties[prop]) + + # Convert message to a string + if isinstance(message, dict): + message = json.dumps(message) + + if not isinstance(message, str): + raise ValueError("message must be a string or a dictionary") + + self._send_common(topic, message) + self._callback.message_sent(message) + + def send_twin_patch(self, patch) -> None: + """Send a patch for the reported properties of the device twin + :param patch: The patch as a JSON string or a dictionary + :raises: IoTError if the data is not a string or dictionary + :raises RuntimeError: if the internet connection is not responding or is unable to connect + """ + self._logger.info("- iot_mqtt :: sendProperty :: " + str(patch)) + topic = "$iothub/twin/PATCH/properties/reported/?$rid={}".format(int(time.time())) + self._send_common(topic, patch) diff --git a/adafruit_azureiot/iotcentral_device.py b/adafruit_azureiot/iotcentral_device.py new file mode 100644 index 0000000..43270cc --- /dev/null +++ b/adafruit_azureiot/iotcentral_device.py @@ -0,0 +1,204 @@ +# The MIT License (MIT) +# +# Copyright (c) 2020 Jim Bennett +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`iotcentral_device` +===================== + +Connectivity to Azure IoT Central + +* Author(s): Jim Bennett, Elena Horton +""" + +import json +import time +import adafruit_logging as logging +from .device_registration import DeviceRegistration +from .iot_error import IoTError +from .iot_mqtt import IoTMQTT, IoTMQTTCallback, IoTResponse + + +class IoTCentralDevice(IoTMQTTCallback): + """A device client for the Azure IoT Central service + """ + + def connection_status_change(self, connected: bool) -> None: + """Called when the connection status changes + :param bool connected: True if the device is connected, otherwise false + """ + if self.on_connection_status_changed is not None: + # pylint: disable=E1102 + self.on_connection_status_changed(connected) + + # pylint: disable=W0613, R0201 + def direct_method_called(self, method_name: str, payload: str) -> IoTResponse: + """Called when a direct method is invoked + :param str method_name: The name of the method that was invoked + :param str payload: The payload with the message + :returns: A response with a code and status to show if the method was correctly handled + :rtype: IoTResponse + """ + if self.on_command_executed is not None: + # pylint: disable=E1102 + return self.on_command_executed(method_name, payload) + + raise IoTError("on_command_executed not set") + + def device_twin_desired_updated(self, desired_property_name: str, desired_property_value, desired_version: int) -> None: + """Called when the device twin desired properties are updated + :param str desired_property_name: The name of the desired property that was updated + :param desired_property_value: The value of the desired property that was updated + :param int desired_version: The version of the desired property that was updated + """ + if self.on_property_changed is not None: + # pylint: disable=E1102 + self.on_property_changed(desired_property_name, desired_property_value, desired_version) + + # when a desired property changes, update the reported to match to keep them in sync + self.send_property(desired_property_name, desired_property_value) + + def device_twin_reported_updated(self, reported_property_name: str, reported_property_value, reported_version: int) -> None: + """Called when the device twin reported values are updated + :param str reported_property_name: The name of the reported property that was updated + :param reported_property_value: The value of the reported property that was updated + :param int reported_version: The version of the reported property that was updated + """ + if self.on_property_changed is not None: + # pylint: disable=E1102 + self.on_property_changed(reported_property_name, reported_property_value, reported_version) + + # pylint: disable=R0913 + def __init__(self, socket, iface, id_scope: str, device_id: str, key: str, token_expires: int = 21600, logger: logging = None): + """Create the Azure IoT Central device client + :param socket: The network socket + :param iface: The network interface + :param str id_scope: The ID Scope of the device in IoT Central + :param str device_id: The device ID of the device in IoT Central + :param str key: The primary or secondary key of the device in IoT Central + :param int token_expires: The number of seconds till the token expires, defaults to 6 hours + :param adafruit_logging logger: The logger + """ + self._socket = socket + self._iface = iface + self._id_scope = id_scope + self._device_id = device_id + self._key = key + self._token_expires = token_expires + self._logger = logger if logger is not None else logging.getLogger("log") + self._device_registration = None + self._mqtt = None + + self.on_connection_status_changed = None + """A callback method that is called when the connection status is changed. This method should have the following signature: + def connection_status_changed(connected: bool) -> None + """ + + self.on_command_executed = None + """A callback method that is called when a command is executed on the device. This method should have the following signature: + def connection_status_changed(method_name: str, payload: str) -> IoTResponse: + + This method returns an IoTResponse containing a status code and message from the command call. Set this appropriately + depending on if the command was successfully handled or not. For example, if the command was handled successfully, set + the code to 200 and message to "OK": + + return IoTResponse(200, "OK") + """ + + self.on_property_changed = None + """A callback method that is called when property values are updated. This method should have the following signature: + def property_changed(_property_name: str, property_value, version: int) -> None + """ + + def connect(self) -> None: + """Connects to Azure IoT Central + :raises DeviceRegistrationError: if the device cannot be registered successfully + :raises RuntimeError: if the internet connection is not responding or is unable to connect + """ + self._device_registration = DeviceRegistration(self._socket, self._id_scope, self._device_id, self._key, self._logger) + + token_expiry = int(time.time() + self._token_expires) + hostname = self._device_registration.register_device(token_expiry) + self._mqtt = IoTMQTT(self, self._socket, self._iface, hostname, self._device_id, self._key, self._token_expires, self._logger) + + self._mqtt.connect() + self._mqtt.subscribe_to_twins() + + def disconnect(self) -> None: + """Disconnects from the MQTT broker + :raises IoTError: if there is no open connection to the MQTT broker + """ + if self._mqtt is None: + raise IoTError("You are not connected to IoT Central") + + self._mqtt.disconnect() + + def reconnect(self) -> None: + """Reconnects to the MQTT broker + """ + if self._mqtt is None: + raise IoTError("You are not connected to IoT Central") + + self._mqtt.reconnect() + + def is_connected(self) -> bool: + """Gets if there is an open connection to the MQTT broker + :returns: True if there is an open connection, False if not + :rtype: bool + """ + if self._mqtt is not None: + return self._mqtt.is_connected() + + return False + + def loop(self) -> None: + """Listens for MQTT messages + :raises IoTError: if there is no open connection to the MQTT broker + """ + if self._mqtt is None: + raise IoTError("You are not connected to IoT Central") + + self._mqtt.loop() + + def send_property(self, property_name: str, value) -> None: + """Updates the value of a writable property + :param str property_name: The name of the property to write to + :param value: The value to set on the property + :raises IoTError: if there is no open connection to the MQTT broker + """ + if self._mqtt is None: + raise IoTError("You are not connected to IoT Central") + + patch_json = {property_name: value} + patch = json.dumps(patch_json) + self._mqtt.send_twin_patch(patch) + + def send_telemetry(self, data) -> None: + """Sends telemetry to the IoT Central app + :param data: The telemetry data to send + :raises IoTError: if there is no open connection to the MQTT broker + """ + if self._mqtt is None: + raise IoTError("You are not connected to IoT Central") + + if isinstance(data, dict): + data = json.dumps(data) + + self._mqtt.send_device_to_cloud_message(data) diff --git a/adafruit_azureiot/iothub_device.py b/adafruit_azureiot/iothub_device.py new file mode 100755 index 0000000..c7900d6 --- /dev/null +++ b/adafruit_azureiot/iothub_device.py @@ -0,0 +1,335 @@ +# The MIT License (MIT) +# +# Copyright (c) 2020 Jim Bennett, Elena Horton +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`iothub_device` +===================== + +Connectivity to Azure IoT Hub + +* Author(s): Jim Bennett, Elena Horton +""" + +import json +import adafruit_logging as logging +from .iot_error import IoTError +from .iot_mqtt import IoTMQTT, IoTMQTTCallback, IoTResponse + + +def _validate_keys(connection_string_parts): + """Raise ValueError if incorrect combination of keys + """ + host_name = connection_string_parts.get(HOST_NAME) + shared_access_key_name = connection_string_parts.get(SHARED_ACCESS_KEY_NAME) + shared_access_key = connection_string_parts.get(SHARED_ACCESS_KEY) + device_id = connection_string_parts.get(DEVICE_ID) + + if host_name and device_id and shared_access_key: + pass + elif host_name and shared_access_key and shared_access_key_name: + pass + else: + raise ValueError("Invalid Connection String - Incomplete") + + +DELIMITER = ";" +VALUE_SEPARATOR = "=" + +HOST_NAME = "HostName" +SHARED_ACCESS_KEY_NAME = "SharedAccessKeyName" +SHARED_ACCESS_KEY = "SharedAccessKey" +SHARED_ACCESS_SIGNATURE = "SharedAccessSignature" +DEVICE_ID = "DeviceId" +MODULE_ID = "ModuleId" +GATEWAY_HOST_NAME = "GatewayHostName" + +VALID_KEYS = [ + HOST_NAME, + SHARED_ACCESS_KEY_NAME, + SHARED_ACCESS_KEY, + SHARED_ACCESS_SIGNATURE, + DEVICE_ID, + MODULE_ID, + GATEWAY_HOST_NAME, +] + + +class IoTHubDevice(IoTMQTTCallback): + """A device client for the Azure IoT Hub service + """ + + def connection_status_change(self, connected: bool) -> None: + """Called when the connection status changes + :param bool connected: True if the device is connected, otherwise false + """ + if self._on_connection_status_changed is not None: + # pylint: disable=E1102 + self._on_connection_status_changed(connected) + + # pylint: disable=W0613, R0201 + def direct_method_invoked(self, method_name: str, payload) -> IoTResponse: + """Called when a direct method is invoked + :param str method_name: The name of the method that was invoked + :param str payload: The payload with the message + :returns: A response with a code and status to show if the method was correctly handled + :rtype: IoTResponse + """ + if self._on_direct_method_invoked is not None: + # pylint: disable=E1102 + return self._on_direct_method_invoked(method_name, payload) + + raise IoTError("on_direct_method_invoked not set") + + # pylint: disable=C0103 + def cloud_to_device_message_received(self, body: str, properties: dict) -> None: + """Called when a cloud to device message is received + :param str body: The body of the message + :param dict properties: The propreties sent with the mesage + """ + if self._on_cloud_to_device_message_received is not None: + # pylint: disable=E1102 + self._on_cloud_to_device_message_received(body, properties) + + def device_twin_desired_updated(self, desired_property_name: str, desired_property_value, desired_version: int) -> None: + """Called when the device twin desired properties are updated + :param str desired_property_name: The name of the desired property that was updated + :param desired_property_value: The value of the desired property that was updated + :param int desired_version: The version of the desired property that was updated + """ + if self._on_device_twin_desired_updated is not None: + # pylint: disable=E1102 + self._on_device_twin_desired_updated(desired_property_name, desired_property_value, desired_version) + + def device_twin_reported_updated(self, reported_property_name: str, reported_property_value, reported_version: int) -> None: + """Called when the device twin reported values are updated + :param str reported_property_name: The name of the reported property that was updated + :param reported_property_value: The value of the reported property that was updated + :param int reported_version: The version of the reported property that was updated + """ + if self._on_device_twin_reported_updated is not None: + # pylint: disable=E1102 + self._on_device_twin_reported_updated(reported_property_name, reported_property_value, reported_version) + + def __init__(self, socket, iface, device_connection_string: str, token_expires: int = 21600, logger: logging = None): + """Create the Azure IoT Central device client + :param socket: The network socket + :param iface: The network interface + :param str device_connection_string: The Iot Hub device connection string + :param int token_expires: The number of seconds till the token expires, defaults to 6 hours + :param adafruit_logging logger: The logger + """ + self._socket = socket + self._iface = iface + self._token_expires = token_expires + self._logger = logger if logger is not None else logging.getLogger("log") + + connection_string_values = {} + + try: + cs_args = device_connection_string.split(DELIMITER) + connection_string_values = dict(arg.split(VALUE_SEPARATOR, 1) for arg in cs_args) + except (ValueError, AttributeError): + raise ValueError("Connection string is required and should not be empty or blank and must be supplied as a string") + + if len(cs_args) != len(connection_string_values): + raise ValueError("Invalid Connection String - Unable to parse") + + _validate_keys(connection_string_values) + + self._hostname = connection_string_values[HOST_NAME] + self._device_id = connection_string_values[DEVICE_ID] + self._shared_access_key = connection_string_values[SHARED_ACCESS_KEY] + + self._logger.debug("Hostname: " + self._hostname) + self._logger.debug("Device Id: " + self._device_id) + self._logger.debug("Shared Access Key: " + self._shared_access_key) + + self._on_connection_status_changed = None + self._on_direct_method_invoked = None + self._on_cloud_to_device_message_received = None + self._on_device_twin_desired_updated = None + self._on_device_twin_reported_updated = None + + self._mqtt = None + + @property + def on_connection_status_changed(self): + """A callback method that is called when the connection status is changed. This method should have the following signature: + def connection_status_changed(connected: bool) -> None + """ + return self._on_connection_status_changed + + @on_connection_status_changed.setter + def on_connection_status_changed(self, new_on_connection_status_changed): + """A callback method that is called when the connection status is changed. This method should have the following signature: + def connection_status_changed(connected: bool) -> None + """ + self._on_connection_status_changed = new_on_connection_status_changed + + @property + def on_direct_method_invoked(self): + """A callback method that is called when a direct method is invoked. This method should have the following signature: + def direct_method_invoked(method_name: str, payload: str) -> IoTResponse: + + This method returns an IoTResponse containing a status code and message from the method invocation. Set this appropriately + depending on if the method was successfully handled or not. For example, if the method was handled successfully, set + the code to 200 and message to "OK": + + return IoTResponse(200, "OK") + """ + return self._on_direct_method_invoked + + @on_direct_method_invoked.setter + def on_direct_method_invoked(self, new_on_direct_method_invoked): + """A callback method that is called when a direct method is invoked. This method should have the following signature: + def direct_method_invoked(method_name: str, payload: str) -> IoTResponse: + + This method returns an IoTResponse containing a status code and message from the method invocation. Set this appropriately + depending on if the method was successfully handled or not. For example, if the method was handled successfully, set + the code to 200 and message to "OK": + + return IoTResponse(200, "OK") + """ + self._on_direct_method_invoked = new_on_direct_method_invoked + + @property + def on_cloud_to_device_message_received(self): + """A callback method that is called when a cloud to device message is received. This method should have the following signature: + def cloud_to_device_message_received(body: str, properties: dict) -> None: + """ + return self._on_cloud_to_device_message_received + + @on_cloud_to_device_message_received.setter + def on_cloud_to_device_message_received(self, new_on_cloud_to_device_message_received): + """A callback method that is called when a cloud to device message is received. This method should have the following signature: + def cloud_to_device_message_received(body: str, properties: dict) -> None: + """ + self._on_cloud_to_device_message_received = new_on_cloud_to_device_message_received + + @property + def on_device_twin_desired_updated(self): + """A callback method that is called when the desired properties of the devices device twin are updated. + This method should have the following signature: + def device_twin_desired_updated(desired_property_name: str, desired_property_value, desired_version: int) -> None: + """ + return self._on_device_twin_desired_updated + + @on_device_twin_desired_updated.setter + def on_device_twin_desired_updated(self, new_on_device_twin_desired_updated): + """A callback method that is called when the desired properties of the devices device twin are updated. + This method should have the following signature: + def device_twin_desired_updated(desired_property_name: str, desired_property_value, desired_version: int) -> None: + """ + self._on_device_twin_desired_updated = new_on_device_twin_desired_updated + + if self._mqtt is not None: + self._mqtt.subscribe_to_twins() + + @property + def on_device_twin_reported_updated(self): + """A callback method that is called when the reported properties of the devices device twin are updated. + This method should have the following signature: + def device_twin_reported_updated(reported_property_name: str, reported_property_value, reported_version: int) -> None: + """ + return self._on_device_twin_reported_updated + + @on_device_twin_reported_updated.setter + def on_device_twin_reported_updated(self, new_on_device_twin_reported_updated): + """A callback method that is called when the reported properties of the devices device twin are updated. + This method should have the following signature: + def device_twin_reported_updated(reported_property_name: str, reported_property_value, reported_version: int) -> None: + """ + self._on_device_twin_reported_updated = new_on_device_twin_reported_updated + + if self._mqtt is not None: + self._mqtt.subscribe_to_twins() + + def connect(self) -> None: + """Connects to Azure IoT Hub + :raises RuntimeError: if the internet connection is not responding or is unable to connect + """ + self._mqtt = IoTMQTT( + self, self._socket, self._iface, self._hostname, self._device_id, self._shared_access_key, self._token_expires, self._logger + ) + self._mqtt.connect() + + if self._on_device_twin_desired_updated is not None or self._on_device_twin_reported_updated is not None: + self._mqtt.subscribe_to_twins() + + def disconnect(self) -> None: + """Disconnects from the MQTT broker + :raises IoTError: if there is no open connection to the MQTT broker + """ + if self._mqtt is None: + raise IoTError("You are not connected to IoT Central") + + self._mqtt.disconnect() + + def reconnect(self) -> None: + """Reconnects to the MQTT broker + """ + if self._mqtt is None: + raise IoTError("You are not connected to IoT Central") + + self._mqtt.reconnect() + + def is_connected(self) -> bool: + """Gets if there is an open connection to the MQTT broker + :returns: True if there is an open connection, False if not + :rtype: bool + """ + if self._mqtt is not None: + return self._mqtt.is_connected() + + return False + + def loop(self) -> None: + """Listens for MQTT messages + :raises IoTError: if there is no open connection to the MQTT broker + """ + if self._mqtt is None: + raise IoTError("You are not connected to IoT Central") + + self._mqtt.loop() + + def send_device_to_cloud_message(self, message, system_properties=None) -> None: + """Send a device to cloud message from this device to Azure IoT Hub + :param message: The message data as a JSON string or a dictionary + :param system_properties: System properties to send with the message + :raises: ValueError if the message is not a string or dictionary + :raises RuntimeError: if the internet connection is not responding or is unable to connect + """ + if self._mqtt is None: + raise IoTError("You are not connected to IoT Central") + + self._mqtt.send_device_to_cloud_message(message, system_properties) + + def update_twin(self, patch) -> None: + """Updates the reported properties in the devices device twin + :param patch: The JSON patch to apply to the device twin reported properties + """ + if self._mqtt is None: + raise IoTError("You are not connected to IoT Central") + + if isinstance(patch, dict): + patch = json.dumps(patch) + + self._mqtt.send_twin_patch(patch) diff --git a/docs/conf.py b/docs/conf.py index bef783e..41677b7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,7 +21,7 @@ # Uncomment the below if you use native CircuitPython modules such as # digitalio, micropython and busio. List the modules you use. Without it, the # autodoc module docs will fail to generate with a warning. -# autodoc_mock_imports = ["digitalio", "busio"] +autodoc_mock_imports = ["adafruit_logging", "adafruit_requests", "adafruit_hashlib", "adafruit_ntp"] intersphinx_mapping = { @@ -39,8 +39,8 @@ # General information about the project. project = "Adafruit AzureIoT Library" -copyright = "2019 Brent Rubell" -author = "Brent Rubell" +copyright = "2019 Brent Rubell, Jim Bennett, Elena Horton" +author = "Brent Rubell, Jim Bennett, Elena Horton" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -137,28 +137,14 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - ( - master_doc, - "AdafruitAzureIoTLibrary.tex", - "AdafruitAzureIoT Library Documentation", - author, - "manual", - ), + (master_doc, "AdafruitAzureIoTLibrary.tex", "AdafruitAzureIoT Library Documentation", author, "manual",), ] # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ( - master_doc, - "AdafruitAzureIoTlibrary", - "Adafruit AzureIoT Library Documentation", - [author], - 1, - ) -] +man_pages = [(master_doc, "AdafruitAzureIoTlibrary", "Adafruit AzureIoT Library Documentation", [author], 1,)] # -- Options for Texinfo output ------------------------------------------- diff --git a/docs/examples.rst b/docs/examples.rst index 839a280..013a910 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,8 +1,53 @@ -Simple test +IoT Hub ------------ -Ensure your device works with this simple test. +Ensure your IoT Hub device works with this simple test. -.. literalinclude:: ../examples/azureiot_simpletest.py - :caption: examples/azureiot_simpletest.py +.. literalinclude:: ../examples/iothub_simpletest.py + :caption: examples/iothub_simpletest.py :linenos: + +Handle direct methods. + +.. literalinclude:: ../examples/iothub_directmethods.py + :caption: examples/iothub_directmethods.py + :linenos: + +Send device to cloud messages, and handle cloud to device messages. + +.. literalinclude:: ../examples/iothub_messages.py + :caption: examples/iothub_messages.py + :linenos: + +Update the reported properties of the devices device twin, and receive updates to desired properties. + +.. literalinclude:: ../examples/iothub_twin_operations.py + :caption: examples/iothub_twin_operations.py + :linenos: + +IoT Central +------------ + +Ensure your IoT Central device works with this simple test. + +.. literalinclude:: ../examples/iotcentral_simpletest.py + :caption: examples/iotcentral_simpletest.py + :linenos: + +Handle commands. + +.. literalinclude:: ../examples/iotcentral_commands.py + :caption: examples/iotcentral_commands.py + :linenos: + +Update the properties of the device, and receive updates to properties. + +.. literalinclude:: ../examples/iotcentral_properties.py + :caption: examples/iotcentral_properties.py + :linenos: + +Handle connection errors. + +.. literalinclude:: ../examples/iotcentral_notconnected.py + :caption: examples/iotcentral_notconnected.py + :linenos: \ No newline at end of file diff --git a/docs/iot-central-connect-button.png b/docs/iot-central-connect-button.png new file mode 100644 index 0000000..006fba3 Binary files /dev/null and b/docs/iot-central-connect-button.png differ diff --git a/docs/iot-central-connect-dialog.png b/docs/iot-central-connect-dialog.png new file mode 100644 index 0000000..f63a264 Binary files /dev/null and b/docs/iot-central-connect-dialog.png differ diff --git a/docs/iot-hub-device-keys.png b/docs/iot-hub-device-keys.png new file mode 100644 index 0000000..c081d0d Binary files /dev/null and b/docs/iot-hub-device-keys.png differ diff --git a/docs/iot-hub-device.png b/docs/iot-hub-device.png new file mode 100644 index 0000000..e04fd0b Binary files /dev/null and b/docs/iot-hub-device.png differ diff --git a/examples/azureiot_device_management.py b/examples/azureiot_device_management.py deleted file mode 100644 index 28fed04..0000000 --- a/examples/azureiot_device_management.py +++ /dev/null @@ -1,44 +0,0 @@ -import board -import busio -from digitalio import DigitalInOut -from adafruit_esp32spi import adafruit_esp32spi, adafruit_esp32spi_wifimanager -import neopixel -from adafruit_azureiot import IOT_Hub - -# Get wifi details and more from a secrets.py file -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise - -# ESP32 Setup -try: - esp32_cs = DigitalInOut(board.ESP_CS) - esp32_ready = DigitalInOut(board.ESP_BUSY) - esp32_reset = DigitalInOut(board.ESP_RESET) -except AttributeError: - esp32_cs = DigitalInOut(board.D9) - esp32_ready = DigitalInOut(board.D10) - esp32_reset = DigitalInOut(board.D5) -spi = busio.SPI(board.SCK, board.MOSI, board.MISO) -esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) -status_light = neopixel.NeoPixel( - board.NEOPIXEL, 1, brightness=0.2 -) # Uncomment for Most Boards -"""Uncomment below for ItsyBitsy M4""" -# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) -wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) - -# Create an instance of the Azure IoT Hub -hub = IOT_Hub( - wifi, secrets["azure_iot_hub"], secrets["azure_iot_sas"], secrets["device_id"] -) - -# Enumerate all devices on an Azure IoT Hub -all_hub_devices = hub.get_devices() -print(all_hub_devices) - -# Get a specified device on an Azure IoT Hub -device_data = hub.get_device() -print(device_data) diff --git a/examples/azureiot_devicetwin.py b/examples/azureiot_devicetwin.py deleted file mode 100644 index 40d8757..0000000 --- a/examples/azureiot_devicetwin.py +++ /dev/null @@ -1,53 +0,0 @@ -import board -import busio -from digitalio import DigitalInOut -from adafruit_esp32spi import adafruit_esp32spi, adafruit_esp32spi_wifimanager -import neopixel -from adafruit_azureiot import IOT_Hub - -# Get wifi details and more from a secrets.py file -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise - -# ESP32 Setup -try: - esp32_cs = DigitalInOut(board.ESP_CS) - esp32_ready = DigitalInOut(board.ESP_BUSY) - esp32_reset = DigitalInOut(board.ESP_RESET) -except AttributeError: - esp32_cs = DigitalInOut(board.D9) - esp32_ready = DigitalInOut(board.D10) - esp32_reset = DigitalInOut(board.D5) -spi = busio.SPI(board.SCK, board.MOSI, board.MISO) -esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) -status_light = neopixel.NeoPixel( - board.NEOPIXEL, 1, brightness=0.2 -) # Uncomment for Most Boards -"""Uncomment below for ItsyBitsy M4""" -# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) -wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) - -# Create an instance of the Azure IoT Hub -hub = IOT_Hub( - wifi, secrets["azure_iot_hub"], secrets["azure_iot_sas"], secrets["device_id"] -) - -# Get a Device Twin -device_twin = hub.get_device_twin() -# Filter out the device's name from the twin's properties -device_name = device_twin["properties"]["desired"]["deviceName"] -print(device_name) - -# Update a Device Twin's Properties -data = { - "properties": {"desired": {"deviceName": "{{BasementTemperatureLoggerFeather}}"}} -} -hub.update_device_twin(data) - -# And read the updated device twin information -device_twin = hub.get_device_twin() -device_name = device_twin["properties"]["desired"]["deviceName"] -print(device_name) diff --git a/examples/azureiot_secrets_example.py b/examples/azureiot_secrets_example.py new file mode 100644 index 0000000..0875a7e --- /dev/null +++ b/examples/azureiot_secrets_example.py @@ -0,0 +1,36 @@ +# This file is where you keep secret settings, passwords, and tokens! +# If you put them in the code you risk committing that info or sharing it +# which would be not great. So, instead, keep it all in this one file and +# keep it a secret. + +# To find out how to hide any changes you make to this file from Git, check out +# this blog post: https://www.jimbobbennett.io/hiding-api-keys-from-git/ + +""" +Contains the secrets for your app including WiFi connection details. +DO NOT CHECK THIS INTO SOURCE CODE CONTROL!!!!11!!! +""" + +secrets = { + # WiFi settings + "ssid": "", + "password": "", + # Azure IoT Central settings - if you are connecting to Azure IoT Central, fill in these three values + # To get these values, select your device in Azure IoT Central, + # then select the Connect button + # A dialog will appear with these three values + # id_scope comes from the ID scope value + # device_id comes from the Device ID value + # key comes from either the Primary key or Secondary key + "id_scope": "", + "device_id": "", + "key": "", + # Azure IoT Hub settings - if you are connecting to Azure IoT Hub, fill in this value + # To get this value, from the Azure Portal (https://aka.ms/AzurePortalHome), select your IoT Hub, + # then select Explorers -> IoT devices, select your device, then copy the entire primary or secondary + # connection string using the copy button next to the value and set this here. + # It will be in the format: + # HostName=.azure-devices.net;DeviceId=;SharedAccessKey= + # Note - you need the primary or secondary connection string, NOT the primary or secondary key + "device_connection_string": "", +} diff --git a/examples/azureiot_simpletest.py b/examples/azureiot_simpletest.py deleted file mode 100644 index 87910f5..0000000 --- a/examples/azureiot_simpletest.py +++ /dev/null @@ -1,53 +0,0 @@ -from random import randint -import board -import busio -from digitalio import DigitalInOut -from adafruit_esp32spi import adafruit_esp32spi, adafruit_esp32spi_wifimanager -import neopixel -from adafruit_azureiot import IOT_Hub - -# Get wifi details and more from a secrets.py file -try: - from secrets import secrets -except ImportError: - print("WiFi secrets are kept in secrets.py, please add them there!") - raise - -# ESP32 Setup -try: - esp32_cs = DigitalInOut(board.ESP_CS) - esp32_ready = DigitalInOut(board.ESP_BUSY) - esp32_reset = DigitalInOut(board.ESP_RESET) -except AttributeError: - esp32_cs = DigitalInOut(board.D9) - esp32_ready = DigitalInOut(board.D10) - esp32_reset = DigitalInOut(board.D5) -spi = busio.SPI(board.SCK, board.MOSI, board.MISO) -esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) -status_light = neopixel.NeoPixel( - board.NEOPIXEL, 1, brightness=0.2 -) # Uncomment for Most Boards -"""Uncomment below for ItsyBitsy M4""" -# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) -wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) - -# Create an instance of the Azure IoT Hub -hub = IOT_Hub( - wifi, secrets["azure_iot_hub"], secrets["azure_iot_sas"], secrets["device_id"] -) - -# Send a Device-to-Cloud message -print("Sending Data to Azure IoT Hub...") -data = randint(0, 100) -hub.send_device_message(str(data)) -print("Data Sent!") - -# Receive a Cloud-to-Device message -# NOTE: HTTP Cloud-to-Device messages are HEAVILY throttled over HTTP. -# Microsoft suggests a polling interval of the below code for every 25 minutes. -print("Receiving a message from an Azure IoT Hub...") -message = hub.get_hub_message() -if message is None: - print("IoT Hub Message Queue is empty!") -else: - print(message) diff --git a/examples/iotcentral_commands.py b/examples/iotcentral_commands.py new file mode 100644 index 0000000..e8c3a56 --- /dev/null +++ b/examples/iotcentral_commands.py @@ -0,0 +1,132 @@ +import time +import board +import busio +from digitalio import DigitalInOut +import neopixel +from adafruit_esp32spi import adafruit_esp32spi, adafruit_esp32spi_wifimanager +import adafruit_esp32spi.adafruit_esp32spi_socket as socket +from adafruit_ntp import NTP + +# Get wifi details and more from a secrets.py file +try: + from secrets import secrets +except ImportError: + print("WiFi secrets are kept in secrets.py, please add them there!") + raise + +# ESP32 Setup +try: + esp32_cs = DigitalInOut(board.ESP_CS) + esp32_ready = DigitalInOut(board.ESP_BUSY) + esp32_reset = DigitalInOut(board.ESP_RESET) +except AttributeError: + esp32_cs = DigitalInOut(board.D13) + esp32_ready = DigitalInOut(board.D11) + esp32_reset = DigitalInOut(board.D12) + +spi = busio.SPI(board.SCK, board.MOSI, board.MISO) +esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) + +"""Use below for Most Boards""" +status_light = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2) # Uncomment for Most Boards +"""Uncomment below for ItsyBitsy M4""" +# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) +# Uncomment below for an externally defined RGB LED +# import adafruit_rgbled +# from adafruit_esp32spi import PWMOut +# RED_LED = PWMOut.PWMOut(esp, 26) +# GREEN_LED = PWMOut.PWMOut(esp, 27) +# BLUE_LED = PWMOut.PWMOut(esp, 25) +# status_light = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) +wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) + +print("Connecting to WiFi...") + +wifi.connect() + +print("Connected to WiFi!") + +print("Getting the time...") + +ntp = NTP(esp) +# Wait for a valid time to be received +while not ntp.valid_time: + time.sleep(5) + ntp.set_time() + +print("Time:", str(time.time())) + +# To use Azure IoT Central, you will need to create an IoT Central app. +# You can either create a free tier app that will live for 7 days without an Azure subscription, +# Or a standard tier app that will last for ever with an Azure subscription. +# The standard tiers are free for up to 2 devices +# +# If you don't have an Azure subscription: +# +# If you are a student, head to https://aka.ms/FreeStudentAzure and sign up, validating with your +# student email address. This will give you $100 of Azure credit and free tiers of a load of +# service, renewable each year you are a student +# +# If you are not a student, head to https://aka.ms/FreeAz and sign up to get $200 of credit for 30 +# days, as well as free tiers of a load of services +# +# Create an Azure IoT Central app by following these instructions: https://aka.ms/CreateIoTCentralApp +# Add a device template with telemetry, properties and commands, as well as a view to visualize the +# telemetry and execute commands, and a form to set properties. +# +# Next create a device using the device template, and select Connect to get the device connection details. +# Add the connection details to your secrets.py file, using the following values: +# +# 'id_scope' - the devices ID scope +# 'device_id' - the devices device id +# 'key' - the devices primary key +# +# The adafruit-circuitpython-azureiot library depends on the following libraries: +# +# From the Adafruit CircuitPython Bundle (https://github.com/adafruit/Adafruit_CircuitPython_Bundle): +# * adafruit-circuitpython-minimqtt +# +# From the CircuitPython Community LIbrary and Driver Bundle (https://github.com/adafruit/CircuitPython_Community_Bundle): +# * circuitpython-hmac +# * circuitpython-base64 +# * circuitpython-parse +from adafruit_azureiot import IoTCentralDevice +from adafruit_azureiot.iot_mqtt import IoTResponse + +# Create an IoT Hub device client and connect +device = IoTCentralDevice(socket, esp, secrets["id_scope"], secrets["device_id"], secrets["key"]) + +# Subscribe to commands +# Commands can be sent from the devices Dashboard in IoT Central, assuming +# the device template and view has been set up with the commands +# Command handlers need to return a response to show if the command was handled +# successfully or not, returning an HTTP status code and message +def command_executed(command_name: str, payload) -> IoTResponse: + print("Command", command_name, "executed with payload", str(payload)) + # return a status code and message to indicate if the command was handled correctly + return IoTResponse(200, "OK") + + +# Subscribe to the command execute event +device.on_command_executed = command_executed + +print("Connecting to Azure IoT Central...") + +# Connect to IoT Central +device.connect() + +print("Connected to Azure IoT Central!") + +while True: + try: + # Poll every second for messages from the cloud + device.loop() + except (ValueError, RuntimeError) as e: + print("Connection error, reconnecting\n", str(e)) + # If we lose connectivity, reset the wifi and reconnect + wifi.reset() + wifi.connect() + device.reconnect() + continue + + time.sleep(1) diff --git a/examples/iotcentral_notconnected.py b/examples/iotcentral_notconnected.py new file mode 100644 index 0000000..4a9f4d6 --- /dev/null +++ b/examples/iotcentral_notconnected.py @@ -0,0 +1,107 @@ +import json +import random +import time +import board +import busio +from digitalio import DigitalInOut +import neopixel +from adafruit_esp32spi import adafruit_esp32spi, adafruit_esp32spi_wifimanager +import adafruit_esp32spi.adafruit_esp32spi_socket as socket +from adafruit_ntp import NTP + +# Get wifi details and more from a secrets.py file +try: + from secrets import secrets +except ImportError: + print("WiFi secrets are kept in secrets.py, please add them there!") + raise + +# ESP32 Setup +try: + esp32_cs = DigitalInOut(board.ESP_CS) + esp32_ready = DigitalInOut(board.ESP_BUSY) + esp32_reset = DigitalInOut(board.ESP_RESET) +except AttributeError: + esp32_cs = DigitalInOut(board.D13) + esp32_ready = DigitalInOut(board.D11) + esp32_reset = DigitalInOut(board.D12) + +spi = busio.SPI(board.SCK, board.MOSI, board.MISO) +esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) + +"""Use below for Most Boards""" +status_light = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2) # Uncomment for Most Boards +"""Uncomment below for ItsyBitsy M4""" +# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) +# Uncomment below for an externally defined RGB LED +# import adafruit_rgbled +# from adafruit_esp32spi import PWMOut +# RED_LED = PWMOut.PWMOut(esp, 26) +# GREEN_LED = PWMOut.PWMOut(esp, 27) +# BLUE_LED = PWMOut.PWMOut(esp, 25) +# status_light = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) +wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) + +print("Connecting to WiFi...") + +wifi.connect() + +print("Connected to WiFi!") + +print("Getting the time...") + +ntp = NTP(esp) +# Wait for a valid time to be received +while not ntp.valid_time: + time.sleep(5) + ntp.set_time() + +print("Time:", str(time.time())) + +# To use Azure IoT Central, you will need to create an IoT Central app. +# You can either create a free tier app that will live for 7 days without an Azure subscription, +# Or a standard tier app that will last for ever with an Azure subscription. +# The standard tiers are free for up to 2 devices +# +# If you don't have an Azure subscription: +# +# If you are a student, head to https://aka.ms/FreeStudentAzure and sign up, validating with your +# student email address. This will give you $100 of Azure credit and free tiers of a load of +# service, renewable each year you are a student +# +# If you are not a student, head to https://aka.ms/FreeAz and sign up to get $200 of credit for 30 +# days, as well as free tiers of a load of services +# +# Create an Azure IoT Central app by following these instructions: https://aka.ms/CreateIoTCentralApp +# Add a device template with telemetry, properties and commands, as well as a view to visualize the +# telemetry and execute commands, and a form to set properties. +# +# Next create a device using the device template, and select Connect to get the device connection details. +# Add the connection details to your secrets.py file, using the following values: +# +# 'id_scope' - the devices ID scope +# 'device_id' - the devices device id +# 'key' - the devices primary key +# +# The adafruit-circuitpython-azureiot library depends on the following libraries: +# +# From the Adafruit CircuitPython Bundle (https://github.com/adafruit/Adafruit_CircuitPython_Bundle): +# * adafruit-circuitpython-minimqtt +# +# From the CircuitPython Community LIbrary and Driver Bundle (https://github.com/adafruit/CircuitPython_Community_Bundle): +# * circuitpython-hmac +# * circuitpython-base64 +# * circuitpython-parse +from adafruit_azureiot import IoTCentralDevice, IoTError + +# Create an IoT Hub device client and connect +device = IoTCentralDevice(socket, esp, secrets["id_scope"], secrets["device_id"], secrets["key"]) + +# don't connect +# device.connect() + +try: + message = {"Temperature": random.randint(0, 50)} + device.send_telemetry(json.dumps(message)) +except IoTError as iot_error: + print("Error - ", iot_error.message) diff --git a/examples/iotcentral_properties.py b/examples/iotcentral_properties.py new file mode 100644 index 0000000..15dde99 --- /dev/null +++ b/examples/iotcentral_properties.py @@ -0,0 +1,138 @@ +import random +import time +import board +import busio +from digitalio import DigitalInOut +import neopixel +from adafruit_esp32spi import adafruit_esp32spi, adafruit_esp32spi_wifimanager +import adafruit_esp32spi.adafruit_esp32spi_socket as socket +from adafruit_ntp import NTP + +# Get wifi details and more from a secrets.py file +try: + from secrets import secrets +except ImportError: + print("WiFi secrets are kept in secrets.py, please add them there!") + raise + +# ESP32 Setup +try: + esp32_cs = DigitalInOut(board.ESP_CS) + esp32_ready = DigitalInOut(board.ESP_BUSY) + esp32_reset = DigitalInOut(board.ESP_RESET) +except AttributeError: + esp32_cs = DigitalInOut(board.D13) + esp32_ready = DigitalInOut(board.D11) + esp32_reset = DigitalInOut(board.D12) + +spi = busio.SPI(board.SCK, board.MOSI, board.MISO) +esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) + +"""Use below for Most Boards""" +status_light = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2) # Uncomment for Most Boards +"""Uncomment below for ItsyBitsy M4""" +# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) +# Uncomment below for an externally defined RGB LED +# import adafruit_rgbled +# from adafruit_esp32spi import PWMOut +# RED_LED = PWMOut.PWMOut(esp, 26) +# GREEN_LED = PWMOut.PWMOut(esp, 27) +# BLUE_LED = PWMOut.PWMOut(esp, 25) +# status_light = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) +wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) + +print("Connecting to WiFi...") + +wifi.connect() + +print("Connected to WiFi!") + +print("Getting the time...") + +ntp = NTP(esp) +# Wait for a valid time to be received +while not ntp.valid_time: + time.sleep(5) + ntp.set_time() + +print("Time:", str(time.time())) + +# To use Azure IoT Central, you will need to create an IoT Central app. +# You can either create a free tier app that will live for 7 days without an Azure subscription, +# Or a standard tier app that will last for ever with an Azure subscription. +# The standard tiers are free for up to 2 devices +# +# If you don't have an Azure subscription: +# +# If you are a student, head to https://aka.ms/FreeStudentAzure and sign up, validating with your +# student email address. This will give you $100 of Azure credit and free tiers of a load of +# service, renewable each year you are a student +# +# If you are not a student, head to https://aka.ms/FreeAz and sign up to get $200 of credit for 30 +# days, as well as free tiers of a load of services +# +# Create an Azure IoT Central app by following these instructions: https://aka.ms/CreateIoTCentralApp +# Add a device template with telemetry, properties and commands, as well as a view to visualize the +# telemetry and execute commands, and a form to set properties. +# +# Next create a device using the device template, and select Connect to get the device connection details. +# Add the connection details to your secrets.py file, using the following values: +# +# 'id_scope' - the devices ID scope +# 'device_id' - the devices device id +# 'key' - the devices primary key +# +# The adafruit-circuitpython-azureiot library depends on the following libraries: +# +# From the Adafruit CircuitPython Bundle (https://github.com/adafruit/Adafruit_CircuitPython_Bundle): +# * adafruit-circuitpython-minimqtt +# +# From the CircuitPython Community LIbrary and Driver Bundle (https://github.com/adafruit/CircuitPython_Community_Bundle): +# * circuitpython-hmac +# * circuitpython-base64 +# * circuitpython-parse +from adafruit_azureiot import IoTCentralDevice + +# Create an IoT Hub device client and connect +device = IoTCentralDevice(socket, esp, secrets["id_scope"], secrets["device_id"], secrets["key"]) + +# Subscribe to property changes +# Properties can be updated either in code, or by adding a form to the view +# in the device template, and setting the value on the dashboard for the device +def property_changed(property_name, property_value, version): + print("Property", property_name, "updated to", str(property_value), "version", str(version)) + + +# Subscribe to the property changed event +device.on_property_changed = property_changed + +print("Connecting to Azure IoT Central...") + +# Connect to IoT Central +device.connect() + +print("Connected to Azure IoT Central!") + +message_counter = 60 + +while True: + try: + # Send property values every minute + # You can see the values in the devices dashboard + if message_counter >= 60: + device.send_property("Desired_Temperature", random.randint(0, 50)) + message_counter = 0 + else: + message_counter = message_counter + 1 + + # Poll every second for messages from the cloud + device.loop() + except (ValueError, RuntimeError) as e: + print("Connection error, reconnecting\n", str(e)) + # If we lose connectivity, reset the wifi and reconnect + wifi.reset() + wifi.connect() + device.reconnect() + continue + + time.sleep(1) diff --git a/examples/iotcentral_simpletest.py b/examples/iotcentral_simpletest.py new file mode 100644 index 0000000..571ee38 --- /dev/null +++ b/examples/iotcentral_simpletest.py @@ -0,0 +1,130 @@ +import json +import random +import time +import board +import busio +from digitalio import DigitalInOut +import neopixel +from adafruit_esp32spi import adafruit_esp32spi, adafruit_esp32spi_wifimanager +import adafruit_esp32spi.adafruit_esp32spi_socket as socket +from adafruit_ntp import NTP + +# Get wifi details and more from a secrets.py file +try: + from secrets import secrets +except ImportError: + print("WiFi secrets are kept in secrets.py, please add them there!") + raise + +# ESP32 Setup +try: + esp32_cs = DigitalInOut(board.ESP_CS) + esp32_ready = DigitalInOut(board.ESP_BUSY) + esp32_reset = DigitalInOut(board.ESP_RESET) +except AttributeError: + esp32_cs = DigitalInOut(board.D13) + esp32_ready = DigitalInOut(board.D11) + esp32_reset = DigitalInOut(board.D12) + +spi = busio.SPI(board.SCK, board.MOSI, board.MISO) +esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) + +"""Use below for Most Boards""" +status_light = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2) # Uncomment for Most Boards +"""Uncomment below for ItsyBitsy M4""" +# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) +# Uncomment below for an externally defined RGB LED +# import adafruit_rgbled +# from adafruit_esp32spi import PWMOut +# RED_LED = PWMOut.PWMOut(esp, 26) +# GREEN_LED = PWMOut.PWMOut(esp, 27) +# BLUE_LED = PWMOut.PWMOut(esp, 25) +# status_light = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) +wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) + +print("Connecting to WiFi...") + +wifi.connect() + +print("Connected to WiFi!") + +print("Getting the time...") + +ntp = NTP(esp) +# Wait for a valid time to be received +while not ntp.valid_time: + time.sleep(5) + ntp.set_time() + +print("Time:", str(time.time())) + +# To use Azure IoT Central, you will need to create an IoT Central app. +# You can either create a free tier app that will live for 7 days without an Azure subscription, +# Or a standard tier app that will last for ever with an Azure subscription. +# The standard tiers are free for up to 2 devices +# +# If you don't have an Azure subscription: +# +# If you are a student, head to https://aka.ms/FreeStudentAzure and sign up, validating with your +# student email address. This will give you $100 of Azure credit and free tiers of a load of +# service, renewable each year you are a student +# +# If you are not a student, head to https://aka.ms/FreeAz and sign up to get $200 of credit for 30 +# days, as well as free tiers of a load of services +# +# Create an Azure IoT Central app by following these instructions: https://aka.ms/CreateIoTCentralApp +# Add a device template with telemetry, properties and commands, as well as a view to visualize the +# telemetry and execute commands, and a form to set properties. +# +# Next create a device using the device template, and select Connect to get the device connection details. +# Add the connection details to your secrets.py file, using the following values: +# +# 'id_scope' - the devices ID scope +# 'device_id' - the devices device id +# 'key' - the devices primary key +# +# The adafruit-circuitpython-azureiot library depends on the following libraries: +# +# From the Adafruit CircuitPython Bundle (https://github.com/adafruit/Adafruit_CircuitPython_Bundle): +# * adafruit-circuitpython-minimqtt +# +# From the CircuitPython Community LIbrary and Driver Bundle (https://github.com/adafruit/CircuitPython_Community_Bundle): +# * circuitpython-hmac +# * circuitpython-base64 +# * circuitpython-parse +from adafruit_azureiot import IoTCentralDevice + +# Create an IoT Hub device client and connect +device = IoTCentralDevice(socket, esp, secrets["id_scope"], secrets["device_id"], secrets["key"]) + +print("Connecting to Azure IoT Central...") + +# Connect to IoT Central +device.connect() + +print("Connected to Azure IoT Central!") + +message_counter = 60 + +while True: + try: + # Send telemetry every minute + # You can see the values in the devices dashboard + if message_counter >= 60: + message = {"Temperature": random.randint(0, 50)} + device.send_telemetry(json.dumps(message)) + message_counter = 0 + else: + message_counter = message_counter + 1 + + # Poll every second for messages from the cloud + device.loop() + except (ValueError, RuntimeError) as e: + print("Connection error, reconnecting\n", str(e)) + # If we lose connectivity, reset the wifi and reconnect + wifi.reset() + wifi.connect() + device.reconnect() + continue + + time.sleep(1) diff --git a/examples/iothub_directmethods.py b/examples/iothub_directmethods.py new file mode 100644 index 0000000..341a51f --- /dev/null +++ b/examples/iothub_directmethods.py @@ -0,0 +1,127 @@ +import time +import board +import busio +from digitalio import DigitalInOut +import neopixel +from adafruit_esp32spi import adafruit_esp32spi, adafruit_esp32spi_wifimanager +import adafruit_esp32spi.adafruit_esp32spi_socket as socket +from adafruit_ntp import NTP + +# Get wifi details and more from a secrets.py file +try: + from secrets import secrets +except ImportError: + print("WiFi secrets are kept in secrets.py, please add them there!") + raise + +# ESP32 Setup +try: + esp32_cs = DigitalInOut(board.ESP_CS) + esp32_ready = DigitalInOut(board.ESP_BUSY) + esp32_reset = DigitalInOut(board.ESP_RESET) +except AttributeError: + esp32_cs = DigitalInOut(board.D13) + esp32_ready = DigitalInOut(board.D11) + esp32_reset = DigitalInOut(board.D12) + +spi = busio.SPI(board.SCK, board.MOSI, board.MISO) +esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) + +"""Use below for Most Boards""" +status_light = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2) # Uncomment for Most Boards +"""Uncomment below for ItsyBitsy M4""" +# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) +# Uncomment below for an externally defined RGB LED +# import adafruit_rgbled +# from adafruit_esp32spi import PWMOut +# RED_LED = PWMOut.PWMOut(esp, 26) +# GREEN_LED = PWMOut.PWMOut(esp, 27) +# BLUE_LED = PWMOut.PWMOut(esp, 25) +# status_light = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) +wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) + +print("Connecting to WiFi...") + +wifi.connect() + +print("Connected to WiFi!") + +print("Getting the time...") + +ntp = NTP(esp) +# Wait for a valid time to be received +while not ntp.valid_time: + time.sleep(5) + ntp.set_time() + +print("Time:", str(time.time())) + +# You will need an Azure subscription to create an Azure IoT Hub resource +# +# If you don't have an Azure subscription: +# +# If you are a student, head to https://aka.ms/FreeStudentAzure and sign up, validating with your +# student email address. This will give you $100 of Azure credit and free tiers of a load of +# service, renewable each year you are a student +# +# If you are not a student, head to https://aka.ms/FreeAz and sign up to get $200 of credit for 30 +# days, as well as free tiers of a load of services +# +# Create an Azure IoT Hub and an IoT device in the Azure portal here: https://aka.ms/AzurePortalHome. +# Instructions to create an IoT Hub and device are here: https://aka.ms/CreateIoTHub +# +# The free tier of IoT Hub allows up to 8,000 messages a day, so try not to send messages too often +# if you are using the free tier +# +# Once you have a hub and a device, copy the device primary connection string. +# Add it to the secrets.py file in an entry called device_connection_string +# +# The adafruit-circuitpython-azureiot library depends on the following libraries: +# +# From the Adafruit CircuitPython Bundle (https://github.com/adafruit/Adafruit_CircuitPython_Bundle): +# * adafruit-circuitpython-minimqtt +# +# From the CircuitPython Community LIbrary and Driver Bundle (https://github.com/adafruit/CircuitPython_Community_Bundle): +# * circuitpython-hmac +# * circuitpython-base64 +# * circuitpython-parse +from adafruit_azureiot import IoTHubDevice +from adafruit_azureiot.iot_mqtt import IoTResponse + +# Create an IoT Hub device client and connect +device = IoTHubDevice(socket, esp, secrets["device_connection_string"]) + +# Subscribe to direct method calls +# To invoke a method on the device, select it in the Azure Portal, select Direct Method, +# fill in the method name and payload, then select Invoke Method +# Direct method handlers need to return a response to show if the method was handled +# successfully or not, returning an HTTP status code and message +def direct_method_invoked(method_name: str, payload) -> IoTResponse: + print("Received direct method", method_name, "with data", str(payload)) + # return a status code and message to indicate if the direct method was handled correctly + return IoTResponse(200, "OK") + + +# Subscribe to the direct method invoked event +device.on_direct_method_invoked = direct_method_invoked + +print("Connecting to Azure IoT Hub...") + +# Connect to IoT Central +device.connect() + +print("Connected to Azure IoT Hub!") + +while True: + try: + # Poll every second for messages from the cloud + device.loop() + except (ValueError, RuntimeError) as e: + print("Connection error, reconnecting\n", str(e)) + # If we lose connectivity, reset the wifi and reconnect + wifi.reset() + wifi.connect() + device.reconnect() + continue + + time.sleep(1) diff --git a/examples/iothub_messages.py b/examples/iothub_messages.py new file mode 100644 index 0000000..52524b1 --- /dev/null +++ b/examples/iothub_messages.py @@ -0,0 +1,136 @@ +import json +import random +import time +import board +import busio +from digitalio import DigitalInOut +import neopixel +from adafruit_esp32spi import adafruit_esp32spi, adafruit_esp32spi_wifimanager +import adafruit_esp32spi.adafruit_esp32spi_socket as socket +from adafruit_ntp import NTP + +# Get wifi details and more from a secrets.py file +try: + from secrets import secrets +except ImportError: + print("WiFi secrets are kept in secrets.py, please add them there!") + raise + +# ESP32 Setup +try: + esp32_cs = DigitalInOut(board.ESP_CS) + esp32_ready = DigitalInOut(board.ESP_BUSY) + esp32_reset = DigitalInOut(board.ESP_RESET) +except AttributeError: + esp32_cs = DigitalInOut(board.D13) + esp32_ready = DigitalInOut(board.D11) + esp32_reset = DigitalInOut(board.D12) + +spi = busio.SPI(board.SCK, board.MOSI, board.MISO) +esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) + +"""Use below for Most Boards""" +status_light = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2) # Uncomment for Most Boards +"""Uncomment below for ItsyBitsy M4""" +# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) +# Uncomment below for an externally defined RGB LED +# import adafruit_rgbled +# from adafruit_esp32spi import PWMOut +# RED_LED = PWMOut.PWMOut(esp, 26) +# GREEN_LED = PWMOut.PWMOut(esp, 27) +# BLUE_LED = PWMOut.PWMOut(esp, 25) +# status_light = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) +wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) + +print("Connecting to WiFi...") + +wifi.connect() + +print("Connected to WiFi!") + +print("Getting the time...") + +ntp = NTP(esp) +# Wait for a valid time to be received +while not ntp.valid_time: + time.sleep(5) + ntp.set_time() + +print("Time:", str(time.time())) + +# You will need an Azure subscription to create an Azure IoT Hub resource +# +# If you don't have an Azure subscription: +# +# If you are a student, head to https://aka.ms/FreeStudentAzure and sign up, validating with your +# student email address. This will give you $100 of Azure credit and free tiers of a load of +# service, renewable each year you are a student +# +# If you are not a student, head to https://aka.ms/FreeAz and sign up to get $200 of credit for 30 +# days, as well as free tiers of a load of services +# +# Create an Azure IoT Hub and an IoT device in the Azure portal here: https://aka.ms/AzurePortalHome. +# Instructions to create an IoT Hub and device are here: https://aka.ms/CreateIoTHub +# +# The free tier of IoT Hub allows up to 8,000 messages a day, so try not to send messages too often +# if you are using the free tier +# +# Once you have a hub and a device, copy the device primary connection string. +# Add it to the secrets.py file in an entry called device_connection_string +# +# The adafruit-circuitpython-azureiot library depends on the following libraries: +# +# From the Adafruit CircuitPython Bundle (https://github.com/adafruit/Adafruit_CircuitPython_Bundle): +# * adafruit-circuitpython-minimqtt +# +# From the CircuitPython Community LIbrary and Driver Bundle (https://github.com/adafruit/CircuitPython_Community_Bundle): +# * circuitpython-hmac +# * circuitpython-base64 +# * circuitpython-parse +from adafruit_azureiot import IoTHubDevice + +# Create an IoT Hub device client and connect +device = IoTHubDevice(socket, esp, secrets["device_connection_string"]) + +# Subscribe to cloud to device messages +# To send a message to the device, select it in the Azure Portal, select Message To Device, +# fill in the message and any properties you want to add, then select Send Message +def cloud_to_device_message_received(body: str, properties: dict): + print("Received message with body", body, "and properties", json.dumps(properties)) + + +# Subscribe to the cloud to device message received events +device.on_cloud_to_device_message_received = cloud_to_device_message_received + +print("Connecting to Azure IoT Hub...") + +# Connect to IoT Central +device.connect() + +print("Connected to Azure IoT Hub!") + +message_counter = 60 + +while True: + try: + # Send a device to cloud message every minute + # You can see the overview of messages sent from the device in the Overview tab + # of the IoT Hub in the Azure Portal + if message_counter >= 60: + message = {"Temperature": random.randint(0, 50)} + device.send_device_to_cloud_message(json.dumps(message)) + message_counter = 0 + else: + message_counter = message_counter + 1 + + # Poll every second for messages from the cloud + device.loop() + except (ValueError, RuntimeError) as e: + print("Connection error, reconnecting\n", str(e)) + # If we lose connectivity, reset the wifi and reconnect + wifi.reset() + wifi.connect() + device.reconnect() + continue + + time.sleep(1) diff --git a/examples/iothub_simpletest.py b/examples/iothub_simpletest.py new file mode 100644 index 0000000..703190c --- /dev/null +++ b/examples/iothub_simpletest.py @@ -0,0 +1,126 @@ +import json +import random +import time +import board +import busio +from digitalio import DigitalInOut +import neopixel +from adafruit_esp32spi import adafruit_esp32spi, adafruit_esp32spi_wifimanager +import adafruit_esp32spi.adafruit_esp32spi_socket as socket +from adafruit_ntp import NTP + +# Get wifi details and more from a secrets.py file +try: + from secrets import secrets +except ImportError: + print("WiFi secrets are kept in secrets.py, please add them there!") + raise + +# ESP32 Setup +try: + esp32_cs = DigitalInOut(board.ESP_CS) + esp32_ready = DigitalInOut(board.ESP_BUSY) + esp32_reset = DigitalInOut(board.ESP_RESET) +except AttributeError: + esp32_cs = DigitalInOut(board.D13) + esp32_ready = DigitalInOut(board.D11) + esp32_reset = DigitalInOut(board.D12) + +spi = busio.SPI(board.SCK, board.MOSI, board.MISO) +esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) + +"""Use below for Most Boards""" +status_light = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2) # Uncomment for Most Boards +"""Uncomment below for ItsyBitsy M4""" +# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) +# Uncomment below for an externally defined RGB LED +# import adafruit_rgbled +# from adafruit_esp32spi import PWMOut +# RED_LED = PWMOut.PWMOut(esp, 26) +# GREEN_LED = PWMOut.PWMOut(esp, 27) +# BLUE_LED = PWMOut.PWMOut(esp, 25) +# status_light = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) +wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) + +print("Connecting to WiFi...") + +wifi.connect() + +print("Connected to WiFi!") + +print("Getting the time...") + +ntp = NTP(esp) +# Wait for a valid time to be received +while not ntp.valid_time: + time.sleep(5) + ntp.set_time() + +print("Time:", str(time.time())) + +# You will need an Azure subscription to create an Azure IoT Hub resource +# +# If you don't have an Azure subscription: +# +# If you are a student, head to https://aka.ms/FreeStudentAzure and sign up, validating with your +# student email address. This will give you $100 of Azure credit and free tiers of a load of +# service, renewable each year you are a student +# +# If you are not a student, head to https://aka.ms/FreeAz and sign up to get $200 of credit for 30 +# days, as well as free tiers of a load of services +# +# Create an Azure IoT Hub and an IoT device in the Azure portal here: https://aka.ms/AzurePortalHome. +# Instructions to create an IoT Hub and device are here: https://aka.ms/CreateIoTHub +# +# The free tier of IoT Hub allows up to 8,000 messages a day, so try not to send messages too often +# if you are using the free tier +# +# Once you have a hub and a device, copy the device primary connection string. +# Add it to the secrets.py file in an entry called device_connection_string +# +# The adafruit-circuitpython-azureiot library depends on the following libraries: +# +# From the Adafruit CircuitPython Bundle (https://github.com/adafruit/Adafruit_CircuitPython_Bundle): +# * adafruit-circuitpython-minimqtt +# +# From the CircuitPython Community LIbrary and Driver Bundle (https://github.com/adafruit/CircuitPython_Community_Bundle): +# * circuitpython-hmac +# * circuitpython-base64 +# * circuitpython-parse +from adafruit_azureiot import IoTHubDevice + +# Create an IoT Hub device client and connect +device = IoTHubDevice(socket, esp, secrets["device_connection_string"]) + +print("Connecting to Azure IoT Hub...") + +# Connect to IoT Central +device.connect() + +print("Connected to Azure IoT Hub!") + +message_counter = 60 + +while True: + try: + # Send a device to cloud message every minute + # You can see the overview of messages sent from the device in the Overview tab + # of the IoT Hub in the Azure Portal + if message_counter >= 60: + message = {"Temperature": random.randint(0, 50)} + device.send_device_to_cloud_message(json.dumps(message)) + message_counter = 0 + else: + message_counter = message_counter + 1 + + # Poll every second for messages from the cloud + device.loop() + except (ValueError, RuntimeError) as e: + print("Connection error, reconnecting\n", str(e)) + # If we lose connectivity, reset the wifi and reconnect + wifi.reset() + wifi.connect() + device.reconnect() + continue + + time.sleep(1) diff --git a/examples/iothub_twin_operations.py b/examples/iothub_twin_operations.py new file mode 100644 index 0000000..83ef88b --- /dev/null +++ b/examples/iothub_twin_operations.py @@ -0,0 +1,139 @@ +import random +import time +import board +import busio +from digitalio import DigitalInOut +import neopixel +from adafruit_esp32spi import adafruit_esp32spi, adafruit_esp32spi_wifimanager +import adafruit_esp32spi.adafruit_esp32spi_socket as socket +from adafruit_ntp import NTP + +# Get wifi details and more from a secrets.py file +try: + from secrets import secrets +except ImportError: + print("WiFi secrets are kept in secrets.py, please add them there!") + raise + +# ESP32 Setup +try: + esp32_cs = DigitalInOut(board.ESP_CS) + esp32_ready = DigitalInOut(board.ESP_BUSY) + esp32_reset = DigitalInOut(board.ESP_RESET) +except AttributeError: + esp32_cs = DigitalInOut(board.D13) + esp32_ready = DigitalInOut(board.D11) + esp32_reset = DigitalInOut(board.D12) + +spi = busio.SPI(board.SCK, board.MOSI, board.MISO) +esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) + +"""Use below for Most Boards""" +status_light = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2) # Uncomment for Most Boards +"""Uncomment below for ItsyBitsy M4""" +# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=0.2) +# Uncomment below for an externally defined RGB LED +# import adafruit_rgbled +# from adafruit_esp32spi import PWMOut +# RED_LED = PWMOut.PWMOut(esp, 26) +# GREEN_LED = PWMOut.PWMOut(esp, 27) +# BLUE_LED = PWMOut.PWMOut(esp, 25) +# status_light = adafruit_rgbled.RGBLED(RED_LED, BLUE_LED, GREEN_LED) +wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) + +print("Connecting to WiFi...") + +wifi.connect() + +print("Connected to WiFi!") + +print("Getting the time...") + +ntp = NTP(esp) +# Wait for a valid time to be received +while not ntp.valid_time: + time.sleep(5) + ntp.set_time() + +print("Time:", str(time.time())) + +# You will need an Azure subscription to create an Azure IoT Hub resource +# +# If you don't have an Azure subscription: +# +# If you are a student, head to https://aka.ms/FreeStudentAzure and sign up, validating with your +# student email address. This will give you $100 of Azure credit and free tiers of a load of +# service, renewable each year you are a student +# +# If you are not a student, head to https://aka.ms/FreeAz and sign up to get $200 of credit for 30 +# days, as well as free tiers of a load of services +# +# Create an Azure IoT Hub and an IoT device in the Azure portal here: https://aka.ms/AzurePortalHome. +# Instructions to create an IoT Hub and device are here: https://aka.ms/CreateIoTHub +# +# The free tier of IoT Hub allows up to 8,000 messages a day, so try not to send messages too often +# if you are using the free tier +# +# Once you have a hub and a device, copy the device primary connection string. +# Add it to the secrets.py file in an entry called device_connection_string +# +# To us twins, you will need either a free or standard tier IoT Hub. Basic tier doesn't +# support twins +# +# The adafruit-circuitpython-azureiot library depends on the following libraries: +# +# From the Adafruit CircuitPython Bundle (https://github.com/adafruit/Adafruit_CircuitPython_Bundle): +# * adafruit-circuitpython-minimqtt +# +# From the CircuitPython Community LIbrary and Driver Bundle (https://github.com/adafruit/CircuitPython_Community_Bundle): +# * circuitpython-hmac +# * circuitpython-base64 +# * circuitpython-parse +from adafruit_azureiot import IoTHubDevice + +# Create an IoT Hub device client and connect +device = IoTHubDevice(socket, esp, secrets["device_connection_string"]) + +# Subscribe to device twin desired property updates +# To see these changes, update the desired properties for the device either in code +# or in the Azure portal by selecting the device in the IoT Hub blade, selecting +# Device Twin then adding or amending an entry in the 'desired' section +def device_twin_desired_updated(desired_property_name: str, desired_property_value, desired_version: int): + print("Property", desired_property_name, "updated to", str(desired_property_value), "version", desired_version) + + +# Subscribe to the device twin desired property updated event +device.on_device_twin_desired_updated = device_twin_desired_updated + +print("Connecting to Azure IoT Hub...") + +# Connect to IoT Central +device.connect() + +print("Connected to Azure IoT Hub!") + +message_counter = 60 + +while True: + try: + if message_counter >= 60: + # Send a reported property twin update every minute + # You can see these in the portal by selecting the device in the IoT Hub blade, selecting + # Device Twin then looking for the updates in the 'reported' section + patch = {"Temperature": random.randint(0, 50)} + device.update_twin(patch) + message_counter = 0 + else: + message_counter = message_counter + 1 + + # Poll every second for messages from the cloud + device.loop() + except (ValueError, RuntimeError) as e: + print("Connection error, reconnecting\n", str(e)) + # If we lose connectivity, reset the wifi and reconnect + wifi.reset() + wifi.connect() + device.reconnect() + continue + + time.sleep(1) diff --git a/iot-central-connect-button.png b/iot-central-connect-button.png new file mode 100644 index 0000000..006fba3 Binary files /dev/null and b/iot-central-connect-button.png differ diff --git a/iot-central-connect-dialog.png b/iot-central-connect-dialog.png new file mode 100644 index 0000000..f63a264 Binary files /dev/null and b/iot-central-connect-dialog.png differ diff --git a/iot-hub-device-keys.png b/iot-hub-device-keys.png new file mode 100644 index 0000000..c081d0d Binary files /dev/null and b/iot-hub-device-keys.png differ diff --git a/iot-hub-device.png b/iot-hub-device.png new file mode 100644 index 0000000..e04fd0b Binary files /dev/null and b/iot-hub-device.png differ diff --git a/requirements.txt b/requirements.txt index bdc2de0..711349f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ Adafruit-Blinka -Adafruit_CircuitPython_ESP32SPI \ No newline at end of file +Adafruit-CircuitPython-miniMQTT +CircuitPython-HMAC +CircuitPython-Base64 +CircuitPython-Parse \ No newline at end of file diff --git a/setup.py b/setup.py index 9505c57..8cc5055 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,14 @@ # Author details author="Adafruit Industries", author_email="circuitpython@adafruit.com", - install_requires=["Adafruit-Blinka", "Adafruit_CircuitPython_ESP32SPI"], + install_requires=[ + "Adafruit-Blinka", + "Adafruit_CircuitPython_ESP32SPI", + "Adafruit-CircuitPython-miniMQTT", + "CircuitPython-HMAC", + "CircuitPython-Base64", + "CircuitPython-Parse", + ], # Choose your license license="MIT", # See https://pypi.python.org/pypi?%3Aaction=list_classifiers @@ -44,7 +51,7 @@ "Programming Language :: Python :: 3.5", ], # What does your project relate to? - keywords="adafruit blinka circuitpython micropython azureiot azure, iot, device, services", + keywords="adafruit blinka circuitpython micropython azureiot azure iot device services, iothub, iotcentral", # You can just specify the packages manually here if your project is # simple. Or you can use find_packages(). # TODO: IF LIBRARY FILES ARE A PACKAGE FOLDER,