From 9716ea846e534dbe77ae0543966b93cb8433bd32 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Fri, 9 Sep 2022 14:45:30 +0100 Subject: [PATCH] feat/selene_callout_proxy (#29) * feat/selene_callout_proxy * better optin handling * proxy_pairing - save identity proxy_pairing + settings_sync * cleanup cleanup bump selene_api move selene helpers to selene util module move selene helpers to selene util module * cleanup * cleanup * readme * per device opt_in * sharedskillsettings db for selene download * feat/forced 2 way sync * fix metaupload * "proxy_email": False * readme --- README.md | 125 ++++++++++- ovos_local_backend/backend/decorators.py | 21 +- ovos_local_backend/backend/device.py | 121 ++++++++-- ovos_local_backend/backend/external_apis.py | 126 ++++++++--- ovos_local_backend/backend/precise.py | 11 +- ovos_local_backend/backend/stt.py | 11 +- ovos_local_backend/configuration.py | 59 ++++- ovos_local_backend/utils/selene.py | 234 ++++++++++++++++++++ requirements/requirements.txt | 3 +- 9 files changed, 644 insertions(+), 67 deletions(-) create mode 100644 ovos_local_backend/utils/selene.py diff --git a/README.md b/README.md index eb40020..a3281b8 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,60 @@ By default admin api is disabled, to enable it add `"admin_key": "unique_super_s you need to provide that key in the request headers for [admin endpoints](./ovos_local_backend/backend/admin.py) -TODO - [selene_api](https://github.com/OpenVoiceOS/selene_api) support +```python +from selene_api.api import AdminApi + +admin = AdminApi("secret_admin_key") + +uuid = "..." # check identity2.json in the device you want to manage + +# manually pair a device +identity_json = admin.pair(uuid) + +# set device info +info = {"opt_in": True, + "name": "my_device", + "device_location": "kitchen", + "email": "notifications@me.com", + "isolated_skills": False, + "lang": "en-us"} +admin.set_device_info(uuid, info) + +# set device preferences +prefs = {"time_format": "full", + "date_format": "DMY", + "system_unit": "metric", + "lang": "en-us"} +admin.set_device_prefs(uuid, prefs) + +# set location data +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 + } +} +admin.set_device_location(uuid, loc) +``` + ## Device Settings @@ -196,6 +249,76 @@ with the local backend you need to configure your own SMTP server and recipient If using gmail you will need to [enable less secure apps](https://hotter.io/docs/email-accounts/secure-app-gmail/) + +## Selene Proxy + +You can integrate local backend with selene, the backend will show up as a device you can manage in mycroft.home + +wait... what? isn't the point of local backend to disable selene? + +- Open Dataset, You do not want to use selene, but you want to opt_in to the open dataset (share recordings with mycroft) +- Privacy, you want to use selene, but you do not want to give away your personal data (email, location, ip address...) +- Control, you want to use only a subset of selene features +- Convenience, pair once, manage all your devices +- Functionality, extra features such as isolated skill settings and forced 2 way sync +- Esoteric Setups, isolated mycroft services that can not share a identity file, such as [ovos-qubes](https://github.com/OpenVoiceOS/ovos-qubes) + +### Pairing + +To pair the local backend with selene you have 2 options + +1 - pair a mycroft-core instance, then copy the identity file + +2 - enable proxy_pairing, whenever a device pairs with local backend the code it speaks is also valid for selene, use that code to pair local backend with selene + +If a device tries to use a selene enabled endpoint without the backend being paired a 401 authentication error will be returned, if the endpoint does not use selene (eg. disabled in config) this check is skipped +### Selene Config + +In your backend config add the following section + +```python + "selene": { + "enabled": False, # needs to be explicitly enabled by user + "url": "https://api.mycroft.ai", # change if you are self hosting selene + "version": "v1", + # pairing settings + # NOTE: the file should be used exclusively by backend, do not share with a mycroft-core instance + "identity_file": BACKEND_IDENTITY, # path to identity2.json file + # send the pairing from selene to any device that attempts to pair with local backend + # this will provide voice/gui prompts to the user and avoid the need to copy a identity file + # only happens if backend is not paired with selene (hopefully exactly once) + # if False you need to pair an existing mycroft-core as usual and move the file for backend usage + "proxy_pairing": False, + + # micro service settings + # NOTE: STT is handled at plugin level, configure ovos-stt-plugin-selene + "proxy_weather": True, # use selene for weather api calls + "proxy_wolfram": True, # use selene for wolfram alpha api calls + "proxy_geolocation": True, # use selene for geolocation api calls + "proxy_email": False, # use selene for sending email (only for email registered in selene) + + # device settings - if you want to spoof data in selene set these to False + "download_location": True, # set default location from selene + "download_prefs": True, # set default device preferences from selene + "download_settings": True, # download shared skill settings from selene + "upload_settings": True, # upload shared skill settings to selene + "force2way": False, # this forcefully re-enables 2way settings sync with selene + # this functionality was removed from core, we hijack the settingsmeta endpoint to upload settings + # upload will happen when mycroft-core boots and overwrite any values in selene (no checks for settings changed) + # the assumption is that selene changes are downloaded instantaneously + # if a device is offline when selene changes those changes will be discarded on next device boot + + # opt-in settings - what data to share with selene + # NOTE: these also depend on opt_in being set in selene + "opt_in": False, # share data from all devices with selene (as if from a single device) + "opt_in_blacklist": [], # list of uuids that should ignore opt_in flag (never share data) + "upload_metrics": True, # upload device metrics to selene + "upload_wakewords": True, # upload wake word samples to selene + "upload_utterances": True # upload utterance samples to selene + } +``` + + ## Mycroft Setup There are 2 main intended ways to run local backend with mycroft diff --git a/ovos_local_backend/backend/decorators.py b/ovos_local_backend/backend/decorators.py index 8f5ac39..03e1d47 100644 --- a/ovos_local_backend/backend/decorators.py +++ b/ovos_local_backend/backend/decorators.py @@ -14,6 +14,8 @@ from flask import make_response, request, Response from ovos_local_backend.configuration import CONFIGURATION from ovos_local_backend.database.settings import DeviceDatabase +from ovos_local_backend.utils.selene import attempt_selene_pairing, requires_selene_pairing +from selene_api.pairing import is_paired def check_auth(uid, token): @@ -36,6 +38,24 @@ def decorated(*args, **kwargs): return decorated +def check_selene_pairing(f): + @wraps(f) + def decorated(*args, **kwargs): + if CONFIGURATION.get("selene", {}).get("proxy_pairing"): + attempt_selene_pairing() + requires_selene = requires_selene_pairing(f.__name__) + # check pairing with selene + if requires_selene and not is_paired(): + return Response( + 'Could not verify your access level for that URL.\n' + 'You have to pair ovos backend with selene first', 401, + {'WWW-Authenticate': 'Basic realm="BACKEND NOT PAIRED WITH SELENE"'}) + + return f(*args, **kwargs) + + return decorated + + def requires_auth(f): @wraps(f) def decorated(*args, **kwargs): @@ -90,4 +110,3 @@ def decorated_function(*args, **kwargs): def noindex(f): """This decorator passes X-Robots-Tag: noindex""" return add_response_headers({'X-Robots-Tag': 'noindex'})(f) - diff --git a/ovos_local_backend/backend/device.py b/ovos_local_backend/backend/device.py index 0738818..a0f7147 100644 --- a/ovos_local_backend/backend/device.py +++ b/ovos_local_backend/backend/device.py @@ -10,55 +10,90 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import json import time + from flask import request +from selene_api.api import DeviceApi +from selene_api.pairing import is_paired from ovos_local_backend.backend import API_VERSION -from ovos_local_backend.backend.decorators import noindex, requires_auth +from ovos_local_backend.backend.decorators import noindex, requires_auth, check_selene_pairing +from ovos_local_backend.configuration import CONFIGURATION from ovos_local_backend.database.metrics import save_metric from ovos_local_backend.database.settings import DeviceDatabase, SkillSettings, SettingsDatabase from ovos_local_backend.utils import generate_code, nice_json from ovos_local_backend.utils.geolocate import get_request_location from ovos_local_backend.utils.mail import send_email - +from ovos_local_backend.utils.selene import get_selene_code, selene_opted_in, download_selene_location, \ + download_selene_skill_settings, upload_selene_skill_settings, upload_selene_skill_settingsmeta, \ + download_selene_preferences, send_selene_email, report_selene_metric def get_device_routes(app): @app.route("/v1/device//settingsMeta", methods=['PUT']) + @check_selene_pairing @requires_auth def settingsmeta(uuid): """ new style skill settings meta (upload only) """ + s = SkillSettings.deserialize(request.json) + + # save new settings meta to db with SettingsDatabase() as db: - s = SkillSettings.deserialize(request.json) # keep old settings, update meta only old_s = db.get_setting(s.skill_id, uuid) if old_s: s.settings = old_s.settings db.add_setting(uuid, s.skill_id, s.settings, s.meta, s.display_name, s.remote_id) + + # upload settings meta to selene if enabled + selene_cfg = CONFIGURATION.get("selene") or {} + if selene_cfg.get("upload_settings"): + if selene_cfg.get("force2way"): + # forced 2 way sync + upload_selene_skill_settings(s.serialize()) + else: + upload_selene_skill_settingsmeta(s.meta) + return nice_json({"success": True, "uuid": uuid}) @app.route("/v1/device//skill/settings", methods=['GET']) + @check_selene_pairing @requires_auth def skill_settings_v2(uuid): """ new style skill settings (download only)""" + # get settings from selene if enabled + selene_cfg = CONFIGURATION.get("selene") or {} + if selene_cfg.get("download_settings"): + download_selene_skill_settings() + db = SettingsDatabase() return {s.skill_id: s.settings for s in db.get_device_settings(uuid)} @app.route("/v1/device//skill", methods=['GET', 'PUT']) + @check_selene_pairing @requires_auth def skill_settings(uuid): """ old style skill settings/settingsmeta - supports 2 way sync PUT - json for 1 skill GET - list of all skills """ + selene_cfg = CONFIGURATION.get("selene") or {} if request.method == 'PUT': + if selene_cfg.get("upload_settings"): + # upload settings to selene if enabled + upload_selene_skill_settings(request.json) + + # update local db with SettingsDatabase() as db: s = SkillSettings.deserialize(request.json) db.add_setting(uuid, s.skill_id, s.settings, s.meta, s.display_name, s.remote_id) return nice_json({"success": True, "uuid": uuid}) else: + if selene_cfg.get("download_settings"): + # get settings from selene if enabled + download_selene_skill_settings() + return nice_json([s.serialize() for s in SettingsDatabase().get_device_settings(uuid)]) @app.route("/v1/device//skillJson", methods=['PUT']) @@ -66,6 +101,8 @@ def skill_settings(uuid): def skill_json(uuid): """ device is communicating to the backend what skills are installed drop the info and don't track it! maybe if we add a UI and it becomes useful...""" + # TODO - do we care about sending this to selene? seems optional.... + # everything works in skill settings without using this data = request.json # {'blacklist': [], # 'skills': [{'name': 'fallback-unknown', @@ -80,17 +117,29 @@ def skill_json(uuid): @app.route("/" + API_VERSION + "/device//location", methods=['GET']) @requires_auth + @check_selene_pairing @noindex def location(uuid): + # get location from selene if enabled + selene_cfg = CONFIGURATION.get("selene") or {} + if selene_cfg.get("download_location"): + download_selene_location(uuid) + device = DeviceDatabase().get_device(uuid) if device: return device.location return get_request_location() @app.route("/" + API_VERSION + "/device//setting", methods=['GET']) + @check_selene_pairing @requires_auth @noindex def setting(uuid=""): + # get/update device preferences from selene if enabled + selene_cfg = CONFIGURATION.get("selene") or {} + if selene_cfg.get("download_prefs"): + download_selene_preferences(uuid) + device = DeviceDatabase().get_device(uuid) if device: return device.selene_settings @@ -108,10 +157,13 @@ def get_uuid(uuid): # 'platform_build': None, # 'enclosureVersion': None} return {} - with DeviceDatabase() as db: - device = db.get_device(uuid) - if device: - return device.selene_device + + # get from local db + device = DeviceDatabase().get_device(uuid) + if device: + return device.selene_device + + # dummy valid data token = request.headers.get('Authorization', '').replace("Bearer ", "") uuid = token.split(":")[-1] return { @@ -134,6 +186,17 @@ def code(): """ uuid = request.args["state"] code = generate_code() + + # if selene enabled and not paired return selene pairing code + # only ask it from selene once, return same code to all devices + # devices are only being used to prompt the user for action in backend + selene_cfg = CONFIGURATION.get("selene") or {} + if selene_cfg.get("enabled") and selene_cfg.get("proxy_pairing") and not is_paired(): + # pairing device with backend + backend with selene + # share spoken code for simplicity + code = get_selene_code() or code + + # pairing device with backend token = f"{code}:{uuid}" result = {"code": code, "uuid": uuid, "token": token, # selene api compat @@ -165,48 +228,72 @@ def activate(): @app.route("/" + API_VERSION + "/device//message", methods=['PUT']) @noindex + @check_selene_pairing @requires_auth def send_mail(uuid=""): + + data = request.json + skill_id = data["sender"] # TODO - auto append to body ? + + selene_cfg = CONFIGURATION.get("selene") or {} + if selene_cfg.get("enabled") and selene_cfg.get("proxy_email"): + return send_selene_email(data["title"], data["body"], skill_id) + target_email = None device = DeviceDatabase().get_device(uuid) if device: target_email = device.email - data = request.json - skill_id = data["sender"] # TODO - auto append to body ? send_email(data["title"], data["body"], target_email) @app.route("/" + API_VERSION + "/device//metric/", methods=['POST']) @noindex + @check_selene_pairing @requires_auth def metric(uuid="", name=""): data = request.json save_metric(uuid, name, data) - # TODO - share with upstream setting # contribute to mycroft metrics dataset - # may require https://github.com/OpenVoiceOS/OVOS-local-backend/issues/20 - uploaded = False - upload_data = {"uploaded": uploaded} + selene_cfg = CONFIGURATION.get("selene") or {} + if selene_cfg.get("upload_metrics"): + return report_selene_metric(name, data) return nice_json({"success": True, "uuid": uuid, "metric": data, - "upload_data": upload_data}) + "upload_data": {"uploaded": False}}) @app.route("/" + API_VERSION + "/device//subscription", methods=['GET']) @noindex @requires_auth def subscription_type(uuid=""): - sub_type = "free" - subscription = {"@type": sub_type} + # if selene enabled and paired check type in selene + selene_cfg = CONFIGURATION.get("selene") or {} + if selene_cfg.get("enabled") and is_paired(): + url = selene_cfg.get("url") + version = selene_cfg.get("version") or "v1" + identity_file = selene_cfg.get("identity_file") + api = DeviceApi(url, version, identity_file) + return api.get_subscription() + + subscription = {"@type": "free"} return nice_json(subscription) @app.route("/" + API_VERSION + "/device//voice", methods=['GET']) @noindex + @check_selene_pairing @requires_auth def get_subscriber_voice_url(uuid=""): arch = request.args["arch"] + # if selene enabled and paired return premium voice data + selene_cfg = CONFIGURATION.get("selene") or {} + if selene_cfg.get("enabled") and is_paired(): + url = selene_cfg.get("url") + version = selene_cfg.get("version") or "v1" + identity_file = selene_cfg.get("identity_file") + api = DeviceApi(url, version, identity_file) + return api.get_subscriber_voice_url(arch=arch) return nice_json({"link": "", "arch": arch}) return app diff --git a/ovos_local_backend/backend/external_apis.py b/ovos_local_backend/backend/external_apis.py index dbeeae1..a584f68 100644 --- a/ovos_local_backend/backend/external_apis.py +++ b/ovos_local_backend/backend/external_apis.py @@ -1,12 +1,13 @@ from flask import request from ovos_local_backend.backend import API_VERSION -from ovos_local_backend.backend.decorators import noindex, requires_auth +from ovos_local_backend.backend.decorators import noindex, requires_auth, check_selene_pairing from ovos_local_backend.configuration import CONFIGURATION from ovos_local_backend.database.settings import DeviceDatabase from ovos_local_backend.session import SESSION as requests from ovos_local_backend.utils import dict_to_camel_case from ovos_local_backend.utils.geolocate import geolocate, get_timezone +from selene_api.api import GeolocationApi, WolframAlphaApi, OpenWeatherMapApi from ovos_utils.ovos_service_api import OvosWolframAlpha, OvosWeather _wolfie = None @@ -16,6 +17,14 @@ if not CONFIGURATION.get("owm_key"): _owm = OvosWeather() +_selene_cfg = CONFIGURATION.get("selene") or {} +_url = _selene_cfg.get("url") +_version = _selene_cfg.get("version") or "v1" +_identity_file = _selene_cfg.get("identity_file") +_selene_owm = OpenWeatherMapApi(_url, _version, _identity_file) +_selene_wolf = WolframAlphaApi(_url, _version, _identity_file) +_selene_geo = GeolocationApi(_url, _version, _identity_file) + def _get_lang(): auth = request.headers.get('Authorization', '').replace("Bearer ", "") @@ -51,10 +60,15 @@ def _get_latlon(): def get_services_routes(app): @app.route("/" + API_VERSION + '/geolocation', methods=['GET']) @noindex + @check_selene_pairing @requires_auth def geolocation(): address = request.args["location"] - data = geolocate(address) + + if _selene_cfg.get("enabled") and _selene_cfg.get("proxy_geolocation"): + data = _selene_geo.get_geolocation(address) + else: + data = geolocate(address) return {"data": { "city": data["city"], "country": data["country"], @@ -66,6 +80,7 @@ def geolocation(): @app.route("/" + API_VERSION + '/wolframAlphaSpoken', methods=['GET']) @noindex + @check_selene_pairing @requires_auth def wolfie_spoken(): query = request.args.get("input") or request.args.get("i") @@ -76,10 +91,11 @@ def wolfie_spoken(): # not used? # https://products.wolframalpha.com/spoken-results-api/documentation/ - # lat, lon = _get_latlon() - # geolocation = request.args.get("geolocation") or lat + " " + lon + lat, lon = _get_latlon() - if _wolfie: + if _selene_cfg.get("enabled") and _selene_cfg.get("proxy_wolfram"): + answer = _selene_wolf.spoken(query, units, (lat, lon)) + elif _wolfie: q = {"input": query, "units": units} answer = _wolfie.get_wolfram_spoken(q) else: @@ -92,6 +108,7 @@ def wolfie_spoken(): @app.route("/" + API_VERSION + '/wolframAlphaSimple', methods=['GET']) @noindex + @check_selene_pairing @requires_auth def wolfie_simple(): query = request.args.get("input") or request.args.get("i") @@ -102,9 +119,11 @@ def wolfie_simple(): # not used? # https://products.wolframalpha.com/spoken-results-api/documentation/ - # lat, lon = _get_latlon() - # geolocation = request.args.get("geolocation") or lat + " " + lon - if _wolfie: + lat, lon = _get_latlon() + + if _selene_cfg.get("enabled") and _selene_cfg.get("proxy_wolfram"): + answer = _selene_wolf.simple(query, units, (lat, lon)) + elif _wolfie: q = {"input": query, "units": units} answer = _wolfie.get_wolfram_simple(q) else: @@ -117,6 +136,7 @@ def wolfie_simple(): @app.route("/" + API_VERSION + '/wolframAlphaFull', methods=['GET']) @noindex + @check_selene_pairing @requires_auth def wolfie_full(): query = request.args.get("input") or request.args.get("i") @@ -127,10 +147,11 @@ def wolfie_full(): # not used? # https://products.wolframalpha.com/spoken-results-api/documentation/ - # lat, lon = _get_latlon() - # geolocation = request.args.get("geolocation") or lat + " " + lon + lat, lon = _get_latlon() - if _wolfie: + if _selene_cfg.get("enabled") and _selene_cfg.get("proxy_wolfram"): + answer = _selene_wolf.full_results(query, units, (lat, lon)) + elif _wolfie: q = {"input": query, "units": units} answer = _wolfie.get_wolfram_full(q) else: @@ -144,6 +165,7 @@ def wolfie_full(): @app.route("/" + API_VERSION + '/wa', methods=['GET']) @noindex + @check_selene_pairing @requires_auth def wolfie_old(): """ old deprecated endpoint with XML results """ @@ -155,9 +177,11 @@ def wolfie_old(): # not used? # https://products.wolframalpha.com/spoken-results-api/documentation/ - # lat, lon = _get_latlon() - # geolocation = request.args.get("geolocation") or lat + " " + lon - if _wolfie: + lat, lon = _get_latlon() + + if _selene_cfg.get("enabled") and _selene_cfg.get("proxy_wolfram"): + answer = _selene_wolf.full_results(query, units, (lat, lon), {"output": "xml"}) + elif _wolfie: q = {"input": query, "units": units, "output": "xml"} answer = _wolfie.get_wolfram_full(q) else: @@ -171,36 +195,49 @@ def wolfie_old(): @app.route("/" + API_VERSION + '/owm/forecast/daily', methods=['GET']) @noindex + @check_selene_pairing @requires_auth def owm_daily_forecast(): - params = dict(request.args) - params["lang"] = request.args.get("lang") or _get_lang() - params["units"] = request.args.get("units") or _get_units() + lang = request.args.get("lang") or _get_lang() + units = request.args.get("units") or _get_units() lat, lon = request.args.get("lat"), request.args.get("lon") if not lat or not lon: lat, lon = _get_latlon() + if _selene_cfg.get("enabled") and _selene_cfg.get("proxy_weather"): + return _selene_owm.get_daily((lat, lon), lang, units) + + params = dict(request.args) + params["lang"] = lang + params["units"] = units if _owm: params["lat"], params["lon"] = lat, lon return _owm.get_forecast(params).json() + params["appid"] = CONFIGURATION["owm_key"] if not request.args.get("q"): params["lat"], params["lon"] = lat, lon - params["appid"] = CONFIGURATION["owm_key"] url = "https://api.openweathermap.org/data/2.5/forecast/daily" return requests.get(url, params=params).json() @app.route("/" + API_VERSION + '/owm/forecast', methods=['GET']) @noindex + @check_selene_pairing @requires_auth def owm_3h_forecast(): - params = dict(request.args) - params["lang"] = request.args.get("lang") or _get_lang() - params["units"] = request.args.get("units") or _get_units() + lang = request.args.get("lang") or _get_lang() + units = request.args.get("units") or _get_units() lat, lon = request.args.get("lat"), request.args.get("lon") if not lat or not lon: lat, lon = _get_latlon() + if _selene_cfg.get("enabled") and _selene_cfg.get("proxy_weather"): + return _selene_owm.get_hourly((lat, lon), lang, units) + + params = dict(request.args) + params["lang"] = lang + params["units"] = units + if _owm: params["lat"], params["lon"] = lat, lon return _owm.get_hourly(params).json() @@ -213,51 +250,68 @@ def owm_3h_forecast(): @app.route("/" + API_VERSION + '/owm/weather', methods=['GET']) @noindex + @check_selene_pairing @requires_auth def owm(): - params = dict(request.args) - - params["lang"] = request.args.get("lang") or _get_lang() - params["units"] = request.args.get("units") or _get_units() + lang = request.args.get("lang") or _get_lang() + units = request.args.get("units") or _get_units() lat, lon = request.args.get("lat"), request.args.get("lon") if not lat or not lon: lat, lon = _get_latlon() + if _selene_cfg.get("enabled") and _selene_cfg.get("proxy_weather"): + return _selene_owm.get_current((lat, lon), lang, units) + + params = dict(request.args) + params["lang"] = lang + params["units"] = units if _owm: params["lat"], params["lon"] = lat, lon return _owm.get_current(params).json() - params["appid"] = CONFIGURATION["owm_key"] if not request.args.get("q"): params["lat"], params["lon"] = lat, lon + params["appid"] = CONFIGURATION["owm_key"] url = "https://api.openweathermap.org/data/2.5/weather" return requests.get(url, params=params).json() @app.route("/" + API_VERSION + '/owm/onecall', methods=['GET']) @noindex + @check_selene_pairing @requires_auth def owm_onecall(): - params = { - "lang": request.args.get("lang") or _get_lang(), - "units": request.args.get("units") or _get_units() - } - + units = request.args.get("units") or _get_units() + lang = request.args.get("lang") or _get_lang() lat, lon = request.args.get("lat"), request.args.get("lon") if not lat or not lon: lat, lon = _get_latlon() - if _owm: + if _selene_cfg.get("enabled") and _selene_cfg.get("proxy_weather"): + return _selene_owm.get_weather((lat, lon), lang, units) + elif _owm: + params = { + "lang": lang, + "units": units, + "lat": lat, + "lon": lon + } params["lat"], params["lon"] = lat, lon - data = _owm.get_weather_onecall(params).json() + return _owm.get_weather_onecall(params).json() else: - params["appid"] = CONFIGURATION["owm_key"] + params = { + "lang": lang, + "units": units, + "appid": CONFIGURATION["owm_key"] + } if request.args.get("q"): params["q"] = request.args.get("q") else: params["lat"], params["lon"] = lat, lon + + params["appid"] = CONFIGURATION["owm_key"] url = "https://api.openweathermap.org/data/2.5/onecall" data = requests.get(url, params=params).json() - # Selene converts the keys from snake_case to camelCase - return dict_to_camel_case(data) + # Selene converts the keys from snake_case to camelCase + return dict_to_camel_case(data) return app diff --git a/ovos_local_backend/backend/precise.py b/ovos_local_backend/backend/precise.py index b9f6d13..fba3d70 100644 --- a/ovos_local_backend/backend/precise.py +++ b/ovos_local_backend/backend/precise.py @@ -1,13 +1,15 @@ from flask import request -from ovos_local_backend.backend.decorators import noindex, requires_auth +from ovos_local_backend.backend.decorators import noindex, requires_auth, check_selene_pairing from ovos_local_backend.configuration import CONFIGURATION from ovos_local_backend.database.wakewords import save_ww_recording +from ovos_local_backend.utils.selene import upload_ww def get_precise_routes(app): @app.route('/precise/upload', methods=['POST']) @noindex + @check_selene_pairing @requires_auth def precise_upload(): if CONFIGURATION["record_wakewords"]: @@ -15,10 +17,11 @@ def precise_upload(): uuid = auth.split(":")[-1] # this split is only valid here, not selene save_ww_recording(uuid, request.files) - # TODO - share with upstream setting - # contribute to mycroft open dataset - # may require https://github.com/OpenVoiceOS/OVOS-local-backend/issues/20 uploaded = False + selene_cfg = CONFIGURATION.get("selene") or {} + if selene_cfg.get("upload_wakewords"): + # contribute to mycroft open dataset + uploaded = upload_ww(request.files) return {"success": True, "sent_to_mycroft": uploaded, diff --git a/ovos_local_backend/backend/stt.py b/ovos_local_backend/backend/stt.py index c884567..5f1ea8b 100644 --- a/ovos_local_backend/backend/stt.py +++ b/ovos_local_backend/backend/stt.py @@ -15,11 +15,11 @@ from flask import request from speech_recognition import Recognizer, AudioFile - from ovos_local_backend.backend import API_VERSION -from ovos_local_backend.backend.decorators import noindex, requires_auth +from ovos_local_backend.backend.decorators import noindex, requires_auth, check_selene_pairing from ovos_local_backend.configuration import CONFIGURATION from ovos_local_backend.database.utterances import save_stt_recording +from ovos_local_backend.utils.selene import upload_utterance from ovos_plugin_manager.stt import OVOSSTTFactory recognizer = Recognizer() @@ -29,6 +29,7 @@ def get_stt_routes(app): @app.route("/" + API_VERSION + "/stt", methods=['POST']) @noindex + @check_selene_pairing @requires_auth def stt(): flac_audio = request.data @@ -47,9 +48,9 @@ def stt(): uuid = auth.split(":")[-1] # this split is only valid here, not selene save_stt_recording(uuid, audio, utterance) - # TODO - share with upstream setting - # contribute to mycroft open dataset - # may require https://github.com/OpenVoiceOS/OVOS-local-backend/issues/20 + selene_cfg = CONFIGURATION.get("selene") or {} + if selene_cfg.get("upload_utterances"): + upload_utterance(flac_audio, lang) return json.dumps([utterance]) diff --git a/ovos_local_backend/configuration.py b/ovos_local_backend/configuration.py index 209fc6f..38a022a 100644 --- a/ovos_local_backend/configuration.py +++ b/ovos_local_backend/configuration.py @@ -13,10 +13,22 @@ from ovos_utils.log import LOG from os.path import exists from json_database import JsonConfigXDG +from ovos_utils.configuration import get_xdg_data_save_path + +BACKEND_IDENTITY = f"{get_xdg_data_save_path('ovos_backend')}/identity2.json" DEFAULT_CONFIG = { - "stt": {"module": "ovos-stt-plugin-server", - "ovos-stt-plugin-server": {"url": "https://stt.openvoiceos.com/stt"}}, + "stt": { + "module": "ovos-stt-plugin-server", + "ovos-stt-plugin-server": { + "url": "https://stt.openvoiceos.com/stt" + }, + "ovos-stt-plugin-selene": { + "url": "https://api.mycroft.ai", + "version": "v1", + "identity_file": BACKEND_IDENTITY # path to identity2.json file + } + }, "backend_port": 6712, "admin_key": "", # To enable simply set this string to something "skip_auth": False, # you almost certainly do not want this, only for atypical use cases such as ovos-qubes @@ -58,6 +70,45 @@ "email": { "username": None, "password": None + }, + "selene": { + "enabled": False, # needs to be explicitly enabled by user + "url": "https://api.mycroft.ai", # change if you are self hosting selene + "version": "v1", + # pairing settings + # NOTE: the file should be used exclusively by backend, do not share with a mycroft-core instance + "identity_file": BACKEND_IDENTITY, # path to identity2.json file + # send the pairing from selene to any device that attempts to pair with local backend + # this will provide voice/gui prompts to the user and avoid the need to copy a identity file + # only happens if backend is not paired with selene (hopefully exactly once) + # if False you need to pair an existing mycroft-core as usual and move the file for backend usage + "proxy_pairing": False, + + # micro service settings + # NOTE: STT is handled at plugin level, configure ovos-stt-plugin-selene + "proxy_weather": True, # use selene for weather api calls + "proxy_wolfram": True, # use selene for wolfram alpha api calls + "proxy_geolocation": True, # use selene for geolocation api calls + "proxy_email": False, # use selene for sending email (only for email registered in selene) + + # device settings - if you want to spoof data in selene set these to False + "download_location": True, # set default location from selene + "download_prefs": True, # set default device preferences from selene + "download_settings": True, # download shared skill settings from selene + "upload_settings": True, # upload shared skill settings to selene + "force2way": False, # this forcefully re-enables 2way settings sync with selene + # this functionality was removed from core, we hijack the settingsmeta endpoint to upload settings + # upload will happen when mycroft-core boots and overwrite any values in selene (no checks for settings changed) + # the assumption is that selene changes are downloaded instantaneously + # if a device is offline when selene changes those changes will be discarded on next device boot + + # opt-in settings - what data to share with selene + # NOTE: these also depend on opt_in being set in selene + "opt_in": False, # share data from all devices with selene (as if from a single device) + "opt_in_blacklist": [], # list of uuids that should ignore opt_in flag (never share data) + "upload_metrics": True, # upload device metrics to selene + "upload_wakewords": True, # upload wake word samples to selene + "upload_utterances": True # upload utterance samples to selene } } @@ -67,4 +118,8 @@ CONFIGURATION.store() LOG.info(f"Saved default configuration: {CONFIGURATION.path}") else: + # set any new default values since file creation + for k, v in DEFAULT_CONFIG.items(): + if k not in CONFIGURATION: + CONFIGURATION[k] = v LOG.info(f"Loaded configuration: {CONFIGURATION.path}") diff --git a/ovos_local_backend/utils/selene.py b/ovos_local_backend/utils/selene.py new file mode 100644 index 0000000..411d95c --- /dev/null +++ b/ovos_local_backend/utils/selene.py @@ -0,0 +1,234 @@ +from uuid import uuid4 + +from flask import request +from ovos_utils.log import LOG +from selene_api.api import DeviceApi, STTApi +from selene_api.identity import IdentityManager +from selene_api.pairing import has_been_paired + +from ovos_local_backend.configuration import CONFIGURATION, BACKEND_IDENTITY +from ovos_local_backend.database.settings import SkillSettings, SharedSettingsDatabase, DeviceDatabase + +_selene_pairing_data = None +_selene_uuid = uuid4() +_selene_cfg = CONFIGURATION.get("selene") or {} + +_ident_file = _selene_cfg.get("identity_file", "") +if _ident_file != IdentityManager.IDENTITY_FILE: + IdentityManager.set_identity_file(_ident_file) + +_device_api = DeviceApi(_selene_cfg.get("url"), + _selene_cfg.get("version") or "v1", + _selene_cfg.get("identity_file")) + + +def upload_selene_skill_settings(settings): + selene_cfg = CONFIGURATION.get("selene") or {} + url = selene_cfg.get("url") + version = selene_cfg.get("version") or "v1" + identity_file = selene_cfg.get("identity_file") + if selene_cfg.get("enabled"): + # upload settings to selene if enabled + api = DeviceApi(url, version, identity_file) + api.put_skill_settings_v1(settings) + + +def upload_selene_skill_settingsmeta(meta): + selene_cfg = CONFIGURATION.get("selene") or {} + url = selene_cfg.get("url") + version = selene_cfg.get("version") or "v1" + identity_file = selene_cfg.get("identity_file") + if selene_cfg.get("enabled"): + # upload settings to selene if enabled + api = DeviceApi(url, version, identity_file) + api.upload_skill_metadata(meta) + + +def download_selene_skill_settings(): + selene_cfg = CONFIGURATION.get("selene") or {} + if selene_cfg.get("enabled"): + url = selene_cfg.get("url") + version = selene_cfg.get("version") or "v1" + identity_file = selene_cfg.get("identity_file") + # get settings from selene if enabled + api = DeviceApi(url, version, identity_file) + sets = api.get_skill_settings() + for skill_id, s in sets.items(): + s = SkillSettings.deserialize(s) + # sync local db with selene + with SharedSettingsDatabase() as db: + db.add_setting(s.skill_id, s.settings, s.meta, + s.display_name, s.remote_id) + + +def download_selene_location(uuid): + # get location from selene if enabled + selene_cfg = CONFIGURATION.get("selene") or {} + if selene_cfg.get("enabled"): + url = selene_cfg.get("url") + version = selene_cfg.get("version") or "v1" + identity_file = selene_cfg.get("identity_file") + api = DeviceApi(url, version, identity_file) + # update in local db + loc = api.get_location() + with DeviceDatabase() as db: + device = db.get_device(uuid) + device.location = loc + db.update_device(device) + + +def download_selene_preferences(uuid): + # get location from selene if enabled + selene_cfg = CONFIGURATION.get("selene") or {} + if selene_cfg.get("enabled"): + url = selene_cfg.get("url") + version = selene_cfg.get("version") or "v1" + identity_file = selene_cfg.get("identity_file") + api = DeviceApi(url, version, identity_file) + data = api.get_settings() + # update in local db + with DeviceDatabase() as db: + device = db.get_device(uuid) + device.system_unit = data["systemUnit"] + device.time_format = data["timeFormat"] + device.date_format = data["dateFormat"] + db.update_device(device) + + +def send_selene_email(title, body, sender): + selene_cfg = CONFIGURATION.get("selene") or {} + url = selene_cfg.get("url") + version = selene_cfg.get("version") or "v1" + identity_file = selene_cfg.get("identity_file") + api = DeviceApi(url, version, identity_file) + return api.send_email(title, body, sender) + + +def report_selene_metric(name, data): + # contribute to mycroft metrics dataset + selene_cfg = CONFIGURATION.get("selene") or {} + if selene_opted_in(): + url = selene_cfg.get("url") + version = selene_cfg.get("version") or "v1" + identity_file = selene_cfg.get("identity_file") + api = DeviceApi(url, version, identity_file) + return api.report_metric(name, data) + return {"success": True, "metric": data, "upload_data": {"uploaded": False}} + + +def upload_utterance(audio, lang="en-us"): + selene_cfg = CONFIGURATION.get("selene") or {} + if selene_opted_in(): + url = selene_cfg.get("url") + version = selene_cfg.get("version") or "v1" + identity_file = selene_cfg.get("identity_file") + api = STTApi(url, version, identity_file) + api.stt(audio, lang) + + +def upload_ww(files): + uploaded = False + if selene_opted_in(): + # contribute to mycroft open dataset + pass # TODO add upload endpoint to selene_api package + return uploaded + + +def selene_opted_in(): + if not _selene_cfg.get("enabled") or not _selene_cfg.get("opt_in"): + return False + auth = request.headers.get('Authorization', '').replace("Bearer ", "") + uuid = auth.split(":")[-1] # this split is only valid here, not selene + if uuid in _selene_cfg.get("opt_in_blacklist", []): + return False + # check device db for per-device opt_in settings + device = DeviceDatabase().get_device(uuid) + if not device or not device.opt_in: + return False + return True + + +def requires_selene_pairing(func_name): + enabled = _selene_cfg.get("enabled") + check_pairing = False + if enabled: + # identity file settings + check_pairing = True + + # individual selene integration settings + if "wolfie" in func_name and not _selene_cfg.get("proxy_wolfram"): + check_pairing = False + elif "owm" in func_name and not _selene_cfg.get("proxy_weather"): + check_pairing = False + elif func_name == "geolocation" and not _selene_cfg.get("proxy_geolocation"): + check_pairing = False + elif func_name == "send_mail" and not _selene_cfg.get("proxy_email"): + check_pairing = False + elif func_name == "location" and not _selene_cfg.get("download_location"): + check_pairing = False + elif func_name == "setting" and not _selene_cfg.get("download_prefs"): + check_pairing = False + elif func_name == "settingsmeta" and not _selene_cfg.get("upload_settings"): + check_pairing = False + elif "skill_settings" in func_name: + if request.method == 'PUT': + if not _selene_cfg.get("upload_settings"): + check_pairing = False + elif not _selene_cfg.get("download_settings"): + check_pairing = False + + # check opt in settings + opts = ["precise_upload", "stt", "metric"] + if not selene_opted_in() and func_name in opts: + check_pairing = False + else: + if func_name == "precise_upload" and not _selene_cfg.get("upload_wakewords"): + check_pairing = False + if func_name == "stt" and not _selene_cfg.get("upload_utterances"): + check_pairing = False + if func_name == "metric" and not _selene_cfg.get("upload_metrics"): + check_pairing = False + + return check_pairing + + +def get_selene_code(): + _selene_pairing_data = get_selene_pairing_data() + return _selene_pairing_data.get("code") + + +def get_selene_pairing_data(): + global _selene_pairing_data, _selene_uuid + if not _selene_pairing_data: + try: + _selene_uuid = uuid4() + _selene_pairing_data = _device_api.get_code(_selene_uuid) + except: + LOG.exception("Failed to get selene pairing data") + return _selene_pairing_data or {} + + +def attempt_selene_pairing(): + global _selene_pairing_data + backend_version = "0.0.1" + platform = "ovos-local-backend" + ident_file = _selene_cfg.get("identity_file") or BACKEND_IDENTITY + if ident_file != IdentityManager.IDENTITY_FILE: + IdentityManager.set_identity_file(ident_file) + if _selene_cfg.get("enabled") and not has_been_paired(): + data = get_selene_pairing_data() + if data: + tok = data["token"] + try: + login = _device_api.activate(_selene_uuid, tok, + platform=platform, + platform_build=backend_version, + core_version=backend_version, + enclosure_version=backend_version) + try: + IdentityManager.save(login) + except: + LOG.exception("Failed to save identity, restarting pairing") + _selene_pairing_data = None + except: + LOG.exception("Failed to activate with selene, user did not yet enter pairing code") diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 3b3313b..5270a78 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -7,4 +7,5 @@ ovos-plugin-manager ovos-stt-plugin-server geocoder timezonefinder -requests_cache \ No newline at end of file +requests_cache +selene_api>=0.0.1 \ No newline at end of file