From 6b0ccac643f5f009db00437d98e3a8593212767b Mon Sep 17 00:00:00 2001 From: jarbasai Date: Fri, 30 Sep 2022 17:48:47 +0100 Subject: [PATCH] explicit backend_type in tests install optional deps for workflows fix tests leave ovos and neon backends to a follow up PR oauth api for offline backend - companion PR to https://github.com/OpenVoiceOS/ovos-personal-backend/pull/36 add oauth, companion PR https://github.com/OpenVoiceOS/ovos-personal-backend/pull/36 fix oauth endpoint url fix device/admin endpoint urls split oauth api from device api, only selene implements it neon_utils dependency only, not neon_api_proxy better support both ww upload device endpoints fix default upload url value make func signatures match upload_url for wakeword param upload_url for wakeword param split metrics api from device api split dataset api from device api split email api from device api offline backend email sending support requirements.txt add offline STT endpoint fix token refresh fix ovos weather missing pairing utils stt endpoints continue adding endpoints cleanup code split backends into their own modules for cleanliness rename again to ovos-backend-client + improve personal backend UI compat cleanup docstrs and rename stuff add database helper + compat with device db personal backend UI wakeword metadata compat - UI tagger comparison table in readme no_backend support metrics / ww offline backend ui compat offline backend - skill settings handling device meta json_database backwards compat admin/device api neon+ovos optional requirements.txt initial support for offline device api admin_api support for offline backend handle offline detect better handle missing timezone data no_backend geolocation add neon backend rename + OVOS "Fake" Backend support api registry concept signal breaking change in versioning --- .github/workflows/build_tests.yml | 2 +- .github/workflows/notify_matrix.yml | 2 +- .github/workflows/publish_alpha.yml | 2 +- .github/workflows/unit_tests.yml | 8 +- README.md | 44 +- ovos_backend_client/__init__.py | 5 + ovos_backend_client/api.py | 543 +++++++++++++++ ovos_backend_client/backends/__init__.py | 82 +++ ovos_backend_client/backends/base.py | 455 +++++++++++++ ovos_backend_client/backends/offline.py | 509 ++++++++++++++ ovos_backend_client/backends/personal.py | 422 ++++++++++++ ovos_backend_client/backends/selene.py | 35 + {selene_api => ovos_backend_client}/cloud.py | 2 +- {selene_api => ovos_backend_client}/config.py | 7 +- ovos_backend_client/database.py | 73 ++ .../exceptions.py | 0 .../identity.py | 9 +- .../pairing.py | 40 +- .../settings.py | 6 +- .../version.py | 4 +- requirements.txt | 1 - requirements/offline.txt | 3 + requirements/requirements.txt | 2 + scripts/bump_alpha.py | 2 +- scripts/bump_build.py | 2 +- scripts/bump_major.py | 2 +- scripts/bump_minor.py | 2 +- scripts/remove_alpha.py | 2 +- selene_api/__init__.py | 5 - selene_api/api.py | 637 ------------------ setup.py | 15 +- test/license_tests.py | 2 +- test/test.wav | Bin 0 -> 104526 bytes test/unittests/test_selene_api.py | 156 +++-- 34 files changed, 2317 insertions(+), 764 deletions(-) create mode 100644 ovos_backend_client/__init__.py create mode 100644 ovos_backend_client/api.py create mode 100644 ovos_backend_client/backends/__init__.py create mode 100644 ovos_backend_client/backends/base.py create mode 100644 ovos_backend_client/backends/offline.py create mode 100644 ovos_backend_client/backends/personal.py create mode 100644 ovos_backend_client/backends/selene.py rename {selene_api => ovos_backend_client}/cloud.py (98%) rename {selene_api => ovos_backend_client}/config.py (97%) create mode 100644 ovos_backend_client/database.py rename {selene_api => ovos_backend_client}/exceptions.py (100%) rename {selene_api => ovos_backend_client}/identity.py (93%) rename {selene_api => ovos_backend_client}/pairing.py (92%) rename {selene_api => ovos_backend_client}/settings.py (99%) rename {selene_api => ovos_backend_client}/version.py (79%) delete mode 100644 requirements.txt create mode 100644 requirements/offline.txt create mode 100644 requirements/requirements.txt delete mode 100644 selene_api/__init__.py delete mode 100644 selene_api/api.py create mode 100644 test/test.wav diff --git a/.github/workflows/build_tests.yml b/.github/workflows/build_tests.yml index aed37e0..7816511 100644 --- a/.github/workflows/build_tests.yml +++ b/.github/workflows/build_tests.yml @@ -7,7 +7,7 @@ on: branches: - dev paths-ignore: - - 'selene_api/version.py' + - 'ovos_backend_client/version.py' - 'test/**' - 'examples/**' - '.github/**' diff --git a/.github/workflows/notify_matrix.yml b/.github/workflows/notify_matrix.yml index 1cc4be5..47fc073 100644 --- a/.github/workflows/notify_matrix.yml +++ b/.github/workflows/notify_matrix.yml @@ -20,4 +20,4 @@ jobs: token: ${{ secrets.MATRIX_TOKEN }} channel: '!WjxEKjjINpyBRPFgxl:krbel.duckdns.org' message: | - new selene_api PR merged! https://github.com/OpenVoiceOS/selene_api/pull/${{ github.event.number }} + new ovos_backend_client PR merged! https://github.com/OpenVoiceOS/ovos-backend-client/pull/${{ github.event.number }} diff --git a/.github/workflows/publish_alpha.yml b/.github/workflows/publish_alpha.yml index 327016a..01c7019 100644 --- a/.github/workflows/publish_alpha.yml +++ b/.github/workflows/publish_alpha.yml @@ -6,7 +6,7 @@ on: branches: - dev paths-ignore: - - 'selene_api/version.py' + - 'ovos_backend_client/version.py' - 'test/**' - 'examples/**' - '.github/**' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index def5142..324eedd 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -6,7 +6,7 @@ on: branches: - dev paths-ignore: - - 'selene_api/version.py' + - 'ovos_backend_client/version.py' - 'requirements/**' - 'examples/**' - '.github/**' @@ -20,7 +20,7 @@ on: branches: - master paths-ignore: - - 'selene_api/version.py' + - 'ovos_backend_client/version.py' - 'requirements/**' - 'examples/**' - '.github/**' @@ -52,13 +52,13 @@ jobs: python -m pip install build wheel - name: Install core repo run: | - pip install . + pip install .[offline] - name: Install test dependencies run: | pip install pytest pytest-timeout pytest-cov - name: Run unittests run: | - pytest --cov=selene_api --cov-report xml test/unittests + pytest --cov=ovos_backend_client --cov-report xml test/unittests # NOTE: additional pytest invocations should also add the --cov-append flag # or they will overwrite previous invocations' coverage reports # (for an example, see OVOS Skill Manager's workflow) diff --git a/README.md b/README.md index f25ca3a..3f26801 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,31 @@ -# Selene Api +# OVOS Backend Api -Unofficial python api for interaction with https://api.mycroft.ai , also compatible -with [ovos-local-backend](https://github.com/OpenVoiceOS/OVOS-local-backend) +Python client library for interaction with several supported backends under a single unified interface + +- Personal backend - [self hosted](https://github.com/OpenVoiceOS/OVOS-local-backend) +- Selene - https://api.mycroft.ai +- Offline - support for setting your own api keys and query services directly + +## Backend Overview + +| API | Offline | Personal | Selene | +|-----------|---------|----------|--------| +| Admin | yes [1] | yes | no | +| Device | yes [2] | yes | yes | +| Metrics | yes [2] | yes | yes | +| Dataset | yes [2] | yes | yes | +| OAuth | yes [2] | yes | yes | +| Wolfram | yes [3] | yes | yes | +| Geolocate | yes | yes | yes | +| STT | yes [3] | yes | yes | +| Weather | yes [3] | yes | yes | +| Email | yes [3] | yes | yes | + + + [1] will update user level mycroft.conf + [2] shared json database with personal backend for UI compat + [3] needs additional configuration (eg. credentials) -Will only work if running in a device paired with mycroft, a valid identity2.json must exist ## STT @@ -12,7 +34,7 @@ a companion stt plugin is available - [ovos-stt-plugin-selene](https://github.co ## Geolocation ```python -from selene_api.api import GeolocationApi +from ovos_backend_client.api import GeolocationApi geo = GeolocationApi() data = geo.get_geolocation("Lisbon Portugal") @@ -26,7 +48,7 @@ data = geo.get_geolocation("Lisbon Portugal") ## OpenWeatherMap Proxy ```python -from selene_api.api import OpenWeatherMapApi +from ovos_backend_client.api import OpenWeatherMapApi owm = OpenWeatherMapApi() data = owm.get_weather() @@ -36,7 +58,7 @@ data = owm.get_weather() ## Wolfram Alpha proxy ```python -from selene_api.api import WolframAlphaApi +from ovos_backend_client.api import WolframAlphaApi wolf = WolframAlphaApi() answer = wolf.spoken("what is the speed of light") @@ -51,7 +73,7 @@ data = wolf.full_results("2+2") To interact with skill settings on selene ```python -from selene_api.settings import RemoteSkillSettings +from ovos_backend_client.settings import RemoteSkillSettings # in ovos-core skill_id is deterministic and safe s = RemoteSkillSettings("skill.author") @@ -78,7 +100,7 @@ s.upload() by hijacking skill settings we allows storing arbitrary data in selene and use it across devices and skills ```python -from selene_api.cloud import SeleneCloud +from ovos_backend_client.cloud import SeleneCloud cloud = SeleneCloud() cloud.add_entry("test", {"secret": "NOT ENCRYPTED MAN"}) @@ -88,7 +110,7 @@ data = cloud.get_entry("test") an encrypted version is also supported if you dont trust selene! ```python -from selene_api.cloud import SecretSeleneCloud +from ovos_backend_client.cloud import SecretSeleneCloud k = "D8fmXEP5VqzVw2HE" # you need this to read back the data cloud = SecretSeleneCloud(k) @@ -104,7 +126,7 @@ since local backend does not provide a web ui a [admin api](https://github.com/O can be used to manage your devices ```python -from selene_api.api import AdminApi +from ovos_backend_client.api import AdminApi admin = AdminApi("secret_admin_key") uuid = "..." # check identity2.json in the device you want to manage diff --git a/ovos_backend_client/__init__.py b/ovos_backend_client/__init__.py new file mode 100644 index 0000000..dd0b58e --- /dev/null +++ b/ovos_backend_client/__init__.py @@ -0,0 +1,5 @@ +from ovos_backend_client.api import DeviceApi, WolframAlphaApi, OpenWeatherMapApi, STTApi, GeolocationApi +from ovos_backend_client.cloud import SeleneCloud, SecretSeleneCloud +from ovos_backend_client.config import RemoteConfigManager +from ovos_backend_client.pairing import is_paired, has_been_paired, check_remote_pairing, PairingManager +from ovos_backend_client.settings import RemoteSkillSettings diff --git a/ovos_backend_client/api.py b/ovos_backend_client/api.py new file mode 100644 index 0000000..150a176 --- /dev/null +++ b/ovos_backend_client/api.py @@ -0,0 +1,543 @@ +from ovos_utils import timed_lru_cache +from ovos_utils.log import LOG + +from ovos_backend_client.backends import OfflineBackend, \ + SeleneBackend, PersonalBackend, BackendType, get_backend_config, API_REGISTRY + + +class BaseApi: + def __init__(self, url=None, version="v1", identity_file=None, backend_type=None, credentials=None): + url, version, identity_file, backend_type = get_backend_config(url, version, + identity_file, backend_type) + self.url = url + self.credentials = credentials or {} + if backend_type == BackendType.SELENE: + self.backend = SeleneBackend(url, version, identity_file) + elif backend_type == BackendType.PERSONAL: + self.backend = PersonalBackend(url, version, identity_file) + else: # if backend_type == BackendType.OFFLINE: + self.backend = OfflineBackend(url, version, identity_file) + self.validate_backend_type() + + def validate_backend_type(self): + pass + + @property + def backend_type(self): + return self.backend.backend_type + + @property + def backend_url(self): + return self.backend.url + + @property + def backend_version(self): + return self.backend.backend_version + + @property + def identity(self): + return self.backend.identity + + @property + def uuid(self): + return self.backend.uuid + + @property + def access_token(self): + return self.backend.access_token + + @property + def headers(self): + return self.backend.headers + + def check_token(self): + self.backend.check_token() + + def refresh_token(self): + self.backend.refresh_token() + + def get(self, url=None, *args, **kwargs): + return self.backend.get(url, *args, **kwargs) + + def post(self, url=None, *args, **kwargs): + return self.backend.post(url, *args, **kwargs) + + def put(self, url=None, *args, **kwargs): + return self.backend.put(url, *args, **kwargs) + + def patch(self, url=None, *args, **kwargs): + return self.backend.patch(url, *args, **kwargs) + + +class AdminApi(BaseApi): + def __init__(self, admin_key, url=None, version="v1", identity_file=None, backend_type=None): + super().__init__(url, version, identity_file, backend_type, credentials={"admin": admin_key}) + self.url = f"{self.backend_url}/{self.backend_version}/admin" + + def validate_backend_type(self): + if not API_REGISTRY[self.backend_type]["admin"]: + raise ValueError(f"{self.__class__.__name__} not available for {self.backend_type}") + + def pair(self, uuid=None): + return self.backend.admin_pair(uuid) + + def set_device_location(self, uuid, loc): + """ + loc = { + "city": { + "code": "Lawrence", + "name": "Lawrence", + "state": { + "code": "KS", + "name": "Kansas", + "country": { + "code": "US", + "name": "United States" + } + } + }, + "coordinate": { + "latitude": 38.971669, + "longitude": -95.23525 + }, + "timezone": { + "code": "America/Chicago", + "name": "Central Standard Time", + "dstOffset": 3600000, + "offset": -21600000 + } + } + """ + return self.backend.admin_set_device_location(uuid, loc) + + def set_device_prefs(self, uuid, prefs): + """ + prefs = {"time_format": "full", + "date_format": "DMY", + "system_unit": "metric", + "lang": "en-us", + "wake_word": "hey_mycroft", + "ww_config": {"phonemes": "HH EY . M AY K R AO F T", + "module": "ovos-ww-plugin-pocketsphinx", + "threshold": 1e-90}, + "tts_module": "ovos-tts-plugin-mimic", + "tts_config": {"voice": "ap"}} + """ + self.backend.admin_set_device_prefs(uuid, prefs) + + def set_device_info(self, uuid, info): + """ + info = {"opt_in": True, + "name": "my_device", + "device_location": "kitchen", + "email": "notifications@me.com", + "isolated_skills": False, + "lang": "en-us"} + """ + self.backend.admin_set_device_info(uuid, info) + + +class DeviceApi(BaseApi): + def __init__(self, url=None, version="v1", identity_file=None, backend_type=None): + super().__init__(url, version, identity_file, backend_type) + self.url = f"{self.backend_url}/{self.backend_version}/device" + + def validate_backend_type(self): + if not API_REGISTRY[self.backend_type]["device"]: + raise ValueError(f"{self.__class__.__name__} not available for {self.backend_type}") + + def get(self, url=None, *args, **kwargs): + """ Retrieve all device information from the web backend """ + return self.backend.device_get() + + def get_skill_settings_v1(self): + """ old style deprecated bidirectional skill settings api, still available! """ + return self.backend.device_get_skill_settings_v1() + + def put_skill_settings_v1(self, data): + """ old style deprecated bidirectional skill settings api, still available! """ + return self.backend.device_put_skill_settings_v1(data) + + def get_settings(self): + """ Retrieve device settings information from the web backend + + Returns: + str: JSON string with user configuration information. + """ + return self.backend.device_get_settings() + + def get_code(self, state=None): + return self.backend.device_get_code(state) + + def activate(self, state, token, + core_version="unknown", + platform="unknown", + platform_build="unknown", + enclosure_version="unknown"): + return self.backend.device_activate(state, token, core_version, + platform, platform_build, enclosure_version) + + def update_version(self, + core_version="unknown", + platform="unknown", + platform_build="unknown", + enclosure_version="unknown"): + return self.backend.device_update_version(core_version, platform, platform_build, enclosure_version) + + def report_metric(self, name, data): + return self.backend.device_report_metric(name, data) + + def get_location(self): + """ Retrieve device location information from the web backend + + Returns: + str: JSON string with user location. + """ + return self.backend.device_get_location() + + def get_subscription(self): + """ + Get information about type of subscription this unit is connected + to. + + Returns: dictionary with subscription information + """ + return self.backend.device_get_subscription() + + @property + def is_subscriber(self): + """ + status of subscription. True if device is connected to a paying + subscriber. + """ + return self.backend.is_subscriber + + def get_subscriber_voice_url(self, voice=None, arch=None): + return self.backend.device_get_subscriber_voice_url(voice, arch) + + def get_oauth_token(self, dev_cred): + """ + Get Oauth token for dev_credential dev_cred. + + Argument: + dev_cred: development credentials identifier + + Returns: + json string containing token and additional information + """ + return self.backend.device_get_oauth_token(dev_cred) + + # cached for 30 seconds because often 1 call per skill is done in quick succession + @timed_lru_cache(seconds=30) + def get_skill_settings(self): + """Get the remote skill settings for all skills on this device.""" + return self.backend.device_get_skill_settings() + + def send_email(self, title, body, sender): + return self.backend.device_send_email(title, body, sender) + + def upload_skill_metadata(self, settings_meta): + """Upload skill metadata. + + Args: + settings_meta (dict): skill info and settings in JSON format + """ + return self.backend.device_upload_skill_metadata(settings_meta) + + def upload_skills_data(self, data): + """ Upload skills.json file. This file contains a manifest of installed + and failed installations for use with the Marketplace. + + Args: + data: dictionary with skills data from msm + """ + if not isinstance(data, dict): + raise ValueError('data must be of type dict') + + _data = dict(data) # Make sure the input data isn't modified + # Strip the skills.json down to the bare essentials + to_send = {'skills': []} + if 'blacklist' in _data: + to_send['blacklist'] = _data['blacklist'] + else: + LOG.warning('skills manifest lacks blacklist entry') + to_send['blacklist'] = [] + + # Make sure skills doesn't contain duplicates (keep only last) + if 'skills' in _data: + skills = {s['name']: s for s in _data['skills']} + to_send['skills'] = [skills[key] for key in skills] + else: + LOG.warning('skills manifest lacks skills entry') + to_send['skills'] = [] + + for s in to_send['skills']: + # Remove optional fields backend objects to + if 'update' in s: + s.pop('update') + + # Finalize skill_gid with uuid if needed + s['skill_gid'] = s.get('skill_gid', '').replace('@|', f'@{self.uuid}|') + + return self.backend.device_upload_skills_data(to_send) + + def upload_wake_word_v1(self, audio, params): + """ upload precise wake word V1 endpoint - DEPRECATED""" + return self.backend.device_upload_wake_word_v1(audio, params) + + def upload_wake_word(self, audio, params): + """ upload precise wake word V2 endpoint """ + return self.backend.device_upload_wake_word(audio, params) + + +class STTApi(BaseApi): + def __init__(self, url=None, version="v1", identity_file=None, backend_type=None): + super().__init__(url, version, identity_file, backend_type) + self.url = f"{self.backend_url}/{self.backend_version}/stt" + + def validate_backend_type(self): + if not API_REGISTRY[self.backend_type]["stt"]: + raise ValueError(f"{self.__class__.__name__} not available for {self.backend_type}") + + @property + def headers(self): + h = self.backend.headers + h["Content-Type"] = "audio/x-flac" + return h + + def stt(self, audio, language="en-us", limit=1): + """ Web API wrapper for performing Speech to Text (STT) + + Args: + audio (bytes): The recorded audio, as in a FLAC file + language (str): A BCP-47 language code, e.g. "en-US" + limit (int): Maximum minutes to transcribe(?) + + Returns: + dict: JSON structure with transcription results + """ + return self.backend.stt_get(audio, language, limit) + + +class GeolocationApi(BaseApi): + """Web API wrapper for performing geolocation lookups.""" + + def __init__(self, url=None, version="v1", identity_file=None, backend_type=None): + super().__init__(url, version, identity_file, backend_type) + + def validate_backend_type(self): + if not API_REGISTRY[self.backend_type]["geolocate"]: + raise ValueError(f"{self.__class__.__name__} not available for {self.backend_type}") + if self.backend_type == BackendType.OFFLINE: + self.url = "https://nominatim.openstreetmap.org" + else: + self.url = f"{self.backend_url}/{self.backend_version}/geolocation" + + def get_geolocation(self, location): + """Call the geolocation endpoint. + + Args: + location (str): the location to lookup (e.g. Kansas City Missouri) + + Returns: + str: JSON structure with lookup results + """ + return self.backend.geolocation_get(location) + + +class WolframAlphaApi(BaseApi): + + def __init__(self, url=None, version="v1", identity_file=None, backend_type=None, key=None): + super().__init__(url, version, identity_file, backend_type, credentials={"wolfram": key}) + + def validate_backend_type(self): + if not API_REGISTRY[self.backend_type]["wolfram"]: + raise ValueError(f"{self.__class__.__name__} not available for {self.backend_type}") + if self.backend_type == BackendType.OFFLINE and not self.credentials["wolfram"]: + raise ValueError("WolframAlpha api key not set!") + + if self.backend_type == BackendType.OFFLINE: + self.url = "https://api.wolframalpha.com" + else: + self.url = f"{self.backend_url}/{self.backend_version}/wolframAlpha" + + # cached to save api calls, wolfram answer wont change often + @timed_lru_cache(seconds=60 * 30) + def spoken(self, query, units="metric", lat_lon=None, optional_params=None): + return self.backend.wolfram_spoken(query, units, lat_lon, optional_params) + + @timed_lru_cache(seconds=60 * 30) + def simple(self, query, units="metric", lat_lon=None, optional_params=None): + return self.backend.wolfram_simple(query, units, lat_lon, optional_params) + + @timed_lru_cache(seconds=60 * 30) + def full_results(self, query, units="metric", lat_lon=None, optional_params=None): + """Wrapper for the WolframAlpha Full Results v2 API. + https://products.wolframalpha.com/api/documentation/ + Pods of interest + - Input interpretation - Wolfram's determination of what is being asked about. + - Name - primary name of + """ + return self.backend.wolfram_full_results(query, units, lat_lon, optional_params) + + +class OpenWeatherMapApi(BaseApi): + """Use Open Weather Map's One Call API to retrieve weather information""" + + def __init__(self, url=None, version="v1", identity_file=None, backend_type=None, key=None): + super().__init__(url, version, identity_file, backend_type, credentials={"owm": key}) + + def validate_backend_type(self): + if not API_REGISTRY[self.backend_type]["owm"]: + raise ValueError(f"{self.__class__.__name__} not available for {self.backend_type}") + if self.backend_type == BackendType.OFFLINE and not self.backend.credentials["owm"]: + raise ValueError("OWM api key not set!") + + if self.backend_type == BackendType.OFFLINE: + self.url = "https://api.openweathermap.org/data/2.5" + else: + self.url = f"{self.backend_url}/{self.backend_version}/owm" + + def owm_language(self, lang: str): + """ + OWM supports 31 languages, see https://openweathermap.org/current#multi + + Convert Mycroft's language code to OpenWeatherMap's, if missing use english. + + Args: + lang: The Mycroft language code. + """ + return self.backend.owm_language(lang) + + # cached to save api calls, owm only updates data every 15mins or so + @timed_lru_cache(seconds=60 * 10) + def get_weather(self, lat_lon=None, lang="en-us", units="metric"): + """Issue an API call and map the return value into a weather report + + Args: + units (str): metric or imperial measurement units + lat_lon (tuple): the geologic (latitude, longitude) of the weather location + """ + return self.backend.owm_get_weather(lat_lon, lang, units) + + @timed_lru_cache(seconds=60 * 10) + def get_current(self, lat_lon=None, lang="en-us", units="metric"): + """Issue an API call and map the return value into a weather report + + Args: + units (str): metric or imperial measurement units + lat_lon (tuple): the geologic (latitude, longitude) of the weather location + """ + return self.backend.owm_get_current(lat_lon, lang, units) + + @timed_lru_cache(seconds=60 * 10) + def get_hourly(self, lat_lon=None, lang="en-us", units="metric"): + """Issue an API call and map the return value into a weather report + + Args: + units (str): metric or imperial measurement units + lat_lon (tuple): the geologic (latitude, longitude) of the weather location + """ + return self.backend.owm_get_hourly(lat_lon, lang, units) + + @timed_lru_cache(seconds=60 * 10) + def get_daily(self, lat_lon=None, lang="en-us", units="metric"): + """Issue an API call and map the return value into a weather report + + Args: + units (str): metric or imperial measurement units + lat_lon (tuple): the geologic (latitude, longitude) of the weather location + """ + return self.backend.owm_get_daily(lat_lon, lang, units) + + +class EmailApi(BaseApi): + """Web API wrapper for sending email""" + + def __init__(self, url=None, version="v1", identity_file=None, backend_type=None): + super().__init__(url, version, identity_file, backend_type) + + def validate_backend_type(self): + if not API_REGISTRY[self.backend_type]["email"]: + raise ValueError(f"{self.__class__.__name__} not available for {self.backend_type}") + if self.backend_type == BackendType.OFFLINE: + self.url = self.credentials["smtp"]["host"] + else: + self.url = self.backend_url + + def send_email(self, title, body, sender): + return self.backend.email_send(title, body, sender) + + +class DatasetApi(BaseApi): + """Web API wrapper for dataset collection""" + + def __init__(self, url=None, version="v1", identity_file=None, backend_type=None): + super().__init__(url, version, identity_file, backend_type) + + def validate_backend_type(self): + if not API_REGISTRY[self.backend_type]["dataset"]: + raise ValueError(f"{self.__class__.__name__} not available for {self.backend_type}") + + def upload_wake_word(self, audio, params, upload_url=None): + return self.backend.dataset_upload_wake_word(audio, params, upload_url) + + +class MetricsApi(BaseApi): + """Web API wrapper for netrics collection""" + + def __init__(self, url=None, version="v1", identity_file=None, backend_type=None): + super().__init__(url, version, identity_file, backend_type) + + def validate_backend_type(self): + if not API_REGISTRY[self.backend_type]["metrics"]: + raise ValueError(f"{self.__class__.__name__} not available for {self.backend_type}") + + def report_metric(self, name, data): + return self.backend.metrics_upload(name, data) + + +class OAuthApi(BaseApi): + """Web API wrapper for oauth api""" + + def __init__(self, url=None, version="v1", identity_file=None, backend_type=None): + super().__init__(url, version, identity_file, backend_type) + + def validate_backend_type(self): + if not API_REGISTRY[self.backend_type]["oauth"]: + raise ValueError(f"{self.__class__.__name__} not available for {self.backend_type}") + + def get_oauth_token(self, dev_cred): + """ + Get Oauth token for dev_credential dev_cred. + + Argument: + dev_cred: development credentials identifier + + Returns: + json string containing token and additional information + """ + return self.backend.oauth_get_token(dev_cred) + + +if __name__ == "__main__": + # d = DeviceApi(FAKE_BACKEND_URL) + + # TODO turn these into unittests + # ident = load_identity() + # paired = is_paired() + geo = GeolocationApi(backend_type=BackendType.OFFLINE) + data = geo.get_geolocation("Missouri Kansas") + print(data) + exit(6) + wolf = WolframAlphaApi(backend_type=BackendType.OFFLINE) + # data = wolf.spoken("what is the speed of light") + # print(data) + # data = wolf.full_results("2+2") + # print(data) + + owm = OpenWeatherMapApi(backend_type=BackendType.OFFLINE) + data = owm.get_current() + print(data) + data = owm.get_weather() + print(data) diff --git a/ovos_backend_client/backends/__init__.py b/ovos_backend_client/backends/__init__.py new file mode 100644 index 0000000..119b61d --- /dev/null +++ b/ovos_backend_client/backends/__init__.py @@ -0,0 +1,82 @@ +from ovos_config.config import Configuration + +from ovos_backend_client.backends.base import BackendType +from ovos_backend_client.backends.offline import OfflineBackend +from ovos_backend_client.backends.personal import PersonalBackend +from ovos_backend_client.backends.selene import SELENE_API_URL, SeleneBackend + +API_REGISTRY = { + BackendType.OFFLINE: { + "admin": True, # updates mycroft.conf if used + "device": True, # shared database with local backend for UI compat + "dataset": True, # shared database with local backend for ww tagger UI compat + "metrics": True, # shared database with local backend for metrics UI compat + "wolfram": True, # key needs to be set + "geolocate": True, # nominatim - no key needed + "stt": True, # uses OPM and reads from mycroft.conf + "owm": True, # key needs to be set + "email": True, # smtp config needs to be set + "oauth": True # use local backend UI on same device to register apps + }, + BackendType.SELENE: { + "admin": False, + "device": True, + "dataset": True, + "metrics": True, + "wolfram": True, + "geolocate": True, + "stt": True, + "owm": True, + "email": True, # only send to email used for registering account + "oauth": True + }, + BackendType.PERSONAL: { + "admin": True, + "device": True, + "dataset": True, + "metrics": True, + "wolfram": True, + "geolocate": True, + "stt": True, + "owm": True, + "email": True, + "oauth": True # can use local backend UI to register apps + } +} + + +def get_backend_type(conf=None): + conf = conf or Configuration() + if "server" in conf: + conf = conf["server"] + if conf.get("disabled"): + return BackendType.OFFLINE + if "backend_type" in conf: + return conf["backend_type"] + url = conf.get("url") + if not url: + return BackendType.OFFLINE + elif "mycroft.ai" in url: + return BackendType.SELENE + return BackendType.PERSONAL + + +def get_backend_config(url=None, version="v1", identity_file=None, backend_type=None): + config = Configuration() + config_server = config.get("server") or {} + if not url: + url = config_server.get("url") + version = config_server.get("version") or version + backend_type = backend_type or get_backend_type(config) + elif not backend_type: + backend_type = get_backend_type({"url": url}) + + if not url and backend_type: + if backend_type == BackendType.SELENE: + url = SELENE_API_URL + elif backend_type == BackendType.PERSONAL: + url = "http://0.0.0.0:6712" + else: + url = "http://127.0.0.1" + + return url, version, identity_file, backend_type diff --git a/ovos_backend_client/backends/base.py b/ovos_backend_client/backends/base.py new file mode 100644 index 0000000..8443d52 --- /dev/null +++ b/ovos_backend_client/backends/base.py @@ -0,0 +1,455 @@ +import abc +from enum import Enum + +import requests +from ovos_config.config import Configuration + +from ovos_backend_client.identity import IdentityManager + +try: + from timezonefinder import TimezoneFinder +except ImportError: + TimezoneFinder = None + + +class BackendType(str, Enum): + OFFLINE = "offline" + PERSONAL = "personal" + SELENE = "selene" + + +class AbstractBackend: + + def __init__(self, url, version="v1", identity_file=None, backend_type=BackendType.OFFLINE, credentials=None): + self.backend_url = url + self.backend_type = backend_type + self._identity_file = identity_file + self.backend_version = version + self.url = url + self.credentials = credentials or {} + + @property + def identity(self): + if self._identity_file: + # this is helpful if copying over the identity to a non-mycroft device + # eg, selene call out proxy in local backend + IdentityManager.set_identity_file(self._identity_file) + return IdentityManager.get() + + @property + def uuid(self): + return self.identity.uuid + + @property + def access_token(self): + return self.identity.access + + @property + def headers(self): + return {"Device": self.uuid, + "Content-Type": "application/json", + "Authorization": f"Bearer {self.access_token}"} + + def check_token(self): + if self.identity.is_expired(): + self.refresh_token() + + def refresh_token(self): + pass + + def get(self, url=None, *args, **kwargs): + url = url or self.url + headers = self.headers + if "headers" in kwargs: + headers.update(kwargs.pop("headers")) + self.check_token() + return requests.get(url, headers=headers, timeout=(3.05, 15), *args, **kwargs) + + def post(self, url=None, *args, **kwargs): + url = url or self.url + headers = self.headers + if "headers" in kwargs: + headers.update(kwargs.pop("headers")) + self.check_token() + return requests.post(url, headers=headers, timeout=(3.05, 15), *args, **kwargs) + + def put(self, url=None, *args, **kwargs): + url = url or self.url + headers = self.headers + if "headers" in kwargs: + headers.update(kwargs.pop("headers")) + self.check_token() + return requests.put(url, headers=headers, timeout=(3.05, 15), *args, **kwargs) + + def patch(self, url=None, *args, **kwargs): + url = url or self.url + headers = self.headers + if "headers" in kwargs: + headers.update(kwargs.pop("headers")) + self.check_token() + return requests.patch(url, headers=headers, timeout=(3.05, 15), *args, **kwargs) + + # OWM Api + @staticmethod + def _get_lat_lon(**kwargs): + lat = kwargs.get("latitude") or kwargs.get("lat") + lon = kwargs.get("longitude") or kwargs.get("lon") or kwargs.get("lng") + if not lat or not lon: + cfg = Configuration().get("location", {}).get("coordinate", {}) + lat = cfg.get("latitude") + lon = cfg.get("longitude") + return lat, lon + + @staticmethod + def owm_language(lang: str): + """ + OWM supports 31 languages, see https://openweathermap.org/current#multi + + Convert Mycroft's language code to OpenWeatherMap's, if missing use english. + + Args: + language_config: The Mycroft language code. + """ + OPEN_WEATHER_MAP_LANGUAGES = ( + "af", "al", "ar", "bg", "ca", "cz", "da", "de", "el", "en", "es", "eu", "fa", "fi", "fr", "gl", "he", "hi", + "hr", "hu", "id", "it", "ja", "kr", "la", "lt", "mk", "nl", "no", "pl", "pt", "pt_br", "ro", "ru", "se", + "sk", + "sl", "sp", "sr", "sv", "th", "tr", "ua", "uk", "vi", "zh_cn", "zh_tw", "zu" + ) + special_cases = {"cs": "cz", "ko": "kr", "lv": "la"} + lang_primary, lang_subtag = lang.split('-') + if lang.replace('-', '_') in OPEN_WEATHER_MAP_LANGUAGES: + return lang.replace('-', '_') + if lang_primary in OPEN_WEATHER_MAP_LANGUAGES: + return lang_primary + if lang_subtag in OPEN_WEATHER_MAP_LANGUAGES: + return lang_subtag + if lang_primary in special_cases: + return special_cases[lang_primary] + return "en" + + @abc.abstractmethod + def owm_get_weather(self, lat_lon=None, lang="en-us", units="metric"): + """Issue an API call and map the return value into a weather report + + Args: + units (str): metric or imperial measurement units + lat_lon (tuple): the geologic (latitude, longitude) of the weather location + """ + raise NotImplementedError() + + @abc.abstractmethod + def owm_get_current(self, lat_lon=None, lang="en-us", units="metric"): + """Issue an API call and map the return value into a weather report + + Args: + units (str): metric or imperial measurement units + lat_lon (tuple): the geologic (latitude, longitude) of the weather location + """ + raise NotImplementedError() + + @abc.abstractmethod + def owm_get_hourly(self, lat_lon=None, lang="en-us", units="metric"): + """Issue an API call and map the return value into a weather report + + Args: + units (str): metric or imperial measurement units + lat_lon (tuple): the geologic (latitude, longitude) of the weather location + """ + raise NotImplementedError() + + @abc.abstractmethod + def owm_get_daily(self, lat_lon=None, lang="en-us", units="metric"): + """Issue an API call and map the return value into a weather report + + Args: + units (str): metric or imperial measurement units + lat_lon (tuple): the geologic (latitude, longitude) of the weather location + """ + raise NotImplementedError() + + # Wolfram Alpha Api + @abc.abstractmethod + def wolfram_spoken(self, query, units="metric", lat_lon=None, optional_params=None): + raise NotImplementedError() + + @abc.abstractmethod + def wolfram_simple(self, query, units="metric", lat_lon=None, optional_params=None): + raise NotImplementedError() + + @abc.abstractmethod + def wolfram_full_results(self, query, units="metric", lat_lon=None, optional_params=None): + """Wrapper for the WolframAlpha Full Results v2 API. + https://products.wolframalpha.com/api/documentation/ + Pods of interest + - Input interpretation - Wolfram's determination of what is being asked about. + - Name - primary name of + """ + raise NotImplementedError() + + # Geolocation Api + @staticmethod + def _get_timezone(**kwargs): + if TimezoneFinder: + lat, lon = AbstractBackend._get_lat_lon(**kwargs) + tz = TimezoneFinder().timezone_at(lng=float(lon), lat=float(lat)) + return { + "name": tz.replace("/", " "), + "code": tz + } + else: + cfg = Configuration().get("location", {}).get("timezone") + return cfg or {"name": "UTC", "code": "UTC"} + + @abc.abstractmethod + def geolocation_get(self, location): + """Call the geolocation endpoint. + + Args: + location (str): the location to lookup (e.g. Kansas City Missouri) + + Returns: + str: JSON structure with lookup results + """ + raise NotImplementedError() + + # STT Api + @abc.abstractmethod + def stt_get(self, audio, language="en-us", limit=1): + """ Web API wrapper for performing Speech to Text (STT) + + Args: + audio (bytes): The recorded audio, as in a FLAC file + language (str): A BCP-47 language code, e.g. "en-US" + limit (int): Maximum minutes to transcribe(?) + + Returns: + dict: JSON structure with transcription results + """ + raise NotImplementedError() + + # Device Api + @property + def is_subscriber(self): + """ + status of subscription. True if device is connected to a paying + subscriber. + """ + try: + return self.device_get_subscription().get('@type') != 'free' + except Exception: + # If can't retrieve, assume not paired and not a subscriber yet + return False + + @abc.abstractmethod + def device_get(self): + """ Retrieve all device information from the web backend """ + raise NotImplementedError() + + @abc.abstractmethod + def device_get_settings(self): + """ Retrieve device settings information from the web backend + + Returns: + str: JSON string with user configuration information. + """ + raise NotImplementedError() + + @abc.abstractmethod + def device_get_skill_settings_v1(self): + """ old style bidirectional skill settings api, still available!""" + raise NotImplementedError() + + @abc.abstractmethod + def device_put_skill_settings_v1(self, data=None): + """ old style bidirectional skill settings api, still available!""" + raise NotImplementedError() + + @abc.abstractmethod + def device_get_code(self, state=None): + raise NotImplementedError() + + @abc.abstractmethod + def device_activate(self, state, token, + core_version="unknown", + platform="unknown", + platform_build="unknown", + enclosure_version="unknown"): + raise NotImplementedError() + + @abc.abstractmethod + def device_update_version(self, + core_version="unknown", + platform="unknown", + platform_build="unknown", + enclosure_version="unknown"): + raise NotImplementedError() + + @abc.abstractmethod + def device_report_metric(self, name, data): + raise NotImplementedError() + + @abc.abstractmethod + def device_get_location(self): + """ Retrieve device location information from the web backend + + Returns: + str: JSON string with user location. + """ + raise NotImplementedError() + + def device_get_subscription(self): + """ + Get information about type of subscription this unit is connected + to. + + Returns: dictionary with subscription information + """ + return {"@type": "free"} + + def device_get_subscriber_voice_url(self, voice=None, arch=None): + return None + + @abc.abstractmethod + def device_get_oauth_token(self, dev_cred): + """ + Get Oauth token for dev_credential dev_cred. + + Argument: + dev_cred: development credentials identifier + + Returns: + json string containing token and additional information + """ + raise NotImplementedError() + + @abc.abstractmethod + def device_get_skill_settings(self): + """Get the remote skill settings for all skills on this device.""" + raise NotImplementedError() + + def device_send_email(self, title, body, sender): + return self.email_send(title, body, sender) + + @abc.abstractmethod + def device_upload_skill_metadata(self, settings_meta): + """Upload skill metadata. + + Args: + settings_meta (dict): skill info and settings in JSON format + """ + raise NotImplementedError() + + @abc.abstractmethod + def device_upload_skills_data(self, data): + """ Upload skills.json file. This file contains a manifest of installed + and failed installations for use with the Marketplace. + + Args: + data: dictionary with skills data from msm + """ + raise NotImplementedError() + + @abc.abstractmethod + def device_upload_wake_word_v1(self, audio, params): + """ upload precise wake word V1 endpoint - url can be external to backend""" + raise NotImplementedError() + + @abc.abstractmethod + def device_upload_wake_word(self, audio, params): + """ upload precise wake word V2 endpoint - integrated with device api""" + raise NotImplementedError() + + # Metrics API + @abc.abstractmethod + def metrics_upload(self, name, data): + """ upload metrics""" + raise NotImplementedError() + + # OAuth API + @abc.abstractmethod + def oauth_get_token(self, dev_cred): + """ + Get Oauth token for dev_credential dev_cred. + + Argument: + dev_cred: development credentials identifier + + Returns: + json string containing token and additional information + """ + raise NotImplementedError() + + # Dataset API + @abc.abstractmethod + def dataset_upload_wake_word(self, audio, params): + """ upload wake word sample - url can be external to backend""" + raise NotImplementedError() + + # Email API + @abc.abstractmethod + def email_send(self, title, body, sender): + raise NotImplementedError() + + # Admin Api + @abc.abstractmethod + def admin_pair(self, uuid=None): + raise NotImplementedError() + + @abc.abstractmethod + def admin_set_device_location(self, uuid, loc): + """ + loc = { + "city": { + "code": "Lawrence", + "name": "Lawrence", + "state": { + "code": "KS", + "name": "Kansas", + "country": { + "code": "US", + "name": "United States" + } + } + }, + "coordinate": { + "latitude": 38.971669, + "longitude": -95.23525 + }, + "timezone": { + "code": "America/Chicago", + "name": "Central Standard Time", + "dstOffset": 3600000, + "offset": -21600000 + } + } + """ + raise NotImplementedError() + + @abc.abstractmethod + def admin_set_device_prefs(self, uuid, prefs): + """ + prefs = {"time_format": "full", + "date_format": "DMY", + "system_unit": "metric", + "lang": "en-us", + "wake_word": "hey_mycroft", + "ww_config": {"phonemes": "HH EY . M AY K R AO F T", + "module": "ovos-ww-plugin-pocketsphinx", + "threshold": 1e-90}, + "tts_module": "ovos-tts-plugin-mimic", + "tts_config": {"voice": "ap"}} + """ + raise NotImplementedError() + + @abc.abstractmethod + def admin_set_device_info(self, uuid, info): + """ + info = {"opt_in": True, + "name": "my_device", + "device_location": "kitchen", + "email": "notifications@me.com", + "isolated_skills": False, + "lang": "en-us"} + """ + raise NotImplementedError() diff --git a/ovos_backend_client/backends/offline.py b/ovos_backend_client/backends/offline.py new file mode 100644 index 0000000..b139d54 --- /dev/null +++ b/ovos_backend_client/backends/offline.py @@ -0,0 +1,509 @@ +import json +import time +from io import BytesIO, StringIO +from tempfile import NamedTemporaryFile +from uuid import uuid4 + +from json_database import JsonStorageXDG +from ovos_config.config import Configuration +from ovos_config.config import update_mycroft_config +from ovos_plugin_manager.stt import OVOSSTTFactory, get_stt_config +from ovos_utils.smtp_utils import send_smtp + +from ovos_backend_client.backends.base import AbstractBackend, BackendType +from ovos_backend_client.database import BackendDatabase + + +class OfflineBackend(AbstractBackend): + + def __init__(self, url="127.0.0.1", version="v1", identity_file=None, credentials=None): + super().__init__(url, version, identity_file, BackendType.OFFLINE, credentials) + self.stt = None + + # OWM API + def owm_get_weather(self, lat_lon=None, lang="en-us", units="metric"): + """Issue an API call and map the return value into a weather report + + Args: + units (str): metric or imperial measurement units + lat_lon (tuple): the geologic (latitude, longitude) of the weather location + """ + # default to configured location + + lat, lon = lat_lon or self._get_lat_lon() + params = { + "lang": lang, + "units": units, + "lat": lat, "lon": lon, + "appid": self.credentials["owm"] + } + url = "https://api.openweathermap.org/data/2.5/onecall" + response = self.get(url, params=params) + return response.json() + + def owm_get_current(self, lat_lon=None, lang="en-us", units="metric"): + """Issue an API call and map the return value into a weather report + + Args: + units (str): metric or imperial measurement units + lat_lon (tuple): the geologic (latitude, longitude) of the weather location + """ + # default to configured location + + lat, lon = lat_lon or self._get_lat_lon() + params = { + "lang": lang, + "units": units, + "lat": lat, "lon": lon, + "appid": self.credentials["owm"] + } + url = "https://api.openweathermap.org/data/2.5/weather" + response = self.get(url, params=params) + return response.json() + + def owm_get_hourly(self, lat_lon=None, lang="en-us", units="metric"): + """Issue an API call and map the return value into a weather report + + Args: + units (str): metric or imperial measurement units + lat_lon (tuple): the geologic (latitude, longitude) of the weather location + """ + # default to configured location + + lat, lon = lat_lon or self._get_lat_lon() + params = { + "lang": lang, + "units": units, + "lat": lat, "lon": lon, + "appid": self.credentials["owm"] + } + url = "https://api.openweathermap.org/data/2.5/forecast" + response = self.get(url, params=params) + return response.json() + + def owm_get_daily(self, lat_lon=None, lang="en-us", units="metric"): + """Issue an API call and map the return value into a weather report + + Args: + units (str): metric or imperial measurement units + lat_lon (tuple): the geologic (latitude, longitude) of the weather location + """ + # default to configured location + + lat, lon = lat_lon or self._get_lat_lon() + params = { + "lang": lang, + "units": units, + "lat": lat, "lon": lon, + "appid": self.credentials["owm"] + } + url = "https://api.openweathermap.org/data/2.5/forecast/daily" + response = self.get(url, params=params) + return response.json() + + # Wolfram Alpha Api + def wolfram_spoken(self, query, units="metric", lat_lon=None, optional_params=None): + optional_params = optional_params or {} + if not lat_lon: + lat_lon = self._get_lat_lon(**optional_params) + params = {'i': query, + "geolocation": "{},{}".format(*lat_lon), + 'units': units, + **optional_params} + url = 'https://api.wolframalpha.com/v1/spoken' + params["appid"] = self.credentials["wolfram"] + return self.get(url, params=params).text + + def wolfram_simple(self, query, units="metric", lat_lon=None, optional_params=None): + optional_params = optional_params or {} + if not lat_lon: + lat_lon = self._get_lat_lon(**optional_params) + params = {'i': query, + "geolocation": "{},{}".format(*lat_lon), + 'units': units, + **optional_params} + url = 'https://api.wolframalpha.com/v1/simple' + params["appid"] = self.credentials["wolfram"] + return self.get(url, params=params).text + + def wolfram_full_results(self, query, units="metric", lat_lon=None, optional_params=None): + """Wrapper for the WolframAlpha Full Results v2 API. + https://products.wolframalpha.com/api/documentation/ + Pods of interest + - Input interpretation - Wolfram's determination of what is being asked about. + - Name - primary name of + """ + optional_params = optional_params or {} + if not lat_lon: + lat_lon = self._get_lat_lon(**optional_params) + params = {'input': query, + "units": units, + "mode": "Default", + "format": "image,plaintext", + "geolocation": "{},{}".format(*lat_lon), + "output": "json", + **optional_params} + url = 'https://api.wolframalpha.com/v2/query' + params["appid"] = self.credentials["wolfram"] + data = self.get(url, params=params) + return data.json() + + # Geolocation Api + def geolocation_get(self, location): + """Call the geolocation endpoint. + + Args: + location (str): the location to lookup (e.g. Kansas City Missouri) + + Returns: + str: JSON structure with lookup results + """ + url = "https://nominatim.openstreetmap.org/search" + data = self.get(url, params={"q": location, "format": "json", "limit": 1}).json()[0] + url = "https://nominatim.openstreetmap.org/details.php?osmtype=W&osmid=38210407&format=json" + details = self.get(url, params={"osmid": data['osm_id'], "osmtype": data['osm_type'][0].upper(), + "format": "json"}).json() + + location = { + "city": { + "code": details["addresstags"].get("postcode") or details["calculated_postcode"] or "", + "name": details["localname"], + "state": { + "code": details["addresstags"].get("state_code") or details["calculated_postcode"] or "", + "name": details["addresstags"].get("state") or data["display_name"].split(", ")[0], + "country": { + "code": details["country_code"].upper() or details["addresstags"].get("country"), + "name": data["display_name"].split(", ")[-1] + } + } + }, + "coordinate": { + "latitude": data["lat"], + "longitude": data["lon"] + } + } + if "timezone" not in location: + location["timezone"] = self._get_timezone( + lon=location["coordinate"]["longitude"], + lat=location["coordinate"]["latitude"]) + return location + + # Device Api + def device_get(self): + """ Retrieve all device information from the web backend """ + data = JsonStorageXDG("ovos_device_info.json", subfolder="OpenVoiceOS") + return data + + def device_get_settings(self): + """ Retrieve device settings information from the web backend + + Returns: + str: JSON string with user configuration information. + """ + return Configuration() # TODO format keys or not needed ? + + def device_get_skill_settings_v1(self): + """ old style bidirectional skill settings api, still available!""" + # TODO scan skill xdg paths + return [] + + def device_put_skill_settings_v1(self, data=None): + """ old style bidirectional skill settings api, still available!""" + # do nothing, skills manage their own settings lifecycle + return {} + + def device_get_code(self, state=None): + return "ABCDEF" # dummy data + + def device_activate(self, state, token, + core_version="unknown", + platform="unknown", + platform_build="unknown", + enclosure_version="unknown"): + data = {"state": state, + "token": token, + "coreVersion": core_version, + "platform": platform, + "platform_build": platform_build, + "enclosureVersion": enclosure_version} + identity = self.admin_pair(state) + data["uuid"] = data.pop("state") + data["token"] = self.access_token + BackendDatabase(self.uuid).update_device_db(data) + + def device_update_version(self, + core_version="unknown", + platform="unknown", + platform_build="unknown", + enclosure_version="unknown"): + data = {"coreVersion": core_version, + "platform": platform, + "platform_build": platform_build, + "enclosureVersion": enclosure_version, + "token": self.access_token} + BackendDatabase(self.uuid).update_device_db(data) + + def device_report_metric(self, name, data): + return self.metrics_upload(name, data) + + def device_get_location(self): + """ Retrieve device location information from the web backend + + Returns: + str: JSON string with user location. + """ + return Configuration().get("location") or {} + + def device_get_oauth_token(self, dev_cred): + """ + Get Oauth token for dev_credential dev_cred. + + Argument: + dev_cred: development credentials identifier + + Returns: + json string containing token and additional information + """ + raise self.oauth_get_token(dev_cred) + + def device_get_skill_settings(self): + """Get the remote skill settings for all skills on this device.""" + # TODO - scan xdg paths ? + return {} + + def device_upload_skill_metadata(self, settings_meta): + """Upload skill metadata. + + Args: + settings_meta (dict): skill info and settings in JSON format + """ + # Do nothing, skills manage their own settingsmeta.json files + return + + def device_upload_skills_data(self, data): + """ Upload skills.json file. This file contains a manifest of installed + and failed installations for use with the Marketplace. + + Args: + data: dictionary with skills data from msm + """ + with JsonStorageXDG("ovos_skills_meta.json", subfolder="OpenVoiceOS") as db: + db.update(data) + + def device_upload_wake_word_v1(self, audio, params, upload_url=None): + """ upload precise wake word V1 endpoint - url can be external to backend""" + return self.dataset_upload_wake_word(audio, params, upload_url) + + def device_upload_wake_word(self, audio, params): + """ upload precise wake word V2 endpoint - integrated with device api""" + return self.dataset_upload_wake_word(audio, params) + + # Metrics API + def metrics_upload(self, name, data): + """ upload metrics""" + BackendDatabase(self.uuid).update_metrics_db(name, data) + return {} + + # Dataset API + def dataset_upload_wake_word(self, audio, params, upload_url=None): + """ upload wake word sample - url can be external to backend""" + if Configuration().get("listener", {}).get('record_wake_words'): + BackendDatabase(self.uuid).update_ww_db(params) # update metadata db for ww tagging UI + + upload_url = upload_url or Configuration().get("listener", {}).get("wake_word_upload", {}).get("url") + if upload_url: + # upload to arbitrary server + ww_files = { + 'audio': BytesIO(audio.get_wav_data()), + 'metadata': StringIO(json.dumps(params)) + } + return self.post(upload_url, files=ww_files) + return {} + + # Email API + def email_send(self, title, body, sender): + """ will raise KeyError if SMTP not configured in mycroft.conf""" + body += f"\n\nsent by: {sender}" # append skill_id info to body + + mail_config = self.credentials["email"] + + smtp_config = mail_config["smtp"] + user = smtp_config["username"] + pswd = smtp_config["password"] + host = smtp_config["host"] + port = smtp_config.get("port", 465) + + recipient = mail_config.get("recipient") or user + + send_smtp(user, pswd, + user, recipient, + title, body, + host, port) + + # OAuth API + def oauth_get_token(self, dev_cred): + """ + Get Oauth token for dev_credential dev_cred. + + Argument: + dev_cred: development credentials identifier + + Returns: + json string containing token and additional information + """ + return JsonStorageXDG("ovos_oauth").get(dev_cred) or {} + + # Admin API + def admin_pair(self, uuid=None): + uuid = uuid or str(uuid4()) + # create dummy identity file for third parties expecting it for pairing checks + identity = {"uuid": uuid, + "access": "OVOSdbF1wJ4jA5lN6x6qmVk_QvJPqBQZTUJQm7fYzkDyY_Y=", + "refresh": "OVOS66c5SpAiSpXbpHlq9HNGl1vsw_srX49t5tCv88JkhuE=", + "expires_at": time.time() + 9999999999} + # save identity file + self.identity.save(identity) + return identity + + def admin_set_device_location(self, uuid, loc): + """ + loc = { + "city": { + "code": "Lawrence", + "name": "Lawrence", + "state": { + "code": "KS", + "name": "Kansas", + "country": { + "code": "US", + "name": "United States" + } + } + }, + "coordinate": { + "latitude": 38.971669, + "longitude": -95.23525 + }, + "timezone": { + "code": "America/Chicago", + "name": "Central Standard Time", + "dstOffset": 3600000, + "offset": -21600000 + } + } + """ + update_mycroft_config({"location": loc}) + + def admin_set_device_prefs(self, uuid, prefs): + """ + prefs = {"time_format": "full", + "date_format": "DMY", + "system_unit": "metric", + "lang": "en-us", + "wake_word": "hey_mycroft", + "ww_config": {"phonemes": "HH EY . M AY K R AO F T", + "module": "ovos-ww-plugin-pocketsphinx", + "threshold": 1e-90}, + "tts_module": "ovos-tts-plugin-mimic", + "tts_config": {"voice": "ap"}} + """ + with JsonStorageXDG("ovos_device_info.json", subfolder="OpenVoiceOS") as db: + db.update(prefs) + cfg = dict(prefs) + cfg["listener"] = {} + cfg["hotwords"] = {} + cfg["tts"] = {} + tts = None + tts_cfg = {} + ww = None + ww_cfg = {} + if "wake_word" in cfg: + ww = cfg.pop("wake_word") + if "ww_config" in cfg: + ww_cfg = cfg.pop("ww_config") + if "tts_module" in cfg: + tts = cfg.pop("tts_module") + if "tts_config" in cfg: + tts_cfg = cfg.pop("tts_config") + if not tts: + tts = tts_cfg.get("module") + if tts: + cfg["tts"]["module"] = tts + cfg["tts"][tts] = tts_cfg + if ww: + cfg["listener"]["wake_word"] = ww + cfg["hotwords"][ww] = ww_cfg + update_mycroft_config(cfg) + + def admin_set_device_info(self, uuid, info): + """ + info = {"opt_in": True, + "name": "my_device", + "device_location": "kitchen", + "email": "notifications@me.com", + "isolated_skills": False, + "lang": "en-us"} + """ + update_mycroft_config({"opt_in": info["opt_in"], "lang": info["lang"]}) + with JsonStorageXDG("ovos_device_info.json", subfolder="OpenVoiceOS") as db: + db.update(info) + + # STT Api + def load_stt_plugin(self, config=None, lang=None): + config = config or get_stt_config(config) + if lang: + config["lang"] = lang + self.stt = OVOSSTTFactory.create(config) + + def stt_get(self, audio, language="en-us", limit=1): + """ Web API wrapper for performing Speech to Text (STT) + + Args: + audio (bytes): The recorded audio, as in a FLAC file + language (str): A BCP-47 language code, e.g. "en-US" + limit (int): Maximum alternate transcriptions + + """ + if self.stt is None: + self.load_stt_plugin(lang=language) + with NamedTemporaryFile() as fp: + fp.write(audio) + with AudioFile(fp.name) as source: + audio = Recognizer().record(source) + tx = self.stt.execute(audio, language) + if isinstance(tx, str): + tx = [tx] + return tx + + +class AbstractPartialBackend(OfflineBackend): + """ helper class that internally delegates unimplemented methods to offline backend implementation + backends that only provide microservices and no DeviceApi should subclass from here + """ + + def __init__(self, url=None, version="v1", identity_file=None, backend_type=BackendType.OFFLINE, credentials=None): + super().__init__(url, version, identity_file, credentials) + self.backend_type = backend_type + + +if __name__ == "__main__": + b = OfflineBackend() + b.load_stt_plugin({"module": "ovos-stt-plugin-vosk"}) + # a = b.geolocation_get("Fafe") + # a = b.wolfram_full_results("2+2") + # a = b.wolfram_spoken("what is the speed of light") + # a = b.owm_get_weather() + + from speech_recognition import Recognizer, AudioFile + + with AudioFile("/home/user/PycharmProjects/selene_api/test/test.wav") as source: + audio = Recognizer().record(source) + + flac_data = audio.get_flac_data() + a = b.stt_get(flac_data) + + #a = b.owm_get_weather() + # a = b.owm_get_daily() + # a = b.owm_get_hourly() + # a = b.owm_get_current() + print(a) diff --git a/ovos_backend_client/backends/personal.py b/ovos_backend_client/backends/personal.py new file mode 100644 index 0000000..42d38e8 --- /dev/null +++ b/ovos_backend_client/backends/personal.py @@ -0,0 +1,422 @@ +import json +import os +import time +from io import BytesIO, StringIO + +from ovos_config.config import Configuration +from ovos_utils.log import LOG +from requests.exceptions import HTTPError + +from ovos_backend_client.backends.offline import AbstractPartialBackend, BackendType +from ovos_backend_client.identity import IdentityManager, identity_lock +import requests + + +class PersonalBackend(AbstractPartialBackend): + + def __init__(self, url="http://0.0.0.0:6712", version="v1", identity_file=None, credentials=None): + super().__init__(url, version, identity_file, BackendType.PERSONAL, credentials) + + def refresh_token(self): + try: + identity_lock.acquire(blocking=False) + # NOTE: requests needs to be used instead of self.get due to self.get calling this + data = requests.get(f"{self.backend_url}/{self.backend_version}/auth/token", headers=self.headers).json() + IdentityManager.save(data, lock=False) + LOG.debug('Saved credentials') + except: + LOG.warning("Failed to refresh access token") + finally: + try: + identity_lock.release() + except RuntimeError: # RuntimeError: release unlocked lock + pass + + # OWM Api + def owm_get_weather(self, lat_lon=None, lang="en-us", units="metric"): + """Issue an API call and map the return value into a weather report + + Args: + units (str): metric or imperial measurement units + lat_lon (tuple): the geologic (latitude, longitude) of the weather location + """ + # default to configured location + lat, lon = lat_lon or self._get_lat_lon() + response = self.get(url=f"{self.backend_url}/{self.backend_version}/owm/onecall", + params={ + "lang": self.owm_language(lang), + "lat": lat, + "lon": lon, + "units": units}) + return response.json() + + def owm_get_current(self, lat_lon=None, lang="en-us", units="metric"): + """Issue an API call and map the return value into a weather report + + Args: + units (str): metric or imperial measurement units + lat_lon (tuple): the geologic (latitude, longitude) of the weather location + """ + # default to configured location + lat, lon = lat_lon or self._get_lat_lon() + response = self.get(url=f"{self.backend_url}/{self.backend_version}/owm/weather", + params={ + "lang": self.owm_language(lang), + "lat": lat, + "lon": lon, + "units": units}) + return response.json() + + def owm_get_hourly(self, lat_lon=None, lang="en-us", units="metric"): + """Issue an API call and map the return value into a weather report + + Args: + units (str): metric or imperial measurement units + lat_lon (tuple): the geologic (latitude, longitude) of the weather location + """ + # default to configured location + lat, lon = lat_lon or self._get_lat_lon() + response = self.get(url=f"{self.backend_url}/{self.backend_version}/owm/forecast", + params={ + "lang": self.owm_language(lang), + "lat": lat, + "lon": lon, + "units": units}) + return response.json() + + def owm_get_daily(self, lat_lon=None, lang="en-us", units="metric"): + """Issue an API call and map the return value into a weather report + + Args: + units (str): metric or imperial measurement units + lat_lon (tuple): the geologic (latitude, longitude) of the weather location + """ + # default to configured location + lat, lon = lat_lon or self._get_lat_lon() + response = self.get(url=f"{self.backend_url}/{self.backend_version}/owm/forecast/daily", + params={ + "lang": self.owm_language(lang), + "lat": lat, + "lon": lon, + "units": units}) + return response.json() + + # Wolfram Alpha API + def wolfram_spoken(self, query, units="metric", lat_lon=None, optional_params=None): + optional_params = optional_params or {} + if not lat_lon: + lat_lon = self._get_lat_lon(**optional_params) + params = {'i': query, + "geolocation": "{},{}".format(*lat_lon), + 'units': units, + **optional_params} + url = f"{self.backend_url}/{self.backend_version}/wolframAlphaSpoken" + data = self.get(url=url, params=params) + return data.text + + def wolfram_simple(self, query, units="metric", lat_lon=None, optional_params=None): + optional_params = optional_params or {} + if not lat_lon: + lat_lon = self._get_lat_lon(**optional_params) + params = {'i': query, + "geolocation": "{},{}".format(*lat_lon), + 'units': units, + **optional_params} + url = f"{self.backend_url}/{self.backend_version}/wolframAlphaSimple" + data = self.get(url=url, params=params) + return data.text + + def wolfram_full_results(self, query, units="metric", lat_lon=None, optional_params=None): + """Wrapper for the WolframAlpha Full Results v2 API. + https://products.wolframalpha.com/api/documentation/ + Pods of interest + - Input interpretation - Wolfram's determination of what is being asked about. + - Name - primary name of + """ + optional_params = optional_params or {} + if not lat_lon: + lat_lon = self._get_lat_lon(**optional_params) + params = {'input': query, + "units": units, + "mode": "Default", + "format": "image,plaintext", + "geolocation": "{},{}".format(*lat_lon), + "output": "json", + **optional_params} + url = f"{self.backend_url}/{self.backend_version}/wolframAlphaFull" + data = self.get(url=url, params=params) + return data.json() + + # Geolocation Api + def geolocation_get(self, location): + """Call the geolocation endpoint. + + Args: + location (str): the location to lookup (e.g. Kansas City Missouri) + + Returns: + str: JSON structure with lookup results + """ + url = f"{self.backend_url}/{self.backend_version}/geolocation" + location = self.get(url, params={"location": location}).json()['data'] + if "timezone" not in location: + location["timezone"] = self._get_timezone( + lon=location["coordinate"]["longitude"], + lat=location["coordinate"]["latitude"]) + return location + + # STT Api + def stt_get(self, audio, language="en-us", limit=1): + """ Web API wrapper for performing Speech to Text (STT) + + Args: + audio (bytes): The recorded audio, as in a FLAC file + language (str): A BCP-47 language code, e.g. "en-US" + limit (int): Maximum alternate transcriptions + + Returns: + dict: JSON structure with transcription results + """ + data = self.post(url=f"{self.backend_url}/{self.backend_version}/stt", + data=audio, params={"lang": language, "limit": limit}, + headers={"Content-Type": "audio/x-flac"}) + if data.status_code == 200: + return data.json() + raise RuntimeError(f"STT api failed, status_code {data.status_code}") + + # Device Api + def device_get(self): + """ Retrieve all device information from the web backend """ + return self.get(f"{self.backend_url}/{self.backend_version}/device/{self.uuid}").json() + + def device_get_settings(self): + """ Retrieve device settings information from the web backend + + Returns: + str: JSON string with user configuration information. + """ + return self.get(f"{self.backend_url}/{self.backend_version}/device/{self.uuid}/setting").json() + + def device_get_skill_settings_v1(self): + """ old style bidirectional skill settings api, still available!""" + return self.get(f"{self.backend_url}/{self.backend_version}/device/{self.uuid}/skill").json() + + def device_put_skill_settings_v1(self, data=None): + """ old style bidirectional skill settings api, still available!""" + self.put(f"{self.backend_url}/{self.backend_version}/device/{self.uuid}/skill", json=data).json() + + def device_get_code(self, state=None): + state = state or self.uuid + return self.get(f"{self.backend_url}/{self.backend_version}/device/code", params={"state": state}).json() + + def device_activate(self, state, token, + core_version="unknown", + platform="unknown", + platform_build="unknown", + enclosure_version="unknown"): + data = {"state": state, + "token": token, + "coreVersion": core_version, + "platform": platform, + "platform_build": platform_build, + "enclosureVersion": enclosure_version} + return self.post(f"{self.backend_url}/{self.backend_version}/device/activate", json=data).json() + + def device_update_version(self, + core_version="unknown", + platform="unknown", + platform_build="unknown", + enclosure_version="unknown"): + data = {"coreVersion": core_version, + "platform": platform, + "platform_build": platform_build, + "enclosureVersion": enclosure_version} + return self.patch(f"{self.backend_url}/{self.backend_version}/device/{self.uuid}", json=data) + + def device_report_metric(self, name, data): + return self.post(f"{self.backend_url}/{self.backend_version}/device/{self.uuid}/metric/" + name, + json=data) + + def device_get_location(self): + """ Retrieve device location information from the web backend + + Returns: + str: JSON string with user location. + """ + return self.get(f"{self.backend_url}/{self.backend_version}/device/{self.uuid}/location").json() + + def device_get_subscription(self): + """ + Get information about type of subscription this unit is connected + to. + + Returns: dictionary with subscription information + """ + return self.get(f"{self.backend_url}/{self.backend_version}/device/{self.uuid}/subscription").json() + + def device_get_subscriber_voice_url(self, voice=None, arch=None): + archs = {'x86_64': 'x86_64', 'armv7l': 'arm', 'aarch64': 'arm'} + arch = arch or archs.get(os.uname()[4]) + if arch: + url = f"{self.backend_url}/{self.backend_version}/device/{self.uuid}/voice" + return self.get(url, params={"arch": arch}).json().get('link') + + def device_get_oauth_token(self, dev_cred): + """ + Get Oauth token for dev_credential dev_cred. + + Argument: + dev_cred: development credentials identifier + + Returns: + json string containing token and additional information + """ + return self.oauth_get_token(dev_cred) + + def device_get_skill_settings(self): + """Get the remote skill settings for all skills on this device.""" + return self.get(f"{self.backend_url}/{self.backend_version}/device/{self.uuid}/skill/settings").json() + + def device_send_email(self, title, body, sender): + return self.put(f"{self.backend_url}/{self.backend_version}/device/{self.uuid}/message", + json={"title": title, "body": body, "sender": sender}).json() + + def device_upload_skill_metadata(self, settings_meta): + """Upload skill metadata. + + Args: + settings_meta (dict): skill info and settings in JSON format + """ + return self.put(url=f"{self.backend_url}/{self.backend_version}/device/{self.uuid}/settingsMeta", + json=settings_meta) + + def device_upload_skills_data(self, data): + """ Upload skills.json file. This file contains a manifest of installed + and failed installations for use with the Marketplace. + + Args: + data: dictionary with skills data from msm + """ + return self.put(url=f"{self.backend_url}/{self.backend_version}/device/{self.uuid}/skillJson", + json=data) + + def device_upload_wake_word_v1(self, audio, params, upload_url=None): + """ upload precise wake word V1 endpoint - url can be external to backend""" + if not upload_url: + config = Configuration().get("listener", {}).get("wake_word_upload", {}) + upload_url = config.get("url") or f"{self.backend_url}/precise/upload" + ww_files = { + 'audio': BytesIO(audio.get_wav_data()), + 'metadata': StringIO(json.dumps(params)) + } + return self.post(upload_url, files=ww_files) + + def device_upload_wake_word(self, audio, params): + """ upload precise wake word V2 endpoint - integrated with device api""" + url = f"{self.backend_url}/{self.backend_version}/device/{self.uuid}/wake-word-file" + request_data = dict( + wake_word=params['name'], + engine=params.get('engine_name') or params.get('engine'), + timestamp=params.get('timestamp') or params.get('time') or str(int(1000 * time.time())), + model=params['model'] + ) + ww_files = { + 'audio': BytesIO(audio.get_wav_data()), + 'metadata': StringIO(json.dumps(request_data)) + } + return self.post(url, files=ww_files) + + # Metrics API + def metrics_upload(self, name, data): + """ upload metrics""" + return self.device_report_metric(name, data) + + # Dataset API + def dataset_upload_wake_word(self, audio, params, upload_url=None): + """ upload wake word sample - url can be external to backend""" + if upload_url: # explicit upload endpoint requested + return self.device_upload_wake_word_v1(audio, params, upload_url) + return self.device_upload_wake_word(audio, params) + + # OAuth API + def oauth_get_token(self, dev_cred): + """ + Get Oauth token for dev_credential dev_cred. + + Argument: + dev_cred: development credentials identifier + + Returns: + json string containing token and additional information + """ + return self.get(f"{self.backend_url}/{self.backend_version}/device/{self.uuid}/token/{dev_cred}").json() + + # Email API + def email_send(self, title, body, sender): + return self.device_send_email(title, body, sender) + + # Admin API + def admin_pair(self, uuid=None): + identity = self.get(f"{self.backend_url}/{self.backend_version}/admin/{uuid}/pair", + headers={"Authorization": f"Bearer {self.credentials['admin']}"}) + # save identity file + self.identity.save(identity) + return identity + + def admin_set_device_location(self, uuid, loc): + """ + loc = { + "city": { + "code": "Lawrence", + "name": "Lawrence", + "state": { + "code": "KS", + "name": "Kansas", + "country": { + "code": "US", + "name": "United States" + } + } + }, + "coordinate": { + "latitude": 38.971669, + "longitude": -95.23525 + }, + "timezone": { + "code": "America/Chicago", + "name": "Central Standard Time", + "dstOffset": 3600000, + "offset": -21600000 + } + } + """ + return self.put(f"{self.backend_url}/{self.backend_version}/admin/{uuid}/location", json=loc, + headers={"Authorization": f"Bearer {self.credentials['admin']}"}) + + def admin_set_device_prefs(self, uuid, prefs): + """ + prefs = {"time_format": "full", + "date_format": "DMY", + "system_unit": "metric", + "lang": "en-us", + "wake_word": "hey_mycroft", + "ww_config": {"phonemes": "HH EY . M AY K R AO F T", + "module": "ovos-ww-plugin-pocketsphinx", + "threshold": 1e-90}, + "tts_module": "ovos-tts-plugin-mimic", + "tts_config": {"voice": "ap"}} + """ + return self.put(f"{self.backend_url}/{self.backend_version}/admin/{uuid}/prefs", json=prefs, + headers={"Authorization": f"Bearer {self.credentials['admin']}"}) + + def admin_set_device_info(self, uuid, info): + """ + info = {"opt_in": True, + "name": "my_device", + "device_location": "kitchen", + "email": "notifications@me.com", + "isolated_skills": False, + "lang": "en-us"} + """ + return self.put(f"{self.backend_url}/{self.backend_version}/admin/{uuid}/device", json=info, + headers={"Authorization": f"Bearer {self.credentials['admin']}"}) diff --git a/ovos_backend_client/backends/selene.py b/ovos_backend_client/backends/selene.py new file mode 100644 index 0000000..c6294f6 --- /dev/null +++ b/ovos_backend_client/backends/selene.py @@ -0,0 +1,35 @@ +from ovos_config.config import Configuration + +from ovos_backend_client.backends.personal import PersonalBackend, BackendType + +SELENE_API_URL = "https://api.mycroft.ai" +SELENE_PRECISE_URL = "https://training.mycroft.ai/precise/upload" + + +class SeleneBackend(PersonalBackend): + + def __init__(self, url=SELENE_API_URL, version="v1", identity_file=None, credentials=None): + super().__init__(url, version, identity_file, credentials) + self.backend_type = BackendType.SELENE + + # Device API + def device_upload_wake_word_v1(self, audio, params, upload_url=None): + """ upload precise wake word V1 endpoint - url can be external to backend""" + # ensure default value for selene backend + if not upload_url: + config = Configuration().get("listener", {}).get("wake_word_upload", {}) + upload_url = config.get("url") or SELENE_PRECISE_URL + return super().device_upload_wake_word_v1(audio, params, upload_url) + + # Admin API - NOT available, use home.mycroft.ai instead + def admin_pair(self, uuid=None): + raise RuntimeError(f"AdminAPI not available for {self.backend_type}") + + def admin_set_device_location(self, uuid, loc): + raise RuntimeError(f"AdminAPI not available for {self.backend_type}") + + def admin_set_device_prefs(self, uuid, prefs): + raise RuntimeError(f"AdminAPI not available for {self.backend_type}") + + def admin_set_device_info(self, uuid, info): + raise RuntimeError(f"AdminAPI not available for {self.backend_type}") diff --git a/selene_api/cloud.py b/ovos_backend_client/cloud.py similarity index 98% rename from selene_api/cloud.py rename to ovos_backend_client/cloud.py index 0d217c7..f312127 100644 --- a/selene_api/cloud.py +++ b/ovos_backend_client/cloud.py @@ -3,7 +3,7 @@ from ovos_utils.security import encrypt, decrypt -from selene_api.api import DeviceApi +from ovos_backend_client.api import DeviceApi class SeleneCloud: diff --git a/selene_api/config.py b/ovos_backend_client/config.py similarity index 97% rename from selene_api/config.py rename to ovos_backend_client/config.py index e295532..e9ee124 100644 --- a/selene_api/config.py +++ b/ovos_backend_client/config.py @@ -1,10 +1,11 @@ import re +from pprint import pformat from ovos_utils import camel_case_split from ovos_utils.log import LOG -from selene_api.pairing import is_paired -from selene_api.api import DeviceApi -from pprint import pformat + +from ovos_backend_client.api import DeviceApi +from ovos_backend_client.pairing import is_paired def _is_remote_list(values): diff --git a/ovos_backend_client/database.py b/ovos_backend_client/database.py new file mode 100644 index 0000000..83e8541 --- /dev/null +++ b/ovos_backend_client/database.py @@ -0,0 +1,73 @@ +import os +from os.path import join + +from json_database import JsonStorageXDG, JsonDatabaseXDG +from ovos_config.config import Configuration +from ovos_utils.configuration import get_xdg_data_save_path + + +class BackendDatabase: + """ This helper class creates ovos-backend-ui compatible json databases + This allows users to visualize metrics, tag wake words and configure devices + even when not using a backend""" + + def __init__(self, uuid): + self.uuid = uuid + + def update_device_db(self, data): + with JsonStorageXDG("ovos_preferences", subfolder="OpenVoiceOS") as db: + db.update(data) + cfg = Configuration() + tts = cfg.get("tts", {}).get("module") + ww = cfg.get("listener", {}).get("wake_word", "hey_mycroft") + + with JsonStorageXDG("ovos_devices") as db: + skips = ["state", "coreVersion", "platform", "platform_build", "enclosureVersion"] + default = { + "uuid": self.uuid, + "isolated_skills": True, + "name": "LocalDevice", + "device_location": "127.0.0.1", + "email": "", + "date_format": cfg.get("date_format") or "DMY", + "time_format": cfg.get("time_format") or "full", + "system_unit": cfg.get("system_unit") or "metric", + "opt_in": cfg.get("opt_in", False), + "lang": cfg.get("lang", "en-us"), + "location": cfg.get("location", {}), + "default_tts": tts, + "default_tts_cfg": cfg.get("tts", {}).get(tts, {}), + "default_ww": ww, + "default_ww_cfg": cfg.get("hotwords", {}).get(ww, {}) + } + data = {k: v if k not in data else data[k] + for k, v in default.items() if k not in skips} + db[self.uuid] = data + + def update_metrics_db(self, name, data): + # shared with personal backend for UI compat + with JsonDatabaseXDG("ovos_metrics") as db: + db.add_item({ + "metric_id": len(db) + 1, + "uuid": self.uuid, + "metric_type": name, + "meta": data + }) + + def update_ww_db(self, params): + listener_config = Configuration().get("listener", {}) + save_path = listener_config.get('save_path', f"{get_xdg_data_save_path()}/listener") + saved_wake_words_dir = join(save_path, 'wake_words') + filename = join(saved_wake_words_dir, + '_'.join(str(params[k]) for k in sorted(params)) + + '.wav') + if os.path.isfile(filename): + with JsonDatabaseXDG("ovos_wakewords") as db: + db.add_item({ + "wakeword_id": len(db) + 1, + "uuid": self.uuid, + "meta": params, + "path": filename, + "transcription": params["name"] + }) + return filename diff --git a/selene_api/exceptions.py b/ovos_backend_client/exceptions.py similarity index 100% rename from selene_api/exceptions.py rename to ovos_backend_client/exceptions.py diff --git a/selene_api/identity.py b/ovos_backend_client/identity.py similarity index 93% rename from selene_api/identity.py rename to ovos_backend_client/identity.py index d107406..b2414d9 100644 --- a/selene_api/identity.py +++ b/ovos_backend_client/identity.py @@ -48,7 +48,7 @@ def __init__(self, **kwargs): self.uuid = kwargs.get("uuid", "") self.access = kwargs.get("access", "") self.refresh = kwargs.get("refresh", "") - self.expires_at = kwargs.get("expires_at", 0) + self.expires_at = kwargs.get("expires_at", -1) def is_expired(self): return self.refresh and 0 < self.expires_at <= time.time() @@ -117,11 +117,14 @@ def save(login=None, lock=True): def _update(login=None): LOG.debug('Updating identity') login = login or {} - expiration = login.get("expiration", 0) + expiration = login.get("expiration", -1) IdentityManager.__identity.uuid = login.get("uuid", "") IdentityManager.__identity.access = login.get("accessToken", "") IdentityManager.__identity.refresh = login.get("refreshToken", "") - IdentityManager.__identity.expires_at = time.time() + expiration + if expiration > 0: + IdentityManager.__identity.expires_at = time.time() + expiration + else: + IdentityManager.__identity.expires_at = -1 @staticmethod def update(login=None, lock=True): diff --git a/selene_api/pairing.py b/ovos_backend_client/pairing.py similarity index 92% rename from selene_api/pairing.py rename to ovos_backend_client/pairing.py index 6108e72..f319e16 100644 --- a/selene_api/pairing.py +++ b/ovos_backend_client/pairing.py @@ -1,23 +1,47 @@ -from ovos_utils.log import LOG -from ovos_utils.network_utils import is_connected -from ovos_utils.enclosure.api import EnclosureAPI -from ovos_utils.messagebus import Message, FakeBus -from selene_api.exceptions import BackendDown, InternetDown, HTTPError -from selene_api.identity import IdentityManager -from selene_api.api import DeviceApi import time from threading import Timer, Lock from uuid import uuid4 +from functools import wraps + +from ovos_utils.enclosure.api import EnclosureAPI +from ovos_utils.log import LOG +from ovos_utils.messagebus import Message, FakeBus +from ovos_utils.network_utils import is_connected +from ovos_config.config import Configuration + +from ovos_backend_client.api import DeviceApi +from ovos_backend_client.exceptions import BackendDown, InternetDown, HTTPError +from ovos_backend_client.identity import IdentityManager _paired_cache = False +def is_backend_disabled(): + config = Configuration() + if not config.get("server"): + # missing server block implies disabling backend + return True + return config["server"].get("disabled") or False + + +def requires_backend(f): + @wraps(f) + def decorated(*args, **kwargs): + if not is_backend_disabled(): + return f(*args, **kwargs) + return {} + + return decorated + + def has_been_paired(): """ Determine if this device has ever been paired with a web backend Returns: bool: True if ever paired with backend (not factory reset) """ + if is_backend_disabled(): + return True # This forces a load from the identity file in case the pairing state # has recently changed id = IdentityManager.load() @@ -34,7 +58,7 @@ def is_paired(ignore_errors=True, url=None, version="v1", identity_file=None): bool: True if paired with backend """ global _paired_cache - if _paired_cache: + if _paired_cache or is_backend_disabled(): # NOTE: This assumes once paired, the unit remains paired. So # un-pairing must restart the system (or clear this value). # The Mark 1 does perform a restart on RESET. diff --git a/selene_api/settings.py b/ovos_backend_client/settings.py similarity index 99% rename from selene_api/settings.py rename to ovos_backend_client/settings.py index 7e8306c..79475e1 100644 --- a/selene_api/settings.py +++ b/ovos_backend_client/settings.py @@ -6,11 +6,11 @@ from os.path import dirname, expanduser, join, isfile, isdir from json_database import JsonStorage - +from ovos_config import Configuration from ovos_utils import camel_case_split from ovos_utils.configuration import get_xdg_config_save_path, get_xdg_data_save_path, get_xdg_data_dirs -from ovos_config import Configuration -from selene_api.api import DeviceApi + +from ovos_backend_client.api import DeviceApi def get_display_name(skill_name: str): diff --git a/selene_api/version.py b/ovos_backend_client/version.py similarity index 79% rename from selene_api/version.py rename to ovos_backend_client/version.py index f31a60b..b30f61a 100644 --- a/selene_api/version.py +++ b/ovos_backend_client/version.py @@ -2,6 +2,6 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 0 -VERSION_BUILD = 4 -VERSION_ALPHA = 4 +VERSION_BUILD = 5 +VERSION_ALPHA = 1 # END_VERSION_BLOCK diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 99ce1dd..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -ovos_utils>=0.0.25a4 \ No newline at end of file diff --git a/requirements/offline.txt b/requirements/offline.txt new file mode 100644 index 0000000..705ceac --- /dev/null +++ b/requirements/offline.txt @@ -0,0 +1,3 @@ +timezonefinder +ovos_plugin_manager +SpeechRecognition~=3.8 \ No newline at end of file diff --git a/requirements/requirements.txt b/requirements/requirements.txt new file mode 100644 index 0000000..272a4c0 --- /dev/null +++ b/requirements/requirements.txt @@ -0,0 +1,2 @@ +ovos_utils>=0.0.25a7 +json_database \ No newline at end of file diff --git a/scripts/bump_alpha.py b/scripts/bump_alpha.py index 5a4184a..e048689 100644 --- a/scripts/bump_alpha.py +++ b/scripts/bump_alpha.py @@ -2,7 +2,7 @@ from os.path import join, dirname -version_file = join(dirname(dirname(__file__)), "selene_api", "version.py") +version_file = join(dirname(dirname(__file__)), "ovos_backend_client", "version.py") version_var_name = "VERSION_ALPHA" with open(version_file, "r", encoding="utf-8") as v: diff --git a/scripts/bump_build.py b/scripts/bump_build.py index 749f1d6..d213300 100644 --- a/scripts/bump_build.py +++ b/scripts/bump_build.py @@ -2,7 +2,7 @@ from os.path import join, dirname -version_file = join(dirname(dirname(__file__)), "selene_api", "version.py") +version_file = join(dirname(dirname(__file__)), "ovos_backend_client", "version.py") version_var_name = "VERSION_BUILD" alpha_var_name = "VERSION_ALPHA" diff --git a/scripts/bump_major.py b/scripts/bump_major.py index daf1b63..74f8f8d 100644 --- a/scripts/bump_major.py +++ b/scripts/bump_major.py @@ -2,7 +2,7 @@ from os.path import join, dirname -version_file = join(dirname(dirname(__file__)), "selene_api", "version.py") +version_file = join(dirname(dirname(__file__)), "ovos_backend_client", "version.py") version_var_name = "VERSION_MAJOR" minor_var_name = "VERSION_MINOR" build_var_name = "VERSION_BUILD" diff --git a/scripts/bump_minor.py b/scripts/bump_minor.py index 1dc333c..6b0f4ba 100644 --- a/scripts/bump_minor.py +++ b/scripts/bump_minor.py @@ -2,7 +2,7 @@ from os.path import join, dirname -version_file = join(dirname(dirname(__file__)), "selene_api", "version.py") +version_file = join(dirname(dirname(__file__)), "ovos_backend_client", "version.py") version_var_name = "VERSION_MINOR" build_var_name = "VERSION_BUILD" alpha_var_name = "VERSION_ALPHA" diff --git a/scripts/remove_alpha.py b/scripts/remove_alpha.py index fd9e642..edcb2d8 100644 --- a/scripts/remove_alpha.py +++ b/scripts/remove_alpha.py @@ -2,7 +2,7 @@ from os.path import join, dirname -version_file = join(dirname(dirname(__file__)), "selene_api", "version.py") +version_file = join(dirname(dirname(__file__)), "ovos_backend_client", "version.py") alpha_var_name = "VERSION_ALPHA" diff --git a/selene_api/__init__.py b/selene_api/__init__.py deleted file mode 100644 index 5be69b2..0000000 --- a/selene_api/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from selene_api.api import DeviceApi, WolframAlphaApi, OpenWeatherMapApi, STTApi, GeolocationApi -from selene_api.cloud import SeleneCloud, SecretSeleneCloud -from selene_api.settings import RemoteSkillSettings -from selene_api.pairing import is_paired, has_been_paired, check_remote_pairing, PairingManager -from selene_api.config import RemoteConfigManager diff --git a/selene_api/api.py b/selene_api/api.py deleted file mode 100644 index 83b9616..0000000 --- a/selene_api/api.py +++ /dev/null @@ -1,637 +0,0 @@ -import json -import os -from io import BytesIO, StringIO -import time -import requests -from ovos_config import Configuration -from ovos_utils import timed_lru_cache -from ovos_utils.log import LOG -from requests.exceptions import HTTPError -from enum import Enum -from selene_api.identity import IdentityManager, identity_lock - - -class BackendType(str, Enum): - OFFLINE = "offline" - PERSONAL = "personal" - SELENE = "selene" - - -def get_backend_type(conf=None): - conf = conf or Configuration() - if "server" in conf: - conf = conf["server"] - if conf.get("disabled"): - return BackendType.OFFLINE - if "backend_type" in conf: - return conf["backend_type"] - if conf.get("url", "") != "https://api.mycroft.ai": - return BackendType.PERSONAL - return BackendType.SELENE - - -class BaseApi: - def __init__(self, url=None, version="v1", identity_file=None): - - if not url: - config = Configuration() - config_server = config.get("server") or {} - url = config_server.get("url") - version = config_server.get("version") or version - - self._identity_file = identity_file - self.backend_url = url or "https://api.mycroft.ai" - self.backend_version = version - self.url = url - - @property - def identity(self): - if self._identity_file: - # this is helpful if copying over the identity to a non-mycroft device - # eg, selene call out proxy in local backend - IdentityManager.set_identity_file(self._identity_file) - return IdentityManager.get() - - @property - def uuid(self): - return self.identity.uuid - - @property - def headers(self): - return {"Content-Type": "application/json", - "Device": self.identity.uuid, - "Authorization": f"Bearer {self.identity.access}"} - - def check_token(self): - if self.identity.is_expired(): - self.refresh_token() - - def refresh_token(self): - LOG.debug('Refreshing token') - if identity_lock.acquire(blocking=False): - try: - data = requests.get(self.backend_url + f"/{self.backend_version}/auth/token", - headers=self.headers).json() - IdentityManager.save(data, lock=False) - LOG.debug('Saved credentials') - except HTTPError as e: - if e.response.status_code == 401: - LOG.error('Could not refresh token, invalid refresh code.') - else: - raise - - finally: - identity_lock.release() - - def get(self, url=None, *args, **kwargs): - url = url or self.url - headers = kwargs.get("headers", {}) - headers.update(self.headers) - self.check_token() - return requests.get(url, headers=headers, timeout=(3.05, 15), *args, **kwargs) - - def post(self, url=None, *args, **kwargs): - url = url or self.url - headers = kwargs.get("headers", {}) - headers.update(self.headers) - self.check_token() - return requests.post(url, headers=headers, timeout=(3.05, 15), *args, **kwargs) - - def put(self, url=None, *args, **kwargs): - url = url or self.url - headers = kwargs.get("headers", {}) - headers.update(self.headers) - self.check_token() - return requests.put(url, headers=headers, timeout=(3.05, 15), *args, **kwargs) - - def patch(self, url=None, *args, **kwargs): - url = url or self.url - headers = kwargs.get("headers", {}) - headers.update(self.headers) - self.check_token() - return requests.patch(url, headers=headers, timeout=(3.05, 15), *args, **kwargs) - - -class AdminApi(BaseApi): - def __init__(self, admin_key, url=None, version="v1", identity_file=None): - self.admin_key = admin_key - super().__init__(url, version, identity_file) - self.url = f"{self.backend_url}/{self.backend_version}/admin" - - @property - def headers(self): - return {"Content-Type": "application/json", - "Device": self.identity.uuid, - "Authorization": f"Bearer {self.admin_key}"} - - def pair(self, uuid): - identity = self.get(f"{self.url}/{uuid}/pair") - # save identity file - self.identity.save(identity) - return identity - - def set_device_location(self, uuid, loc): - """ - loc = { - "city": { - "code": "Lawrence", - "name": "Lawrence", - "state": { - "code": "KS", - "name": "Kansas", - "country": { - "code": "US", - "name": "United States" - } - } - }, - "coordinate": { - "latitude": 38.971669, - "longitude": -95.23525 - }, - "timezone": { - "code": "America/Chicago", - "name": "Central Standard Time", - "dstOffset": 3600000, - "offset": -21600000 - } - } - """ - return self.put(f"{self.url}/{uuid}/location", - json=loc) - - def set_device_prefs(self, uuid, prefs): - """ - prefs = {"time_format": "full", - "date_format": "DMY", - "system_unit": "metric", - "lang": "en-us", - "wake_word": "hey_mycroft", - "ww_config": {"phonemes": "HH EY . M AY K R AO F T", - "module": "ovos-ww-plugin-pocketsphinx", - "threshold": 1e-90}, - "tts_module": "ovos-tts-plugin-mimic", - "tts_config": {"voice": "ap"}} - """ - return self.put(f"{self.url}/{uuid}/prefs", - json=prefs) - - def set_device_info(self, uuid, info): - """ - info = {"opt_in": True, - "name": "my_device", - "device_location": "kitchen", - "email": "notifications@me.com", - "isolated_skills": False, - "lang": "en-us"} - """ - return self.put(f"{self.url}/{uuid}/device", - json=info) - - -class DeviceApi(BaseApi): - def __init__(self, url=None, version="v1", identity_file=None): - super().__init__(url, version, identity_file) - self.url = f"{self.backend_url}/{self.backend_version}/device" - config = Configuration().get("listener", {}).get("wake_word_upload", {}) - self.precise_url_v1 = config.get("url") or f"https://training.mycroft.ai/precise/upload" - - def get(self, url=None, *args, **kwargs): - """ Retrieve all device information from the web backend """ - url = url or self.url - return super().get(url + "/" + self.uuid).json() - - def get_skill_settings_v1(self): - """ old style deprecated bidirectional skill settings api, still available! """ - return super().get(self.url + "/" + self.uuid + "/skill").json() - - def put_skill_settings_v1(self, data): - """ old style deprecated bidirectional skill settings api, still available! """ - return super().put(self.url + "/" + self.uuid + "/skill", json=data).json() - - def get_settings(self): - """ Retrieve device settings information from the web backend - - Returns: - str: JSON string with user configuration information. - """ - return super().get(self.url + "/" + self.uuid + "/setting").json() - - def get_code(self, state=None): - state = state or self.uuid - return super().get(self.url + "/code", params={"state": state}).json() - - def activate(self, state, token, - core_version="unknown", - platform="unknown", - platform_build="unknown", - enclosure_version="unknown"): - data = {"state": state, - "token": token, - "coreVersion": core_version, - "platform": platform, - "platform_build": platform_build, - "enclosureVersion": enclosure_version} - return self.post(self.url + "/activate", json=data).json() - - def update_version(self, - core_version="unknown", - platform="unknown", - platform_build="unknown", - enclosure_version="unknown"): - data = {"coreVersion": core_version, - "platform": platform, - "platform_build": platform_build, - "enclosureVersion": enclosure_version} - return self.patch(self.url + "/" + self.uuid, json=data) - - def report_metric(self, name, data): - return self.post(self.url + "/" + self.uuid + "/metric/" + name, - json=data) - - def get_location(self): - """ Retrieve device location information from the web backend - - Returns: - str: JSON string with user location. - """ - return super().get(self.url + "/" + self.uuid + "/location").json() - - def get_subscription(self): - """ - Get information about type of subscription this unit is connected - to. - - Returns: dictionary with subscription information - """ - return super().get(self.url + "/" + self.uuid + "/subscription").json() - - @property - def is_subscriber(self): - """ - status of subscription. True if device is connected to a paying - subscriber. - """ - try: - return self.get_subscription().get('@type') != 'free' - except Exception: - # If can't retrieve, assume not paired and not a subscriber yet - return False - - def get_subscriber_voice_url(self, voice=None, arch=None): - archs = {'x86_64': 'x86_64', 'armv7l': 'arm', 'aarch64': 'arm'} - arch = arch or archs.get(os.uname()[4]) - if arch: - path = '/' + self.uuid + '/voice?arch=' + arch - return super().get(self.url + path).json().get('link') - - def get_oauth_token(self, dev_cred): - """ - Get Oauth token for dev_credential dev_cred. - - Argument: - dev_cred: development credentials identifier - - Returns: - json string containing token and additional information - """ - return super().get(self.url + "/" + self.uuid + "/token/" + str(dev_cred)).json() - - # cached for 30 seconds because often 1 call per skill is done in quick succession - @timed_lru_cache(seconds=30) - def get_skill_settings(self): - """Get the remote skill settings for all skills on this device.""" - return super().get(self.url + "/" + self.uuid + "/skill/settings").json() - - def send_email(self, title, body, sender): - return self.put(self.url + "/" + self.uuid + "/message", - json={"title": title, - "body": body, - "sender": sender}).json() - - def upload_skill_metadata(self, settings_meta): - """Upload skill metadata. - - Args: - settings_meta (dict): skill info and settings in JSON format - """ - return self.put(url=self.url + "/" + self.uuid + "/settingsMeta", - json=settings_meta) - - def upload_skills_data(self, data): - """ Upload skills.json file. This file contains a manifest of installed - and failed installations for use with the Marketplace. - - Args: - data: dictionary with skills data from msm - """ - if not isinstance(data, dict): - raise ValueError('data must be of type dict') - - _data = dict(data) # Make sure the input data isn't modified - # Strip the skills.json down to the bare essentials - to_send = {'skills': []} - if 'blacklist' in _data: - to_send['blacklist'] = _data['blacklist'] - else: - LOG.warning('skills manifest lacks blacklist entry') - to_send['blacklist'] = [] - - # Make sure skills doesn't contain duplicates (keep only last) - if 'skills' in _data: - skills = {s['name']: s for s in _data['skills']} - to_send['skills'] = [skills[key] for key in skills] - else: - LOG.warning('skills manifest lacks skills entry') - to_send['skills'] = [] - - for s in to_send['skills']: - # Remove optional fields backend objects to - if 'update' in s: - s.pop('update') - - # Finalize skill_gid with uuid if needed - s['skill_gid'] = s.get('skill_gid', '').replace('@|', f'@{self.uuid}|') - return self.put(url=self.url + "/" + self.uuid + "/skillJson", - json=to_send) - - def upload_wake_word_v1(self, audio, params): - """ upload precise wake word V1 endpoint - DEPRECATED""" - return self.post(self.precise_url_v1, files={ - 'audio': BytesIO(audio.get_wav_data()), - 'metadata': StringIO(json.dumps(params)) - }) - - def upload_wake_word(self, audio, params): - """ upload precise wake word V2 endpoint """ - url = f"{self.url}/{self.uuid}/wake-word-file" - request_data = dict( - wake_word=params['name'], - engine=params.get('engine_name') or params.get('engine'), - timestamp=params.get('timestamp') or params.get('time') or str(int(1000 * time.time())), - model=params['model'] - ) - - return self.post(url, files={ - 'audio': BytesIO(audio.get_wav_data()), - 'metadata': StringIO(json.dumps(request_data)) - }) - - -class STTApi(BaseApi): - def __init__(self, url=None, version="v1", identity_file=None): - super().__init__(url, version, identity_file) - self.url = f"{self.backend_url}/{self.backend_version}/stt" - - @property - def headers(self): - return {"Content-Type": "audio/x-flac", - "Device": self.identity.uuid, - "Authorization": f"Bearer {self.identity.access}"} - - def stt(self, audio, language="en-us", limit=1): - """ Web API wrapper for performing Speech to Text (STT) - - Args: - audio (bytes): The recorded audio, as in a FLAC file - language (str): A BCP-47 language code, e.g. "en-US" - limit (int): Maximum minutes to transcribe(?) - - Returns: - dict: JSON structure with transcription results - """ - data = self.post(data=audio, params={"lang": language, "limit": limit}) - if data.status_code == 200: - return data.json() - raise RuntimeError(f"STT api failed, status_code {data.status_code}") - - -class GeolocationApi(BaseApi): - """Web API wrapper for performing geolocation lookups.""" - - def __init__(self, url=None, version="v1", identity_file=None): - super().__init__(url, version, identity_file) - self.url = f"{self.backend_url}/{self.backend_version}/geolocation" - - def get_geolocation(self, location): - """Call the geolocation endpoint. - - Args: - location (str): the location to lookup (e.g. Kansas City Missouri) - - Returns: - str: JSON structure with lookup results - """ - response = self.get(params={"location": location}).json() - return response['data'] - - -class WolframAlphaApi(BaseApi): - - def __init__(self, url=None, version="v1", identity_file=None): - super().__init__(url, version, identity_file) - self.url = f"{self.backend_url}/{self.backend_version}/wolframAlpha" - - # cached to save api calls, wolfram answer wont change often - @timed_lru_cache(seconds=60 * 30) - def spoken(self, query, units="metric", lat_lon=None, optional_params=None): - optional_params = optional_params or {} - # default to location configured in selene - if not lat_lon: - loc = DeviceApi(url=self.backend_url, version=self.backend_version).get_location() - lat_lon = (loc['coordinate']['latitude'], loc['coordinate']['longitude']) - url = f"{self.url}Spoken" - params = {'i': query, - 'units': units, - "geolocation": "{},{}".format(*lat_lon), - **optional_params} - data = self.get(url=url, params=params) - return data.text - - @timed_lru_cache(seconds=60 * 30) - def simple(self, query, units="metric", lat_lon=None, optional_params=None): - optional_params = optional_params or {} - # default to location configured in selene - if not lat_lon: - loc = DeviceApi(url=self.backend_url, version=self.backend_version).get_location() - lat_lon = (loc['coordinate']['latitude'], loc['coordinate']['longitude']) - url = f"{self.url}Simple" - params = {'i': query, - 'units': units, - "geolocation": "{},{}".format(*lat_lon), - **optional_params} - data = self.get(url=url, params=params) - return data.text - - @timed_lru_cache(seconds=60 * 30) - def full_results(self, query, units="metric", lat_lon=None, optional_params=None): - """Wrapper for the WolframAlpha Full Results v2 API. - https://products.wolframalpha.com/api/documentation/ - Pods of interest - - Input interpretation - Wolfram's determination of what is being asked about. - - Name - primary name of - """ - optional_params = optional_params or {} - # default to location configured in selene - if not lat_lon: - loc = DeviceApi(url=self.backend_url, version=self.backend_version).get_location() - lat_lon = (loc['coordinate']['latitude'], loc['coordinate']['longitude']) - - params = {'input': query, - "units": units, - "geolocation": "{},{}".format(*lat_lon), - "mode": "Default", - "format": "image,plaintext", - "output": "json", - **optional_params} - url = f"{self.url}Full" - data = self.get(url=url, params=params) - return data.json() - - -class OpenWeatherMapApi(BaseApi): - """Use Open Weather Map's One Call API to retrieve weather information""" - - def __init__(self, url=None, version="v1", identity_file=None): - super().__init__(url, version, identity_file) - self.url = f"{self.backend_url}/{self.backend_version}/owm" - - @staticmethod - def owm_language(lang: str): - """ - OWM supports 31 languages, see https://openweathermap.org/current#multi - - Convert Mycroft's language code to OpenWeatherMap's, if missing use english. - - Args: - language_config: The Mycroft language code. - """ - OPEN_WEATHER_MAP_LANGUAGES = ( - "af", "al", "ar", "bg", "ca", "cz", "da", "de", "el", "en", "es", "eu", "fa", "fi", "fr", "gl", "he", "hi", - "hr", "hu", "id", "it", "ja", "kr", "la", "lt", "mk", "nl", "no", "pl", "pt", "pt_br", "ro", "ru", "se", - "sk", - "sl", "sp", "sr", "sv", "th", "tr", "ua", "uk", "vi", "zh_cn", "zh_tw", "zu" - ) - special_cases = {"cs": "cz", "ko": "kr", "lv": "la"} - lang_primary, lang_subtag = lang.split('-') - if lang.replace('-', '_') in OPEN_WEATHER_MAP_LANGUAGES: - return lang.replace('-', '_') - if lang_primary in OPEN_WEATHER_MAP_LANGUAGES: - return lang_primary - if lang_subtag in OPEN_WEATHER_MAP_LANGUAGES: - return lang_subtag - if lang_primary in special_cases: - return special_cases[lang_primary] - return "en" - - # cached to save api calls, owm only updates data every 15mins or so - @timed_lru_cache(seconds=60 * 10) - def get_weather(self, lat_lon=None, lang="en-us", units="metric"): - """Issue an API call and map the return value into a weather report - - Args: - units (str): metric or imperial measurement units - lat_lon (tuple): the geologic (latitude, longitude) of the weather location - """ - # default to location configured in selene - if not lat_lon: - loc = DeviceApi(url=self.backend_url, version=self.backend_version).get_location() - lat = loc['coordinate']['latitude'] - lon = loc['coordinate']['longitude'] - else: - lat, lon = lat_lon - response = self.get(url=self.url + "/onecall", - params={ - "lang": self.owm_language(lang), - "lat": lat, - "lon": lon, - "units": units}) - return response.json() - - @timed_lru_cache(seconds=60 * 10) - def get_current(self, lat_lon=None, lang="en-us", units="metric"): - """Issue an API call and map the return value into a weather report - - Args: - units (str): metric or imperial measurement units - lat_lon (tuple): the geologic (latitude, longitude) of the weather location - """ - # default to location configured in selene - if not lat_lon: - loc = DeviceApi(url=self.backend_url, version=self.backend_version).get_location() - lat = loc['coordinate']['latitude'] - lon = loc['coordinate']['longitude'] - else: - lat, lon = lat_lon - response = self.get(url=self.url + "/weather", - params={ - "lang": self.owm_language(lang), - "lat": lat, - "lon": lon, - "units": units}) - return response.json() - - @timed_lru_cache(seconds=60 * 10) - def get_hourly(self, lat_lon=None, lang="en-us", units="metric"): - """Issue an API call and map the return value into a weather report - - Args: - units (str): metric or imperial measurement units - lat_lon (tuple): the geologic (latitude, longitude) of the weather location - """ - # default to location configured in selene - if not lat_lon: - loc = DeviceApi(url=self.backend_url, version=self.backend_version).get_location() - lat = loc['coordinate']['latitude'] - lon = loc['coordinate']['longitude'] - else: - lat, lon = lat_lon - response = self.get(url=self.url + "/forecast", - params={ - "lang": self.owm_language(lang), - "lat": lat, - "lon": lon, - "units": units}) - return response.json() - - @timed_lru_cache(seconds=60 * 10) - def get_daily(self, lat_lon=None, lang="en-us", units="metric"): - """Issue an API call and map the return value into a weather report - - Args: - units (str): metric or imperial measurement units - lat_lon (tuple): the geologic (latitude, longitude) of the weather location - """ - # default to location configured in selene - if not lat_lon: - loc = DeviceApi(url=self.backend_url, version=self.backend_version).get_location() - lat = loc['coordinate']['latitude'] - lon = loc['coordinate']['longitude'] - else: - lat, lon = lat_lon - response = self.get(url=self.url + "/forecast/daily", - params={ - "lang": self.owm_language(lang), - "lat": lat, - "lon": lon, - "units": units}) - return response.json() - - -if __name__ == "__main__": - d = DeviceApi("http://0.0.0.0:6712") - - # TODO turn these into unittests - # ident = load_identity() - # paired = is_paired() - geo = GeolocationApi("http://0.0.0.0:6712") - data = geo.get_geolocation("Lisbon Portugal") - print(data) - wolf = WolframAlphaApi("http://0.0.0.0:6712") - data = wolf.spoken("what is the speed of light") - print(data) - data = wolf.full_results("2+2") - print(data) - owm = OpenWeatherMapApi("http://0.0.0.0:6712") - data = owm.get_weather() - print(data) diff --git a/setup.py b/setup.py index 87dda8e..eb77f9d 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ def get_version(): """ Find the version of the package""" version = None - version_file = os.path.join(BASEDIR, 'selene_api', 'version.py') + version_file = os.path.join(BASEDIR, 'ovos_backend_client', 'version.py') major, minor, build, alpha = (None, None, None, None) with open(version_file) as f: for line in f: @@ -49,13 +49,16 @@ def required(requirements_file): setup( - name='selene_api', + name='ovos-backend-client', version=get_version(), - packages=['selene_api'], - url='https://github.com/OpenVoiceOS/selene_api', + packages=['ovos_backend_client', 'ovos_backend_client.backends'], + url='https://github.com/OpenVoiceOS/ovos-backend-client', license='Apache2', author='jarbasai', - install_requires=required("requirements.txt"), + install_requires=required("requirements/requirements.txt"), + extras_require={ + 'offline': required('requirements/offline.txt') + }, author_email='jarbasai@mailfence.com', - description='unofficial api for selene backend' + description='api client for supported ovos-core backends' ) diff --git a/test/license_tests.py b/test/license_tests.py index 82602c1..5920eca 100644 --- a/test/license_tests.py +++ b/test/license_tests.py @@ -22,7 +22,7 @@ allow_unlicense = True allow_ambiguous = False -pkg_name = "selene_api" +pkg_name = "ovos_backend_client" class TestLicensing(unittest.TestCase): diff --git a/test/test.wav b/test/test.wav new file mode 100644 index 0000000000000000000000000000000000000000..80893f35a8489cf78ecba116b5edb03f5e38a844 GIT binary patch literal 104526 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Ñ(FP6b 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 literal 0 HcmV?d00001 diff --git a/test/unittests/test_selene_api.py b/test/unittests/test_selene_api.py index de2d7eb..a30c54f 100644 --- a/test/unittests/test_selene_api.py +++ b/test/unittests/test_selene_api.py @@ -13,12 +13,13 @@ # limitations under the License. # import unittest -from copy import copy -import selene_api -import selene_api.pairing +import ovos_backend_client +from ovos_backend_client.backends import BackendType +import ovos_backend_client.backends +import ovos_backend_client.pairing from unittest.mock import MagicMock, patch -selene_api.api.requests.post = MagicMock() +ovos_backend_client.backends.base.requests.post = MagicMock() def create_identity(uuid, expired=False): @@ -39,47 +40,51 @@ def create_response(status, json=None, url='', data=''): class TestDeviceApi(unittest.TestCase): - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.request') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.request') def test_init(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200) mock_identity_get.return_value = create_identity('1234') - device = selene_api.api.DeviceApi(url="https://api-test.mycroft.ai") + device = ovos_backend_client.api.DeviceApi(url="https://api-test.mycroft.ai", + backend_type=BackendType.SELENE) self.assertEqual(device.identity.uuid, '1234') self.assertTrue(device.url.endswith("/device")) - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.post') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.post') def test_device_activate(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200) mock_identity_get.return_value = create_identity('1234') # Test activate - device = selene_api.api.DeviceApi(url="https://api-test.mycroft.ai") + device = ovos_backend_client.api.DeviceApi(url="https://api-test.mycroft.ai", + backend_type=BackendType.SELENE) device.activate('state', 'token') json = mock_request.call_args[1]['json'] self.assertEqual(json['state'], 'state') self.assertEqual(json['token'], 'token') - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.get') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.get') def test_device_get(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200) mock_identity_get.return_value = create_identity('1234') # Test get - device = selene_api.api.DeviceApi(url="https://api-test.mycroft.ai") + device = ovos_backend_client.api.DeviceApi(url="https://api-test.mycroft.ai", + backend_type=BackendType.SELENE) device.get() url = mock_request.call_args[0][0] self.assertEqual(url, 'https://api-test.mycroft.ai/v1/device/1234') - @patch('selene_api.identity.IdentityManager.update') - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.get') + @patch('ovos_backend_client.identity.IdentityManager.update') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.get') def test_device_get_code(self, mock_request, mock_identity_get, mock_identit_update): mock_request.return_value = create_response(200, '123ABC') mock_identity_get.return_value = create_identity('1234') - device = selene_api.api.DeviceApi(url="https://api-test.mycroft.ai") + device = ovos_backend_client.api.DeviceApi(url="https://api-test.mycroft.ai", + backend_type=BackendType.SELENE) ret = device.get_code('state') self.assertEqual(ret, '123ABC') url = mock_request.call_args[0][0] @@ -87,23 +92,25 @@ def test_device_get_code(self, mock_request, mock_identity_get, self.assertEqual(url, 'https://api-test.mycroft.ai/v1/device/code') self.assertEqual(params["params"], {"state": "state"}) - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.get') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.get') def test_device_get_settings(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200, {}) mock_identity_get.return_value = create_identity('1234') - device = selene_api.api.DeviceApi(url="https://api-test.mycroft.ai") + device = ovos_backend_client.api.DeviceApi(url="https://api-test.mycroft.ai", + backend_type=BackendType.SELENE) device.get_settings() url = mock_request.call_args[0][0] self.assertEqual( url, 'https://api-test.mycroft.ai/v1/device/1234/setting') - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.post') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.post') def test_device_report_metric(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200, {}) mock_identity_get.return_value = create_identity('1234') - device = selene_api.api.DeviceApi(url="https://api-test.mycroft.ai") + device = ovos_backend_client.api.DeviceApi(url="https://api-test.mycroft.ai", + backend_type=BackendType.SELENE) device.report_metric('mymetric', {'data': 'mydata'}) url = mock_request.call_args[0][0] params = mock_request.call_args[1] @@ -115,12 +122,13 @@ def test_device_report_metric(self, mock_request, mock_identity_get): self.assertEqual( url, 'https://api-test.mycroft.ai/v1/device/1234/metric/mymetric') - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.put') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.put') def test_device_send_email(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200, {}) mock_identity_get.return_value = create_identity('1234') - device = selene_api.api.DeviceApi(url="https://api-test.mycroft.ai") + device = ovos_backend_client.api.DeviceApi(url="https://api-test.mycroft.ai", + backend_type=BackendType.SELENE) device.send_email('title', 'body', 'sender') url = mock_request.call_args[0][0] params = mock_request.call_args[1] @@ -132,35 +140,38 @@ def test_device_send_email(self, mock_request, mock_identity_get): self.assertEqual( url, 'https://api-test.mycroft.ai/v1/device/1234/message') - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.get') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.get') def test_device_get_oauth_token(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200, {}) mock_identity_get.return_value = create_identity('1234') - device = selene_api.api.DeviceApi(url="https://api-test.mycroft.ai") + device = ovos_backend_client.api.DeviceApi(url="https://api-test.mycroft.ai", + backend_type=BackendType.SELENE) device.get_oauth_token(1) url = mock_request.call_args[0][0] self.assertEqual( url, 'https://api-test.mycroft.ai/v1/device/1234/token/1') - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.get') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.get') def test_device_get_location(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200, {}) mock_identity_get.return_value = create_identity('1234') - device = selene_api.api.DeviceApi(url="https://api-test.mycroft.ai") + device = ovos_backend_client.api.DeviceApi(url="https://api-test.mycroft.ai", + backend_type=BackendType.SELENE) device.get_location() url = mock_request.call_args[0][0] self.assertEqual( url, 'https://api-test.mycroft.ai/v1/device/1234/location') - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.get') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.get') def test_device_get_subscription(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200, {}) mock_identity_get.return_value = create_identity('1234') - device = selene_api.api.DeviceApi(url="https://api-test.mycroft.ai") + device = ovos_backend_client.api.DeviceApi(url="https://api-test.mycroft.ai", + backend_type=BackendType.SELENE) device.get_subscription() url = mock_request.call_args[0][0] self.assertEqual( @@ -175,12 +186,13 @@ def test_device_get_subscription(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200, {'@type': 'yearly'}) self.assertTrue(device.is_subscriber) - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.put') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.put') def test_device_upload_skills_data(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200) mock_identity_get.return_value = create_identity('1234') - device = selene_api.api.DeviceApi(url="https://api-test.mycroft.ai") + device = ovos_backend_client.api.DeviceApi(url="https://api-test.mycroft.ai", + backend_type=BackendType.SELENE) device.upload_skills_data({}) url = mock_request.call_args[0][0] data = mock_request.call_args[1]['json'] @@ -196,20 +208,20 @@ def test_device_upload_skills_data(self, mock_request, mock_identity_get): with self.assertRaises(ValueError): device.upload_skills_data('This isn\'t right at all') - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.get') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.get') def test_stt(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200, {}) mock_identity_get.return_value = create_identity('1234') - stt = selene_api.api.STTApi('stt') + stt = ovos_backend_client.api.STTApi('stt', backend_type=BackendType.SELENE) self.assertTrue(stt.url.endswith('stt')) - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.post') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.post') def test_stt_stt(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200, {}) mock_identity_get.return_value = create_identity('1234') - stt = selene_api.api.STTApi('https://api-test.mycroft.ai') + stt = ovos_backend_client.api.STTApi('https://api-test.mycroft.ai', backend_type=BackendType.SELENE) stt.stt('La la la', 'en-US', 1) url = mock_request.call_args[0][0] self.assertEqual(url, 'https://api-test.mycroft.ai/v1/stt') @@ -218,30 +230,31 @@ def test_stt_stt(self, mock_request, mock_identity_get): params = mock_request.call_args[1].get('params') self.assertEqual(params['lang'], 'en-US') - @patch('selene_api.identity.IdentityManager.load') + @patch('ovos_backend_client.identity.IdentityManager.load') def test_has_been_paired(self, mock_identity_load): # reset pairing cache mock_identity = MagicMock() mock_identity_load.return_value = mock_identity # Test None mock_identity.uuid = None - self.assertFalse(selene_api.pairing.has_been_paired()) + self.assertFalse(ovos_backend_client.pairing.has_been_paired()) # Test empty string mock_identity.uuid = "" - self.assertFalse(selene_api.pairing.has_been_paired()) + self.assertFalse(ovos_backend_client.pairing.has_been_paired()) # Test actual id number mock_identity.uuid = "1234" - self.assertTrue(selene_api.pairing.has_been_paired()) + self.assertTrue(ovos_backend_client.pairing.has_been_paired()) class TestSettingsMeta(unittest.TestCase): - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.put') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.put') def test_upload_meta(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200, {}) mock_identity_get.return_value = create_identity('1234') - device = selene_api.api.DeviceApi(url="https://api-test.mycroft.ai") + device = ovos_backend_client.api.DeviceApi(url="https://api-test.mycroft.ai", + backend_type=BackendType.SELENE) settings_meta = { 'name': 'TestMeta', @@ -271,12 +284,13 @@ def test_upload_meta(self, mock_request, mock_identity_get): self.assertEqual( url, 'https://api-test.mycroft.ai/v1/device/1234/settingsMeta') - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.get') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.get') def test_get_skill_settings(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200, {}) mock_identity_get.return_value = create_identity('1234') - device = selene_api.api.DeviceApi(url="https://api-test.mycroft.ai") + device = ovos_backend_client.api.DeviceApi(url="https://api-test.mycroft.ai", + backend_type=BackendType.SELENE) device.get_skill_settings() url = mock_request.call_args[0][0] params = mock_request.call_args[1] @@ -285,10 +299,10 @@ def test_get_skill_settings(self, mock_request, mock_identity_get): url, 'https://api-test.mycroft.ai/v1/device/1234/skill/settings') -@patch('selene_api.pairing._paired_cache', False) +@patch('ovos_backend_client.pairing._paired_cache', False) class TestIsPaired(unittest.TestCase): - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.get') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.get') def test_is_paired_true(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200) mock_identity = MagicMock() @@ -298,14 +312,14 @@ def test_is_paired_true(self, mock_request, mock_identity_get): num_calls = mock_identity_get.num_calls # reset paired cache - self.assertTrue(selene_api.pairing.is_paired()) + self.assertTrue(ovos_backend_client.pairing.is_paired()) self.assertEqual(num_calls, mock_identity_get.num_calls) url = mock_request.call_args[0][0] self.assertTrue(url.endswith('/v1/device/1234')) - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.get') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.get') def test_is_paired_false_local(self, mock_request, mock_identity_get): mock_request.return_value = create_response(200) mock_identity = MagicMock() @@ -313,25 +327,25 @@ def test_is_paired_false_local(self, mock_request, mock_identity_get): mock_identity.uuid = '' mock_identity_get.return_value = mock_identity - self.assertFalse(selene_api.pairing.is_paired()) + self.assertFalse(ovos_backend_client.pairing.is_paired()) mock_identity.uuid = None - self.assertFalse(selene_api.pairing.is_paired()) + self.assertFalse(ovos_backend_client.pairing.is_paired()) @unittest.skip("TODO - refactor/fix test") - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.get') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.get') def test_is_paired_false_remote(self, mock_request, mock_identity_get): mock_request.return_value = create_response(401) mock_identity = MagicMock() mock_identity.is_expired.return_value = False mock_identity.uuid = '1234' mock_identity_get.return_value = mock_identity - selene_api.pairing._paired_cache = False - self.assertFalse(selene_api.pairing.is_paired()) + ovos_backend_client.pairing._paired_cache = False + self.assertFalse(ovos_backend_client.pairing.is_paired()) @unittest.skip("TODO - refactor/fix test") - @patch('selene_api.identity.IdentityManager.get') - @patch('selene_api.api.requests.get') + @patch('ovos_backend_client.identity.IdentityManager.get') + @patch('ovos_backend_client.backends.base.requests.get') def test_is_paired_error_remote(self, mock_request, mock_identity_get): mock_request.return_value = create_response(500) mock_identity = MagicMock() @@ -339,7 +353,7 @@ def test_is_paired_error_remote(self, mock_request, mock_identity_get): mock_identity.uuid = '1234' mock_identity_get.return_value = mock_identity - self.assertFalse(selene_api.pairing.is_paired()) + self.assertFalse(ovos_backend_client.pairing.is_paired()) - with self.assertRaises(selene_api.exceptions.BackendDown): - selene_api.pairing.is_paired(ignore_errors=False) + with self.assertRaises(ovos_backend_client.exceptions.BackendDown): + ovos_backend_client.pairing.is_paired(ignore_errors=False)