/ adafruit_azureiot / device_registration.py
device_registration.py
1 # The MIT License (MIT) 2 # 3 # Copyright (c) 2020 Jim Bennett 4 # 5 # Permission is hereby granted, free of charge, to any person obtaining a copy 6 # of this software and associated documentation files (the "Software"), to deal 7 # in the Software without restriction, including without limitation the rights 8 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 # copies of the Software, and to permit persons to whom the Software is 10 # furnished to do so, subject to the following conditions: 11 # 12 # The above copyright notice and this permission notice shall be included in 13 # all copies or substantial portions of the Software. 14 # 15 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 # THE SOFTWARE. 22 """ 23 `device_registration` 24 ===================== 25 26 Handles registration of IoT Central devices, and gets the hostname to use when connecting 27 to IoT Central over MQTT 28 29 * Author(s): Jim Bennett, Elena Horton 30 """ 31 32 import gc 33 import json 34 import time 35 import adafruit_requests as requests 36 import adafruit_logging as logging 37 from adafruit_logging import Logger 38 from . import constants 39 from .quote import quote 40 from .keys import compute_derived_symmetric_key 41 42 # Azure HTTP error status codes 43 AZURE_HTTP_ERROR_CODES = [400, 401, 404, 403, 412, 429, 500] 44 45 46 class DeviceRegistrationError(Exception): 47 """ 48 An error from the device registration 49 """ 50 51 def __init__(self, message): 52 super(DeviceRegistrationError, self).__init__(message) 53 self.message = message 54 55 56 class DeviceRegistration: 57 """ 58 Handles registration of IoT Central devices, and gets the hostname to use when connecting 59 to IoT Central over MQTT 60 """ 61 62 _loop_interval = 2 63 64 @staticmethod 65 def _parse_http_status(status_code: int, status_reason: str) -> None: 66 """Parses status code, throws error based on Azure IoT Common Error Codes. 67 :param int status_code: HTTP status code. 68 :param str status_reason: Description of HTTP status. 69 :raises DeviceRegistrationError: if the status code is an error code 70 """ 71 for error in AZURE_HTTP_ERROR_CODES: 72 if error == status_code: 73 raise DeviceRegistrationError("Error {0}: {1}".format(status_code, status_reason)) 74 75 def __init__(self, socket, id_scope: str, device_id: str, key: str, logger: Logger = None): 76 """Creates an instance of the device registration service 77 :param socket: The network socket 78 :param str id_scope: The ID scope of the device to register 79 :param str device_id: The device ID of the device to register 80 :param str key: The primary or secondary key of the device to register 81 :param adafruit_logging.Logger logger: The logger to use to log messages 82 """ 83 self._id_scope = id_scope 84 self._device_id = device_id 85 self._key = key 86 self._logger = logger if logger is not None else logging.getLogger("log") 87 88 requests.set_socket(socket) 89 90 def _loop_assign(self, operation_id, headers) -> str: 91 uri = "https://%s/%s/registrations/%s/operations/%s?api-version=%s" % ( 92 constants.DPS_END_POINT, 93 self._id_scope, 94 self._device_id, 95 operation_id, 96 constants.DPS_API_VERSION, 97 ) 98 self._logger.info("- iotc :: _loop_assign :: " + uri) 99 100 response = self._run_get_request_with_retry(uri, headers) 101 102 try: 103 data = response.json() 104 except ValueError as error: 105 err = "ERROR: " + str(error) + " => " + str(response) 106 self._logger.error(err) 107 raise DeviceRegistrationError(err) 108 109 loop_try = 0 110 111 if data is not None and "status" in data: 112 if data["status"] == "assigning": 113 time.sleep(self._loop_interval) 114 if loop_try < 20: 115 loop_try = loop_try + 1 116 return self._loop_assign(operation_id, headers) 117 118 err = "ERROR: Unable to provision the device." 119 self._logger.error(err) 120 raise DeviceRegistrationError(err) 121 122 if data["status"] == "assigned": 123 state = data["registrationState"] 124 return state["assignedHub"] 125 else: 126 data = str(data) 127 128 err = "DPS L => " + str(data) 129 self._logger.error(err) 130 raise DeviceRegistrationError(err) 131 132 def _run_put_request_with_retry(self, url, body, headers): 133 retry = 0 134 response = None 135 136 while True: 137 gc.collect() 138 try: 139 self._logger.debug("Trying to send...") 140 response = requests.put(url, json=body, headers=headers) 141 self._logger.debug("Sent!") 142 break 143 except RuntimeError as runtime_error: 144 self._logger.info("Could not send data, retrying after 0.5 seconds: " + str(runtime_error)) 145 retry = retry + 1 146 147 if retry >= 10: 148 self._logger.error("Failed to send data") 149 raise 150 151 time.sleep(0.5) 152 continue 153 154 gc.collect() 155 return response 156 157 def _run_get_request_with_retry(self, url, headers): 158 retry = 0 159 response = None 160 161 while True: 162 gc.collect() 163 try: 164 self._logger.debug("Trying to send...") 165 response = requests.get(url, headers=headers) 166 self._logger.debug("Sent!") 167 break 168 except RuntimeError as runtime_error: 169 self._logger.info("Could not send data, retrying after 0.5 seconds: " + str(runtime_error)) 170 retry = retry + 1 171 172 if retry >= 10: 173 self._logger.error("Failed to send data") 174 raise 175 176 time.sleep(0.5) 177 continue 178 179 gc.collect() 180 return response 181 182 def register_device(self, expiry: int) -> str: 183 """ 184 Registers the device with the IoT Central device registration service. 185 Returns the hostname of the IoT hub to use over MQTT 186 :param int expiry: The expiry time for the registration 187 :returns: The underlying IoT Hub that this device should connect to 188 :rtype: str 189 :raises DeviceRegistrationError: if the device cannot be registered successfully 190 :raises RuntimeError: if the internet connection is not responding or is unable to connect 191 """ 192 # pylint: disable=C0103 193 sr = self._id_scope + "%2Fregistrations%2F" + self._device_id 194 sig_no_encode = compute_derived_symmetric_key(self._key, sr + "\n" + str(expiry)) 195 sig_encoded = quote(sig_no_encode, "~()*!.'") 196 auth_string = "SharedAccessSignature sr=" + sr + "&sig=" + sig_encoded + "&se=" + str(expiry) + "&skn=registration" 197 198 headers = { 199 "content-type": "application/json; charset=utf-8", 200 "user-agent": "iot-central-client/1.0", 201 "Accept": "*/*", 202 } 203 204 if auth_string is not None: 205 headers["authorization"] = auth_string 206 207 body = {"registrationId": self._device_id} 208 209 uri = "https://%s/%s/registrations/%s/register?api-version=%s" % ( 210 constants.DPS_END_POINT, 211 self._id_scope, 212 self._device_id, 213 constants.DPS_API_VERSION, 214 ) 215 216 self._logger.info("Connecting...") 217 self._logger.info("URL: " + uri) 218 self._logger.info("body: " + json.dumps(body)) 219 220 response = self._run_put_request_with_retry(uri, body, headers) 221 222 data = None 223 try: 224 data = response.json() 225 except ValueError as error: 226 err = "ERROR: non JSON is received from " + constants.DPS_END_POINT + " => " + str(response) + " .. message : " + str(error) 227 self._logger.error(err) 228 raise DeviceRegistrationError(err) 229 230 if "errorCode" in data: 231 err = "DPS => " + str(data) 232 self._logger.error(err) 233 raise DeviceRegistrationError(err) 234 235 time.sleep(1) 236 return self._loop_assign(data["operationId"], headers)