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 0000000..80893f3 Binary files /dev/null and b/test/test.wav differ 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)