1182 lines
46 KiB
Python
1182 lines
46 KiB
Python
"""
|
|
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:
|
|
# Remove accidental whitespace introduced by mobile keyboards/paste.
|
|
self.config[CONF_URL] = "".join(user_input[CONF_URL].split())
|
|
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="<script>window.close()</script>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"<a href={ISSUE_URL}>here</a>.",
|
|
)
|
|
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"<a href={ISSUE_URL}>here</a>:"
|
|
+ f"<br /><pre>{html_lib.escape(type(ex).__name__)}</pre>",
|
|
)
|
|
|
|
return wrapped
|
|
|
|
@classmethod
|
|
def reset(cls) -> None:
|
|
"""Reset the view."""
|
|
cls.known_ips = {}
|