scripts/mycroft/api/__init__.py
Go to the documentation of this file.
1 # Copyright 2017 Mycroft AI Inc.
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14 #
15 from copy import copy
16 
17 import json
18 import requests
19 from requests import HTTPError, RequestException
20 import os
21 import time
22 from threading import Lock
23 
24 from mycroft.configuration import Configuration
25 from mycroft.configuration.config import DEFAULT_CONFIG, SYSTEM_CONFIG, \
26  USER_CONFIG
27 from mycroft.identity import IdentityManager, identity_lock
28 from mycroft.version import VersionManager
29 from mycroft.util import get_arch, connected, LOG
30 
31 
32 _paired_cache = False
33 
34 
35 class BackendDown(RequestException):
36  pass
37 
38 
39 class InternetDown(RequestException):
40  pass
41 
42 
43 class Api:
44  """ Generic class to wrap web APIs """
45  params_to_etag = {}
46  etag_to_response = {}
47 
48  def __init__(self, path):
49  self.path = path
50 
51  # Load the config, skipping the REMOTE_CONFIG since we are
52  # getting the info needed to get to it!
53  config = Configuration.get([DEFAULT_CONFIG,
54  SYSTEM_CONFIG,
55  USER_CONFIG],
56  cache=False)
57  config_server = config.get("server")
58  self.url = config_server.get("url")
59  self.version = config_server.get("version")
60  self.identity = IdentityManager.get()
61 
62  def request(self, params):
63  self.check_token()
64  self.build_path(params)
65  self.old_params = copy(params)
66  return self.send(params)
67 
68  def check_token(self):
69  # If the identity hasn't been loaded, load it
70  if not self.identity.has_refresh():
71  self.identity = IdentityManager.load()
72  # If refresh is needed perform a refresh
73  if self.identity.refresh and self.identity.is_expired():
74  self.identity = IdentityManager.load()
75  # if no one else has updated the token refresh it
76  if self.identity.is_expired():
77  self.refresh_token()
78 
79  def refresh_token(self):
80  LOG.debug('Refreshing token')
81  if identity_lock.acquire(blocking=False):
82  try:
83  data = self.send({
84  "path": "auth/token",
85  "headers": {
86  "Authorization": "Bearer " + self.identity.refresh,
87  "Device": self.identity.uuid
88  }
89  })
90  IdentityManager.save(data, lock=False)
91  LOG.debug('Saved credentials')
92  except HTTPError as e:
93  if e.response.status_code == 401:
94  LOG.error('Could not refresh token, invalid refresh code.')
95  else:
96  raise
97 
98  finally:
99  identity_lock.release()
100  else: # Someone is updating the identity wait for release
101  with identity_lock:
102  LOG.debug('Refresh is already in progress, waiting until done')
103  time.sleep(1.2)
104  os.sync()
105  self.identity = IdentityManager.load(lock=False)
106  LOG.debug('new credentials loaded')
107 
108  def send(self, params, no_refresh=False):
109  """ Send request to mycroft backend.
110  The method handles Etags and will return a cached response value
111  if nothing has changed on the remote.
112 
113  Arguments:
114  params (dict): request parameters
115  no_refresh (bool): optional parameter to disable refreshs of token
116 
117  Returns:
118  Requests response object.
119  """
120  query_data = frozenset(params.get('query', {}).items())
121  params_key = (params.get('path'), query_data)
122  etag = self.params_to_etag.get(params_key)
123 
124  method = params.get("method", "GET")
125  headers = self.build_headers(params)
126  data = self.build_data(params)
127  json_body = self.build_json(params)
128  query = self.build_query(params)
129  url = self.build_url(params)
130 
131  # For an introduction to the Etag feature check out:
132  # https://en.wikipedia.org/wiki/HTTP_ETag
133  if etag:
134  headers['If-None-Match'] = etag
135 
136  response = requests.request(
137  method, url, headers=headers, params=query,
138  data=data, json=json_body, timeout=(3.05, 15)
139  )
140  if response.status_code == 304:
141  # Etag matched, use response previously cached
142  response = self.etag_to_response[etag]
143  elif 'ETag' in response.headers:
144  etag = response.headers['ETag'].strip('"')
145  # Cache response for future lookup when we receive a 304
146  self.params_to_etag[params_key] = etag
147  self.etag_to_response[etag] = response
148 
149  return self.get_response(response, no_refresh)
150 
151  def get_response(self, response, no_refresh=False):
152  """ Parse response and extract data from response.
153 
154  Will try to refresh the access token if it's expired.
155 
156  Arguments:
157  response (requests Response object): Response to parse
158  no_refresh (bool): Disable refreshing of the token
159  Returns:
160  data fetched from server
161  """
162  data = self.get_data(response)
163 
164  if 200 <= response.status_code < 300:
165  return data
166  elif (not no_refresh and response.status_code == 401 and not
167  response.url.endswith("auth/token") and
168  self.identity.is_expired()):
169  self.refresh_token()
170  return self.send(self.old_params, no_refresh=True)
171  raise HTTPError(data, response=response)
172 
173  def get_data(self, response):
174  try:
175  return response.json()
176  except Exception:
177  return response.text
178 
179  def build_headers(self, params):
180  headers = params.get("headers", {})
181  self.add_content_type(headers)
182  self.add_authorization(headers)
183  params["headers"] = headers
184  return headers
185 
186  def add_content_type(self, headers):
187  if not headers.__contains__("Content-Type"):
188  headers["Content-Type"] = "application/json"
189 
190  def add_authorization(self, headers):
191  if not headers.__contains__("Authorization"):
192  headers["Authorization"] = "Bearer " + self.identity.access
193 
194  def build_data(self, params):
195  return params.get("data")
196 
197  def build_json(self, params):
198  json = params.get("json")
199  if json and params["headers"]["Content-Type"] == "application/json":
200  for k, v in json.items():
201  if v == "":
202  json[k] = None
203  params["json"] = json
204  return json
205 
206  def build_query(self, params):
207  return params.get("query")
208 
209  def build_path(self, params):
210  path = params.get("path", "")
211  params["path"] = self.path + path
212  return params["path"]
213 
214  def build_url(self, params):
215  path = params.get("path", "")
216  version = params.get("version", self.version)
217  return self.url + "/" + version + "/" + path
218 
219 
220 class DeviceApi(Api):
221  """ Web API wrapper for obtaining device-level information """
222  _skill_settings_lock = Lock()
223  _skill_settings = None
224 
225  def __init__(self):
226  super(DeviceApi, self).__init__("device")
227 
228  def get_code(self, state):
229  IdentityManager.update()
230  return self.request({
231  "path": "/code?state=" + state
232  })
233 
234  def activate(self, state, token):
235  version = VersionManager.get()
236  platform = "unknown"
237  platform_build = ""
238 
239  # load just the local configs to get platform info
240  config = Configuration.get([SYSTEM_CONFIG,
241  USER_CONFIG],
242  cache=False)
243  if "enclosure" in config:
244  platform = config.get("enclosure").get("platform", "unknown")
245  platform_build = config.get("enclosure").get("platform_build", "")
246 
247  return self.request({
248  "method": "POST",
249  "path": "/activate",
250  "json": {"state": state,
251  "token": token,
252  "coreVersion": version.get("coreVersion"),
253  "platform": platform,
254  "platform_build": platform_build,
255  "enclosureVersion": version.get("enclosureVersion")}
256  })
257 
258  def update_version(self):
259  version = VersionManager.get()
260  platform = "unknown"
261  platform_build = ""
262 
263  # load just the local configs to get platform info
264  config = Configuration.get([SYSTEM_CONFIG,
265  USER_CONFIG],
266  cache=False)
267  if "enclosure" in config:
268  platform = config.get("enclosure").get("platform", "unknown")
269  platform_build = config.get("enclosure").get("platform_build", "")
270 
271  return self.request({
272  "method": "PATCH",
273  "path": "/" + self.identity.uuid,
274  "json": {"coreVersion": version.get("coreVersion"),
275  "platform": platform,
276  "platform_build": platform_build,
277  "enclosureVersion": version.get("enclosureVersion")}
278  })
279 
280  def send_email(self, title, body, sender):
281  return self.request({
282  "method": "PUT",
283  "path": "/" + self.identity.uuid + "/message",
284  "json": {"title": title, "body": body, "sender": sender}
285  })
286 
287  def report_metric(self, name, data):
288  return self.request({
289  "method": "POST",
290  "path": "/" + self.identity.uuid + "/metric/" + name,
291  "json": data
292  })
293 
294  def get(self):
295  """ Retrieve all device information from the web backend """
296  return self.request({
297  "path": "/" + self.identity.uuid
298  })
299 
300  def get_settings(self):
301  """ Retrieve device settings information from the web backend
302 
303  Returns:
304  str: JSON string with user configuration information.
305  """
306  return self.request({
307  "path": "/" + self.identity.uuid + "/setting"
308  })
309 
310  def get_location(self):
311  """ Retrieve device location information from the web backend
312 
313  Returns:
314  str: JSON string with user location.
315  """
316  return self.request({
317  "path": "/" + self.identity.uuid + "/location"
318  })
319 
320  def get_subscription(self):
321  """
322  Get information about type of subscrition this unit is connected
323  to.
324 
325  Returns: dictionary with subscription information
326  """
327  return self.request({
328  'path': '/' + self.identity.uuid + '/subscription'})
329 
330  @property
331  def is_subscriber(self):
332  """
333  status of subscription. True if device is connected to a paying
334  subscriber.
335  """
336  try:
337  return self.get_subscription().get('@type') != 'free'
338  except Exception:
339  # If can't retrieve, assume not paired and not a subscriber yet
340  return False
341 
342  def get_subscriber_voice_url(self, voice=None):
343  self.check_token()
344  archs = {'x86_64': 'x86_64', 'armv7l': 'arm', 'aarch64': 'arm'}
345  arch = archs.get(get_arch())
346  if arch:
347  path = '/' + self.identity.uuid + '/voice?arch=' + arch
348  return self.request({'path': path})['link']
349 
350  def get_oauth_token(self, dev_cred):
351  """
352  Get Oauth token for dev_credential dev_cred.
353 
354  Argument:
355  dev_cred: development credentials identifier
356 
357  Returns:
358  json string containing token and additional information
359  """
360  return self.request({
361  "method": "GET",
362  "path": "/" + self.identity.uuid + "/token/" + str(dev_cred)
363  })
364 
366  """ Fetch all skill settings. """
367  with DeviceApi._skill_settings_lock:
368  if (DeviceApi._skill_settings is None or
369  time.monotonic() > DeviceApi._skill_settings[0] + 30):
370  DeviceApi._skill_settings = (
371  time.monotonic(),
372  self.request({
373  "method": "GET",
374  "path": "/" + self.identity.uuid + "/skill"
375  })
376  )
377  return DeviceApi._skill_settings[1]
378 
379  def upload_skill_metadata(self, settings_meta):
380  """ Upload skill metadata.
381 
382  Arguments:
383  settings_meta (dict): settings_meta typecasted to suite the backend
384  """
385  return self.request({
386  "method": "PUT",
387  "path": "/" + self.identity.uuid + "/skill",
388  "json": settings_meta
389  })
390 
391  def delete_skill_metadata(self, uuid):
392  """ Delete the current skill metadata from backend
393 
394  TODO: Real implementation when method exists on backend
395  Args:
396  uuid (str): unique id of the skill
397  """
398  try:
399  LOG.debug("Deleting remote metadata for {}".format(skill_gid))
400  self.request({
401  "method": "DELETE",
402  "path": ("/" + self.identity.uuid + "/skill" +
403  "/{}".format(skill_gid))
404  })
405  except Exception as e:
406  LOG.error("{} cannot delete metadata because this".format(e))
407 
408  def upload_skills_data(self, data):
409  """ Upload skills.json file. This file contains a manifest of installed
410  and failed installations for use with the Marketplace.
411 
412  Arguments:
413  data: dictionary with skills data from msm
414  """
415  if not isinstance(data, dict):
416  raise ValueError('data must be of type dict')
417 
418  # Strip the skills.json down to the bare essentials
419  to_send = {}
420  if 'blacklist' in data:
421  to_send['blacklist'] = data['blacklist']
422  else:
423  LOG.warning('skills manifest lacks blacklist entry')
424  to_send['blacklist'] = []
425 
426  # Make sure skills doesn't contain duplicates (keep only last)
427  if 'skills' in data:
428  skills = {s['name']: s for s in data['skills']}
429  to_send['skills'] = [skills[key] for key in skills]
430  else:
431  LOG.warning('skills manifest lacks skills entry')
432  to_send['skills'] = []
433 
434  # Finalize skill_gid with uuid if needed
435  for s in to_send['skills']:
436  s['skill_gid'] = s.get('skill_gid', '').replace(
437  '@|', '@{}|'.format(self.identity.uuid))
438 
439  self.request({
440  "method": "PUT",
441  "path": "/" + self.identity.uuid + "/skillJson",
442  "json": to_send
443  })
444 
445 
446 class STTApi(Api):
447  """ Web API wrapper for performing Speech to Text (STT) """
448 
449  def __init__(self, path):
450  super(STTApi, self).__init__(path)
451 
452  def stt(self, audio, language, limit):
453  """ Web API wrapper for performing Speech to Text (STT)
454 
455  Args:
456  audio (bytes): The recorded audio, as in a FLAC file
457  language (str): A BCP-47 language code, e.g. "en-US"
458  limit (int): Maximum minutes to transcribe(?)
459 
460  Returns:
461  str: JSON structure with transcription results
462  """
463 
464  return self.request({
465  "method": "POST",
466  "headers": {"Content-Type": "audio/x-flac"},
467  "query": {"lang": language, "limit": limit},
468  "data": audio
469  })
470 
471 
473  """ Determine if this device has ever been paired with a web backend
474 
475  Returns:
476  bool: True if ever paired with backend (not factory reset)
477  """
478  # This forces a load from the identity file in case the pairing state
479  # has recently changed
480  id = IdentityManager.load()
481  return id.uuid is not None and id.uuid != ""
482 
483 
484 def is_paired(ignore_errors=True):
485  """ Determine if this device is actively paired with a web backend
486 
487  Determines if the installation of Mycroft has been paired by the user
488  with the backend system, and if that pairing is still active.
489 
490  Returns:
491  bool: True if paired with backend
492  """
493  global _paired_cache
494  if _paired_cache:
495  # NOTE: This assumes once paired, the unit remains paired. So
496  # un-pairing must restart the system (or clear this value).
497  # The Mark 1 does perform a restart on RESET.
498  return True
499 
500  try:
501  api = DeviceApi()
502  device = api.get()
503  _paired_cache = api.identity.uuid is not None and \
504  api.identity.uuid != ""
505  return _paired_cache
506  except HTTPError as e:
507  if e.response.status_code == 401:
508  return False
509  except Exception as e:
510  LOG.warning('Could not get device info: ' + repr(e))
511  if ignore_errors:
512  return False
513  if connected():
514  raise BackendDown
515  raise InternetDown
def stt(self, audio, language, limit)
def build_headers(self, params)
def get_subscriber_voice_url(self, voice=None)
def is_paired(ignore_errors=True)
def add_content_type(self, headers)
def get_data(self, response)
def send_email(self, title, body, sender)
def get_response(self, response, no_refresh=False)
def activate(self, state, token)
def report_metric(self, name, data)
def add_authorization(self, headers)
def send(self, params, no_refresh=False)
def upload_skill_metadata(self, settings_meta)


mycroft_ros
Author(s):
autogenerated on Mon Apr 26 2021 02:35:40