""" Alexa Config Flow. SPDX-License-Identifier: Apache-2.0 For more details about this platform, please refer to the documentation at https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639 """ from asyncio import sleep from collections import OrderedDict import datetime from datetime import timedelta from functools import reduce import html as html_lib import logging from typing import Any, Optional from urllib.parse import urlparse from aiohttp import ClientConnectionError, ClientSession, InvalidURL, web, web_response from aiohttp.web_exceptions import HTTPBadRequest from alexapy import ( AlexaLogin, AlexaProxy, AlexapyConnectionError, AlexapyPyotpInvalidKey, hide_email, obfuscate, ) from awesomeversion import AwesomeVersion from homeassistant import config_entries from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.persistent_notification import ( async_dismiss as async_dismiss_persistent_notification, ) from homeassistant.const import ( CONF_EMAIL, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_URL, __version__ as HAVERSION, ) from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult, UnknownFlow from homeassistant.exceptions import Unauthorized from homeassistant.helpers.httpx_client import create_async_httpx_client from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.util import slugify import httpx import voluptuous as vol from yarl import URL from .const import ( AUTH_CALLBACK_NAME, AUTH_CALLBACK_PATH, AUTH_PROXY_NAME, AUTH_PROXY_PATH, CONF_DEBUG, CONF_EXCLUDE_DEVICES, CONF_EXTENDED_ENTITY_DISCOVERY, CONF_HASS_URL, CONF_INCLUDE_DEVICES, CONF_OAUTH, CONF_OTPSECRET, CONF_PROXY_WARNING, CONF_PUBLIC_URL, CONF_QUEUE_DELAY, CONF_SECURITYCODE, CONF_TOTP_REGISTER, DATA_ALEXAMEDIA, DEFAULT_DEBUG, DEFAULT_EXTENDED_ENTITY_DISCOVERY, DEFAULT_HASS_URL, DEFAULT_PUBLIC_URL, DEFAULT_QUEUE_DELAY, DEFAULT_SCAN_INTERVAL, DOMAIN, ISSUE_URL, ) from .helpers import calculate_uuid _LOGGER = logging.getLogger(__name__) CONFIG_VERSION = 1 @callback def configured_instances(hass): """Return a set of configured Alexa Media instances.""" return {entry.title for entry in hass.config_entries.async_entries(DOMAIN)} @callback def in_progress_instances(hass): """Return a set of in-progress Alexa Media flows.""" return { entry["flow_id"] for entry in hass.config_entries.flow.async_progress() if entry["handler"] == DOMAIN # Ensure only Alexa Media flows are included } @config_entries.HANDLERS.register(DOMAIN) class AlexaMediaFlowHandler(config_entries.ConfigFlow): """Handle a Alexa Media config flow.""" VERSION = CONFIG_VERSION CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL proxy: AlexaProxy = None proxy_view: "AlexaMediaAuthorizationProxyView" = None def _update_ord_dict(self, old_dict: OrderedDict, new_dict: dict) -> OrderedDict: result: OrderedDict = OrderedDict() for k, v in old_dict.items(): # pylint: disable=invalid-name for key, value in new_dict.items(): if k == key: result.update([(key, value)]) break if k not in result: result.update([(k, v)]) return result def __init__(self): """Initialize the config flow.""" self.login = None self.securitycode: Optional[str] = None self.automatic_steps: int = 0 self.config = OrderedDict() self.proxy_schema = None self.data_schema = OrderedDict( [ (vol.Required(CONF_URL, default="amazon.com"), str), (vol.Required(CONF_HASS_URL), str), (vol.Required(CONF_EMAIL), str), (vol.Required(CONF_PASSWORD), str), (vol.Optional(CONF_OTPSECRET), str), (vol.Optional(CONF_SECURITYCODE), str), (vol.Optional(CONF_PUBLIC_URL), str), (vol.Optional(CONF_INCLUDE_DEVICES, default=""), str), (vol.Optional(CONF_EXCLUDE_DEVICES, default=""), str), (vol.Optional(CONF_SCAN_INTERVAL, default=60), int), (vol.Optional(CONF_QUEUE_DELAY, default=1.5), float), (vol.Optional(CONF_EXTENDED_ENTITY_DISCOVERY, default=False), bool), (vol.Optional(CONF_DEBUG, default=False), bool), ] ) self.totp_register = OrderedDict( [(vol.Optional(CONF_TOTP_REGISTER, default=False), bool)] ) self.proxy_warning = OrderedDict( [(vol.Optional(CONF_PROXY_WARNING, default=False), bool)] ) async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" return await self.async_step_user_legacy(import_config) async def async_step_user(self, user_input=None): # pylint: disable=too-many-branches """Provide a proxy for login.""" self._save_user_input_to_config(user_input=user_input) """ Internal URL for proxy authentication """ try: hass_url: str = get_url(self.hass, allow_external=False) except NoURLAvailableError: hass_url = DEFAULT_HASS_URL """ External URL for cloud connected services """ try: url: str = get_url(self.hass, allow_internal=False) except NoURLAvailableError: DEFAULT_PUBLIC_URL = "" else: DEFAULT_PUBLIC_URL = url if url.endswith("/") else url + "/" self.proxy_schema = OrderedDict( [ ( vol.Required( CONF_URL, default=self.config.get(CONF_URL, "amazon.com") ), str, ), ( vol.Required(CONF_EMAIL, default=self.config.get(CONF_EMAIL, "")), str, ), ( vol.Required( CONF_PASSWORD, default=self.config.get(CONF_PASSWORD, "") ), str, ), ( vol.Optional( CONF_OTPSECRET, default=self.config.get(CONF_OTPSECRET, "") ), str, ), ( vol.Optional( CONF_HASS_URL, default=self.config.get(CONF_HASS_URL, hass_url), ), str, ), ( vol.Optional( CONF_PUBLIC_URL, default=self.config.get(CONF_PUBLIC_URL, DEFAULT_PUBLIC_URL), ), str, ), ( vol.Optional( CONF_INCLUDE_DEVICES, default=self.config.get(CONF_INCLUDE_DEVICES, ""), ), str, ), ( vol.Optional( CONF_EXCLUDE_DEVICES, default=self.config.get(CONF_EXCLUDE_DEVICES, ""), ), str, ), ( vol.Optional( CONF_SCAN_INTERVAL, default=self.config.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ), ), int, ), ( vol.Optional( CONF_QUEUE_DELAY, default=self.config.get(CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY), ), float, ), ( vol.Optional( CONF_EXTENDED_ENTITY_DISCOVERY, default=self.config.get( CONF_EXTENDED_ENTITY_DISCOVERY, DEFAULT_EXTENDED_ENTITY_DISCOVERY, ), ), bool, ), ( vol.Optional( CONF_DEBUG, default=self.config.get(CONF_DEBUG, DEFAULT_DEBUG) ), bool, ), ] ) if not user_input: return self.async_show_form( step_id="user", data_schema=vol.Schema(self.proxy_schema), description_placeholders={"message": ""}, ) if self.login is None: try: self.login = self.hass.data[DATA_ALEXAMEDIA]["accounts"][ self.config[CONF_EMAIL] ].get("login_obj") except KeyError: self.login = None try: if not self.login or self.login.session.closed: _LOGGER.debug("Creating new login") uuid_dict = await calculate_uuid( self.hass, self.config.get(CONF_EMAIL), self.config[CONF_URL] ) uuid = uuid_dict["uuid"] self.login = AlexaLogin( url=self.config[CONF_URL], email=self.config.get(CONF_EMAIL, ""), password=self.config.get(CONF_PASSWORD, ""), outputpath=self.hass.config.path, debug=self.config[CONF_DEBUG], otp_secret=self.config.get(CONF_OTPSECRET, ""), oauth=self.config.get(CONF_OAUTH, {}), uuid=uuid, oauth_login=True, ) else: _LOGGER.debug("Using existing login") if self.config.get(CONF_EMAIL): self.login.email = self.config.get(CONF_EMAIL) if self.config.get(CONF_PASSWORD): self.login.password = self.config.get(CONF_PASSWORD) if self.config.get(CONF_OTPSECRET): self.login.set_totp(self.config.get(CONF_OTPSECRET, "")) except AlexapyPyotpInvalidKey: return self.async_show_form( step_id="user", data_schema=vol.Schema(self.proxy_schema), errors={"base": "2fa_key_invalid"}, description_placeholders={ "otp_secret": self.config.get(CONF_OTPSECRET, ""), }, ) hass_url: str = user_input.get(CONF_HASS_URL) if hass_url is None: try: hass_url = get_url(self.hass, prefer_external=True) except NoURLAvailableError: _LOGGER.debug( "No Home Assistant URL found in config or detected; forcing user form" ) return self.async_show_form( step_id="user", data_schema=vol.Schema(self.proxy_schema), description_placeholders={"message": ""}, ) hass_url_valid: bool = False hass_url_error: str = "" async with ClientSession() as session: try: async with session.get(hass_url) as resp: hass_url_valid = resp.status == 200 except ClientConnectionError as err: hass_url_valid = False hass_url_error = str(err) except InvalidURL as err: hass_url_valid = False hass_url_error = str(err.__cause__) if not hass_url_valid: _LOGGER.debug( "Unable to connect to provided Home Assistant url: %s", hass_url ) return self.async_show_form( step_id="proxy_warning", data_schema=vol.Schema(self.proxy_warning), errors={}, description_placeholders={ "email": self.login.email, "hass_url": hass_url, "error": hass_url_error, }, ) if ( user_input and user_input.get(CONF_OTPSECRET) and user_input.get(CONF_OTPSECRET).replace(" ", "") ): otp: str = self.login.get_totp_token() if otp: _LOGGER.debug("Generated TOTP: %s", otp) return self.async_show_form( step_id="totp_register", data_schema=vol.Schema(self.totp_register), errors={}, description_placeholders={ "email": self.login.email, "url": self.login.url, "message": otp, }, ) return await self.async_step_start_proxy(user_input) async def async_step_start_proxy(self, user_input=None): """Start proxy for login.""" # pylint: disable=unused-argument _LOGGER.debug( "Starting proxy for %s - %s", hide_email(self.login.email), self.login.url, ) if not self.proxy: try: self.proxy = AlexaProxy( self.login, str(URL(self.config.get(CONF_HASS_URL)).with_path(AUTH_PROXY_PATH)), ) self.proxy.session_factory = lambda: create_async_httpx_client( self.hass, verify_ssl=True, follow_redirects=False, timeout=httpx.Timeout( connect=30.0, read=120.0, write=30.0, pool=30.0, ), ) except ValueError as ex: return self.async_show_form( step_id="user", errors={"base": "invalid_url"}, description_placeholders={"message": str(ex)}, ) # Swap the login object self.proxy.change_login(self.login) # Increase timeout for Amazon authentication (default 5s is too short) if hasattr(self.proxy, "session") and self.proxy.session: self.proxy.session.timeout = httpx.Timeout( connect=30.0, read=120.0, write=30.0, pool=30.0, ) _LOGGER.debug( "Proxy session timeout set to: %s", self.proxy.session.timeout, ) else: _LOGGER.warning( "Proxy: No session found on proxy object. Attrs: %s", dir(self.proxy), ) if not self.proxy_view: self.proxy_view = AlexaMediaAuthorizationProxyView(self.proxy.all_handler) else: _LOGGER.debug("Found existing proxy_view") self.proxy_view.handler = self.proxy.all_handler self.hass.http.register_view(AlexaMediaAuthorizationCallbackView()) self.hass.http.register_view(self.proxy_view) callback_url = ( URL(self.config[CONF_HASS_URL]) .with_path(AUTH_CALLBACK_PATH) .with_query({"flow_id": self.flow_id}) ) proxy_url = self.proxy.access_url().with_query( {"config_flow_id": self.flow_id, "callback_url": str(callback_url)} ) self.login._session.cookie_jar.clear() # pylint: disable=protected-access self.login.proxy_url = proxy_url return self.async_external_step(step_id="check_proxy", url=str(proxy_url)) async def async_step_check_proxy(self, user_input=None): # pylint: disable=unused-argument """Check status of proxy for login.""" _LOGGER.debug( "Checking proxy response for %s - %s", hide_email(self.login.email), self.login.url, ) self.proxy_view.reset() return self.async_external_step_done(next_step_id="finish_proxy") async def async_step_finish_proxy(self, user_input=None): # pylint: disable=unused-argument """Finish auth.""" if await self.login.test_loggedin(): await self.login.finalize_login() self.config[CONF_EMAIL] = self.login.email self.config[CONF_PASSWORD] = self.login.password return await self._test_login() return self.async_abort(reason="login_failed") async def async_step_user_legacy(self, user_input=None): """Handle legacy input for the config flow.""" # pylint: disable=too-many-return-statements self._save_user_input_to_config(user_input=user_input) self.data_schema = self._update_schema_defaults() if not user_input: self.automatic_steps = 0 return self.async_show_form( step_id="user", data_schema=vol.Schema(self.data_schema), description_placeholders={"message": ""}, ) if ( not self.config.get("reauth") and f"{self.config[CONF_EMAIL]} - {self.config[CONF_URL]}" in configured_instances(self.hass) and not self.hass.data[DATA_ALEXAMEDIA]["config_flows"].get( f"{self.config[CONF_EMAIL]} - {self.config[CONF_URL]}" ) ): _LOGGER.debug("Existing account found") self.automatic_steps = 0 return self.async_show_form( step_id="user", data_schema=vol.Schema(self.data_schema), errors={CONF_EMAIL: "identifier_exists"}, description_placeholders={"message": ""}, ) if self.login is None: try: self.login = self.hass.data[DATA_ALEXAMEDIA]["accounts"][ self.config[CONF_EMAIL] ].get("login_obj") except KeyError: self.login = None try: if not self.login or self.login.session.closed: _LOGGER.debug("Creating new login") uuid_dict = await calculate_uuid( self.hass, self.config.get(CONF_EMAIL), self.config[CONF_URL] ) uuid = uuid_dict["uuid"] self.login = AlexaLogin( url=self.config[CONF_URL], email=self.config[CONF_EMAIL], password=self.config[CONF_PASSWORD], outputpath=self.hass.config.path, debug=self.config[CONF_DEBUG], otp_secret=self.config.get(CONF_OTPSECRET, ""), uuid=uuid, oauth_login=True, ) else: _LOGGER.debug("Using existing login") if ( not self.config.get("reauth") and user_input and user_input.get(CONF_OTPSECRET) and user_input.get(CONF_OTPSECRET).replace(" ", "") ): otp: str = self.login.get_totp_token() if otp: _LOGGER.debug("Generated TOTP: %s", otp) return self.async_show_form( step_id="totp_register", data_schema=vol.Schema(self.totp_register), errors={}, description_placeholders={ "email": self.login.email, "url": self.login.url, "message": otp, }, ) return self.async_show_form( step_id="user", errors={"base": "2fa_key_invalid"}, description_placeholders={ "otp_secret": user_input.get(CONF_OTPSECRET), }, ) if self.login.status: _LOGGER.debug("Resuming existing flow") return await self._test_login() _LOGGER.debug("Trying to login %s", self.login.status) await self.login.login( data=self.config, ) return await self._test_login() except AlexapyConnectionError: self.automatic_steps = 0 return self.async_show_form( step_id="user_legacy", errors={"base": "connection_error"}, description_placeholders={"message": ""}, ) except AlexapyPyotpInvalidKey: self.automatic_steps = 0 return self.async_show_form( step_id="user_legacy", errors={"base": "2fa_key_invalid"}, description_placeholders={ "otp_secret": user_input.get(CONF_OTPSECRET), }, ) except BaseException as ex: # pylint: disable=broad-except _LOGGER.warning("Unknown error: %s", ex) if self.config[CONF_DEBUG]: raise self.automatic_steps = 0 return self.async_show_form( step_id="user_legacy", errors={"base": "unknown_error"}, description_placeholders={"message": str(ex)}, ) async def async_step_proxy_warning(self, user_input=None): """Handle the proxy_warning for the config flow.""" self._save_user_input_to_config(user_input=user_input) if user_input and user_input.get(CONF_PROXY_WARNING) is False: _LOGGER.debug("User is not accepting warning, go back") return self.async_show_form( step_id="user", data_schema=vol.Schema(self.proxy_schema), description_placeholders={"message": ""}, ) _LOGGER.debug("User is ignoring proxy warning; starting proxy anyway") return await self.async_step_start_proxy(user_input) async def async_step_totp_register(self, user_input=None): """Handle the input processing of the config flow.""" self._save_user_input_to_config(user_input=user_input) if user_input and user_input.get(CONF_TOTP_REGISTER) is False: _LOGGER.debug("Not registered, regenerating") otp: str = self.login.get_totp_token() if otp: _LOGGER.debug("Generated TOTP: %s", otp) return self.async_show_form( step_id="totp_register", data_schema=vol.Schema(self.totp_register), errors={}, description_placeholders={ "email": self.login.email, "url": self.login.url, "message": otp, }, ) return await self.async_step_start_proxy(user_input) async def async_step_process(self, step_id, user_input=None): """Handle the input processing of the config flow.""" _LOGGER.debug( "Processing input for %s: %s", step_id, obfuscate(user_input), ) self._save_user_input_to_config(user_input=user_input) if user_input: return await self.async_step_user(user_input=None) return await self._test_login() async def async_step_reauth(self, user_input=None): """Handle reauth processing for the config flow.""" self._save_user_input_to_config(user_input) self.config["reauth"] = True reauth_schema = self._update_schema_defaults() _LOGGER.debug( "Creating reauth form with %s", obfuscate(self.config), ) self.automatic_steps = 0 if self.login is None: try: self.login = self.hass.data[DATA_ALEXAMEDIA]["accounts"][ self.config[CONF_EMAIL] ].get("login_obj") except KeyError: self.login = None seconds_since_login: int = ( (datetime.datetime.now() - self.login.stats["login_timestamp"]).seconds if self.login else 60 ) if seconds_since_login < 60: _LOGGER.debug( "Relogin requested within %s seconds; manual login required", seconds_since_login, ) return self.async_show_form( step_id="user", data_schema=vol.Schema(reauth_schema), description_placeholders={"message": "REAUTH"}, ) _LOGGER.debug("Attempting automatic relogin") await sleep(15) return await self.async_step_user_legacy(self.config) async def _test_login(self): login = self.login email = login.email _LOGGER.debug("Testing login status: %s", login.status) if login.status and login.status.get("login_successful"): existing_entry = await self.async_set_unique_id(f"{email} - {login.url}") if self.config.get("reauth"): self.config.pop("reauth") if self.config.get(CONF_SECURITYCODE): self.config.pop(CONF_SECURITYCODE) if self.config.get("hass_url"): self.config.pop("hass_url") self.config[CONF_OAUTH] = { "access_token": login.access_token, "refresh_token": login.refresh_token, "expires_in": login.expires_in, "mac_dms": login.mac_dms, "code_verifier": login.code_verifier, "authorization_code": login.authorization_code, } self.hass.data.setdefault( DATA_ALEXAMEDIA, {"accounts": {}, "config_flows": {}, "notify_service": None}, ) self.hass.data[DATA_ALEXAMEDIA].setdefault("accounts", {}) self.hass.data[DATA_ALEXAMEDIA].setdefault("config_flows", {}) if existing_entry: self.hass.config_entries.async_update_entry( existing_entry, data=self.config ) _LOGGER.debug("Reauth successful for %s", hide_email(email)) self.hass.bus.async_fire( "alexa_media_relogin_success", event_data={"email": hide_email(email), "url": login.url}, ) host = urlparse(login.url).hostname or login.url notification_id = f"alexa_media_{slugify(email)}_{slugify(host)}" async_dismiss_persistent_notification( self.hass, notification_id, ) if not self.hass.data[DATA_ALEXAMEDIA]["accounts"].get( self.config[CONF_EMAIL] ): self.hass.data[DATA_ALEXAMEDIA]["accounts"][ self.config[CONF_EMAIL] ] = {} self.hass.data[DATA_ALEXAMEDIA]["accounts"][self.config[CONF_EMAIL]][ "login_obj" ] = self.login self.hass.data[DATA_ALEXAMEDIA]["config_flows"][ f"{email} - {login.url}" ] = None # Reload the integration to apply new credentials and clear error state try: _LOGGER.debug("Reloading integration for %s", hide_email(email)) await self.hass.config_entries.async_reload(existing_entry.entry_id) except Exception: # noqa: BLE001 _LOGGER.warning( "Failed to reload integration for %s; restart may be needed", hide_email(email), ) return self.async_abort(reason="reauth_successful") _LOGGER.debug( "Setting up Alexa devices with %s", dict(obfuscate(self.config)) ) self._abort_if_unique_id_configured(self.config) return self.async_create_entry( title=f"{login.email} - {login.url}", data=self.config ) if login.status and login.status.get("securitycode_required"): _LOGGER.debug( "Creating config_flow to request 2FA. Saved security code %s", self.securitycode, ) generated_securitycode: str = login.get_totp_token() if ( self.securitycode or generated_securitycode ) and self.automatic_steps < 2: if self.securitycode: _LOGGER.debug( "Automatically submitting securitycode %s", self.securitycode ) else: _LOGGER.debug( "Automatically submitting generated securitycode %s", generated_securitycode, ) self.automatic_steps += 1 await sleep(5) if generated_securitycode: return await self.async_step_user_legacy( user_input={CONF_SECURITYCODE: generated_securitycode} ) return await self.async_step_user_legacy( user_input={CONF_SECURITYCODE: self.securitycode} ) if login.status and (login.status.get("login_failed")): _LOGGER.debug("Login failed: %s", login.status.get("login_failed")) host = urlparse(login.url).hostname or login.url notification_id = f"alexa_media_{slugify(email)}_{slugify(host)}" await login.close() async_dismiss_persistent_notification( self.hass, notification_id, ) return self.async_abort(reason="login_failed") new_schema = self._update_schema_defaults() if login.status and login.status.get("error_message"): _LOGGER.debug("Login error detected: %s", login.status.get("error_message")) if ( login.status.get("error_message") in { "There was a problem\n Enter a valid email or mobile number\n " } and self.automatic_steps < 2 ): _LOGGER.debug( "Trying automatic resubmission %s for error_message 'valid email'", self.automatic_steps, ) self.automatic_steps += 1 await sleep(5) return await self.async_step_user_legacy(user_input=self.config) _LOGGER.debug( "Done with automatic resubmission for error_message 'valid email'; returning error message", ) self.automatic_steps = 0 return self.async_show_form( step_id="user", data_schema=vol.Schema(new_schema), description_placeholders={ "message": f" \n> {login.status.get('error_message', '')}" }, ) def _save_user_input_to_config(self, user_input=None) -> None: # pylint: disable=too-many-branches """Process user_input to save to self.config. user_input can be a dictionary of strings or an internally saved config_entry data entry. This function will convert all to internal strings. """ if user_input is None: return if CONF_HASS_URL in user_input: self.config[CONF_HASS_URL] = user_input[CONF_HASS_URL] self.securitycode = user_input.get(CONF_SECURITYCODE) if self.securitycode is not None: self.config[CONF_SECURITYCODE] = self.securitycode elif CONF_SECURITYCODE in self.config: self.config.pop(CONF_SECURITYCODE) if user_input.get(CONF_OTPSECRET) and user_input.get(CONF_OTPSECRET).replace( " ", "" ): self.config[CONF_OTPSECRET] = user_input[CONF_OTPSECRET].replace(" ", "") elif user_input.get(CONF_OTPSECRET): # a blank line self.config.pop(CONF_OTPSECRET) if CONF_EMAIL in user_input: self.config[CONF_EMAIL] = user_input[CONF_EMAIL] if CONF_PASSWORD in user_input: self.config[CONF_PASSWORD] = user_input[CONF_PASSWORD] if CONF_URL in user_input: self.config[CONF_URL] = user_input[CONF_URL] if CONF_PUBLIC_URL in user_input: if not user_input[CONF_PUBLIC_URL].endswith("/"): user_input[CONF_PUBLIC_URL] = user_input[CONF_PUBLIC_URL] + "/" self.config[CONF_PUBLIC_URL] = user_input[CONF_PUBLIC_URL] if CONF_SCAN_INTERVAL in user_input: self.config[CONF_SCAN_INTERVAL] = ( user_input[CONF_SCAN_INTERVAL] if not isinstance(user_input[CONF_SCAN_INTERVAL], timedelta) else user_input[CONF_SCAN_INTERVAL].total_seconds() ) if CONF_QUEUE_DELAY in user_input: self.config[CONF_QUEUE_DELAY] = user_input[CONF_QUEUE_DELAY] if CONF_INCLUDE_DEVICES in user_input: if isinstance(user_input[CONF_INCLUDE_DEVICES], list): self.config[CONF_INCLUDE_DEVICES] = ( reduce(lambda x, y: f"{x},{y}", user_input[CONF_INCLUDE_DEVICES]) if user_input[CONF_INCLUDE_DEVICES] else "" ) else: self.config[CONF_INCLUDE_DEVICES] = user_input[CONF_INCLUDE_DEVICES] if CONF_EXCLUDE_DEVICES in user_input: if isinstance(user_input[CONF_EXCLUDE_DEVICES], list): self.config[CONF_EXCLUDE_DEVICES] = ( reduce(lambda x, y: f"{x},{y}", user_input[CONF_EXCLUDE_DEVICES]) if user_input[CONF_EXCLUDE_DEVICES] else "" ) else: self.config[CONF_EXCLUDE_DEVICES] = user_input[CONF_EXCLUDE_DEVICES] if CONF_EXTENDED_ENTITY_DISCOVERY in user_input: self.config[CONF_EXTENDED_ENTITY_DISCOVERY] = user_input[ CONF_EXTENDED_ENTITY_DISCOVERY ] if CONF_DEBUG in user_input: self.config[CONF_DEBUG] = user_input[CONF_DEBUG] def _update_schema_defaults(self) -> Any: new_schema = self._update_ord_dict( self.data_schema, { vol.Required( CONF_URL, default=self.config.get(CONF_URL, "amazon.com") ): str, vol.Required(CONF_EMAIL, default=self.config.get(CONF_EMAIL, "")): str, vol.Required( CONF_PASSWORD, default=self.config.get(CONF_PASSWORD, "") ): str, vol.Required( CONF_SECURITYCODE, default=self.securitycode if self.securitycode else "", ): str, vol.Required( CONF_OTPSECRET, default=self.config.get(CONF_OTPSECRET, "") ): str, vol.Optional( CONF_PUBLIC_URL, default=self.config.get(CONF_PUBLIC_URL, DEFAULT_PUBLIC_URL), ): str, vol.Optional( CONF_INCLUDE_DEVICES, default=self.config.get(CONF_INCLUDE_DEVICES, ""), ): str, vol.Optional( CONF_EXCLUDE_DEVICES, default=self.config.get(CONF_EXCLUDE_DEVICES, ""), ): str, vol.Optional( CONF_SCAN_INTERVAL, default=self.config.get(CONF_SCAN_INTERVAL, 60) ): int, vol.Optional( CONF_QUEUE_DELAY, default=self.config.get(CONF_QUEUE_DELAY, 1.5) ): float, vol.Optional( CONF_EXTENDED_ENTITY_DISCOVERY, default=self.config.get( CONF_EXTENDED_ENTITY_DISCOVERY, DEFAULT_EXTENDED_ENTITY_DISCOVERY, ), ): bool, vol.Optional( CONF_DEBUG, default=self.config.get(CONF_DEBUG, False) ): bool, }, ) return new_schema @staticmethod @callback def async_get_options_flow( config_entry: config_entries.ConfigEntry, ) -> config_entries.OptionsFlow: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for Alexa Media.""" def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config = OrderedDict() if AwesomeVersion(HAVERSION) < "2024.12": self.config_entry = config_entry async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the options""" self.options_schema = OrderedDict( [ ( vol.Optional( CONF_PUBLIC_URL, default=self.config_entry.data.get( CONF_PUBLIC_URL, DEFAULT_PUBLIC_URL ), ), str, ), ( vol.Optional( CONF_INCLUDE_DEVICES, default=self.config_entry.data.get(CONF_INCLUDE_DEVICES, ""), ), str, ), ( vol.Optional( CONF_EXCLUDE_DEVICES, default=self.config_entry.data.get(CONF_EXCLUDE_DEVICES, ""), ), str, ), ( vol.Optional( CONF_SCAN_INTERVAL, default=self.config_entry.data.get(CONF_SCAN_INTERVAL, 120), ), int, ), ( vol.Optional( CONF_QUEUE_DELAY, default=self.config_entry.data.get( CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY ), ), float, ), ( vol.Optional( CONF_EXTENDED_ENTITY_DISCOVERY, default=self.config_entry.data.get( CONF_EXTENDED_ENTITY_DISCOVERY, DEFAULT_EXTENDED_ENTITY_DISCOVERY, ), ), bool, ), ( vol.Optional( CONF_DEBUG, default=self.config_entry.data.get(CONF_DEBUG, DEFAULT_DEBUG), ), bool, ), ] ) if user_input is not None: """Preserve these parameters""" if CONF_URL in self.config_entry.data: user_input[CONF_URL] = self.config_entry.data[CONF_URL] if CONF_EMAIL in self.config_entry.data: user_input[CONF_EMAIL] = self.config_entry.data[CONF_EMAIL] if CONF_PASSWORD in self.config_entry.data: user_input[CONF_PASSWORD] = self.config_entry.data[CONF_PASSWORD] if CONF_SECURITYCODE in self.config_entry.data: user_input[CONF_SECURITYCODE] = self.config_entry.data[ CONF_SECURITYCODE ] if CONF_OTPSECRET in self.config_entry.data: user_input[CONF_OTPSECRET] = self.config_entry.data[CONF_OTPSECRET] if CONF_OAUTH in self.config_entry.data: user_input[CONF_OAUTH] = self.config_entry.data[CONF_OAUTH] """Ensure public_url ends with trailing slash""" if CONF_PUBLIC_URL in self.config_entry.data: if not user_input[CONF_PUBLIC_URL].endswith("/"): user_input[CONF_PUBLIC_URL] = user_input[CONF_PUBLIC_URL] + "/" """Remove leading/trailing spaces in device strings""" if CONF_INCLUDE_DEVICES in self.config_entry.data: user_input[CONF_INCLUDE_DEVICES] = user_input[ CONF_INCLUDE_DEVICES ].strip() if CONF_EXCLUDE_DEVICES in self.config_entry.data: user_input[CONF_EXCLUDE_DEVICES] = user_input[ CONF_EXCLUDE_DEVICES ].strip() self.hass.config_entries.async_update_entry( self.config_entry, data=user_input, options=self.config_entry.options ) return self.async_create_entry(title="", data={}) return self.async_show_form( step_id="init", data_schema=vol.Schema(self.options_schema), description_placeholders={"message": ""}, ) class AlexaMediaAuthorizationCallbackView(HomeAssistantView): """Handle callback from external auth.""" url = AUTH_CALLBACK_PATH name = AUTH_CALLBACK_NAME requires_auth = False async def get(self, request: web.Request): """Receive authorization confirmation.""" hass = request.app["hass"] try: await hass.config_entries.flow.async_configure( flow_id=request.query["flow_id"], user_input=None ) except (KeyError, UnknownFlow) as ex: _LOGGER.debug("Callback flow_id is invalid.") raise HTTPBadRequest() from ex return web_response.Response( headers={"content-type": "text/html"}, text="Success! This window can be closed", ) class AlexaMediaAuthorizationProxyView(HomeAssistantView): """Handle proxy connections.""" url: str = AUTH_PROXY_PATH extra_urls: list[str] = [f"{AUTH_PROXY_PATH}/{{tail:.*}}"] name: str = AUTH_PROXY_NAME requires_auth: bool = False handler: web.RequestHandler = None known_ips: dict[str, datetime.datetime] = {} auth_seconds: int = 300 def __init__(self, handler: web.RequestHandler): """Initialize routes for view. Args: handler (web.RequestHandler): Handler to apply to all method types """ AlexaMediaAuthorizationProxyView.handler = handler for method in ("get", "post", "delete", "put", "patch", "head", "options"): setattr(self, method, self.check_auth()) @classmethod def check_auth(cls): """Wrap authentication into the handler.""" async def wrapped(request, **kwargs): """Notify that the API is running.""" hass = request.app["hass"] success = False if ( request.remote not in cls.known_ips or (datetime.datetime.now() - cls.known_ips.get(request.remote)).seconds > cls.auth_seconds ): try: flow_id = request.url.query["config_flow_id"] except KeyError as ex: raise Unauthorized() from ex for flow in hass.config_entries.flow.async_progress(): if flow["flow_id"] == flow_id: _LOGGER.debug( "Found flow_id; adding %s to known_ips for %s seconds", request.remote, cls.auth_seconds, ) success = True if not success: raise Unauthorized() cls.known_ips[request.remote] = datetime.datetime.now() _sensitive_keys = { "authorization", "cookie", "set-cookie", "x-amz-security-token", } if _LOGGER.isEnabledFor(logging.DEBUG): _safe_req_headers = { k: ("***" if k.lower() in _sensitive_keys else v) for k, v in request.headers.items() } _LOGGER.debug( "Proxy request: %s %s | Remote: %s | Headers: %s", request.method, request.url, request.remote, _safe_req_headers, ) try: result = await cls.handler(request, **kwargs) if _LOGGER.isEnabledFor(logging.DEBUG): _safe_resp_headers = ( { k: ("***" if k.lower() in _sensitive_keys else v) for k, v in result.headers.items() } if hasattr(result, "headers") else "unknown" ) _LOGGER.debug( "Proxy response: %s %s | Status: %s | Response headers: %s", request.method, request.url, result.status if hasattr(result, "status") else "unknown", _safe_resp_headers, ) return result except httpx.ConnectError as ex: _LOGGER.warning("Detected Connection error: %s", ex) return web_response.Response( headers={"content-type": "text/html"}, text="Connection Error! Please try refreshing. " + "If this persists, please report this error to " + f"here.", ) except web.HTTPException: raise # Let aiohttp handle redirects (HTTPFound) and other HTTP exceptions except Exception as ex: # pylint: disable=broad-except _LOGGER.warning( "Proxy exception at %s %s: %s - %s", request.method, request.url, type(ex).__name__, ex, exc_info=True, ) return web_response.Response( headers={"content-type": "text/html"}, text="An unexpected error occurred during login. " + "Please try refreshing. " + "If this persists, please report this error to " + f"here:" + f"
{html_lib.escape(type(ex).__name__)}
", ) return wrapped @classmethod def reset(cls) -> None: """Reset the view.""" cls.known_ips = {}