/ 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)