From 79ac77a93d37bcfae4161c43e6666f6fce68936a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 28 Oct 2019 23:47:31 -0700 Subject: [PATCH] Almond integration (#28282) * Initial Almond integration * Hassfest * Update library * Address comments * Fix inheritance issue py36 * Remove no longer needed check * Fix time --- CODEOWNERS | 1 + homeassistant/auth/__init__.py | 8 +- .../components/almond/.translations/en.json | 8 + homeassistant/components/almond/__init__.py | 230 ++++++++++++++++++ .../components/almond/config_flow.py | 125 ++++++++++ homeassistant/components/almond/const.py | 4 + homeassistant/components/almond/manifest.json | 9 + homeassistant/components/almond/strings.json | 9 + homeassistant/generated/config_flows.py | 1 + .../helpers/config_entry_oauth2_flow.py | 13 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/almond/__init__.py | 1 + tests/components/almond/test_config_flow.py | 138 +++++++++++ 14 files changed, 544 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/almond/.translations/en.json create mode 100644 homeassistant/components/almond/__init__.py create mode 100644 homeassistant/components/almond/config_flow.py create mode 100644 homeassistant/components/almond/const.py create mode 100644 homeassistant/components/almond/manifest.json create mode 100644 homeassistant/components/almond/strings.json create mode 100644 tests/components/almond/__init__.py create mode 100644 tests/components/almond/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index dac59039935..aed575b5271 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -19,6 +19,7 @@ homeassistant/components/airly/* @bieniu homeassistant/components/airvisual/* @bachya homeassistant/components/alarm_control_panel/* @colinodell homeassistant/components/alexa/* @home-assistant/cloud @ochlocracy +homeassistant/components/almond/* @gcampax @balloob homeassistant/components/alpha_vantage/* @fabaff homeassistant/components/amazon_polly/* @robbiet480 homeassistant/components/ambiclimate/* @danielhiversen diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 921bec71e78..3f7dd570400 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -261,7 +261,7 @@ class AuthManager: """Enable a multi-factor auth module for user.""" if user.system_generated: raise ValueError( - "System generated users cannot enable " "multi-factor auth module." + "System generated users cannot enable multi-factor auth module." ) module = self.get_auth_mfa_module(mfa_module_id) @@ -276,7 +276,7 @@ class AuthManager: """Disable a multi-factor auth module for user.""" if user.system_generated: raise ValueError( - "System generated users cannot disable " "multi-factor auth module." + "System generated users cannot disable multi-factor auth module." ) module = self.get_auth_mfa_module(mfa_module_id) @@ -320,7 +320,7 @@ class AuthManager: if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM): raise ValueError( - "System generated users can only have system type " "refresh tokens" + "System generated users can only have system type refresh tokens" ) if token_type == models.TOKEN_TYPE_NORMAL and client_id is None: @@ -330,7 +330,7 @@ class AuthManager: token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN and client_name is None ): - raise ValueError("Client_name is required for long-lived access " "token") + raise ValueError("Client_name is required for long-lived access token") if token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN: for token in user.refresh_tokens.values(): diff --git a/homeassistant/components/almond/.translations/en.json b/homeassistant/components/almond/.translations/en.json new file mode 100644 index 00000000000..cc48b1c28eb --- /dev/null +++ b/homeassistant/components/almond/.translations/en.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure one Almond account." + }, + "title": "Almond" + } +} diff --git a/homeassistant/components/almond/__init__.py b/homeassistant/components/almond/__init__.py new file mode 100644 index 00000000000..ebdddecdec3 --- /dev/null +++ b/homeassistant/components/almond/__init__.py @@ -0,0 +1,230 @@ +"""Support for Almond.""" +import asyncio +from datetime import timedelta +import logging +import time + +import async_timeout +from aiohttp import ClientSession, ClientError +from pyalmond import AlmondLocalAuth, AbstractAlmondWebAuth, WebAlmondAPI +import voluptuous as vol + +from homeassistant.const import CONF_TYPE, CONF_HOST +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.auth.const import GROUP_ID_ADMIN +from homeassistant.helpers import ( + config_validation as cv, + config_entry_oauth2_flow, + intent, + aiohttp_client, + storage, +) +from homeassistant import config_entries +from homeassistant.components import conversation + +from . import config_flow +from .const import DOMAIN, TYPE_LOCAL, TYPE_OAUTH2 + +CONF_CLIENT_ID = "client_id" +CONF_CLIENT_SECRET = "client_secret" + +STORAGE_VERSION = 1 +STORAGE_KEY = DOMAIN + +DEFAULT_OAUTH2_HOST = "https://almond.stanford.edu" +DEFAULT_LOCAL_HOST = "http://localhost:3000" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Any( + vol.Schema( + { + vol.Required(CONF_TYPE): TYPE_OAUTH2, + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_OAUTH2_HOST): cv.url, + } + ), + vol.Schema( + {vol.Required(CONF_TYPE): TYPE_LOCAL, vol.Required(CONF_HOST): cv.url} + ), + ) + }, + extra=vol.ALLOW_EXTRA, +) +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Set up the Almond component.""" + hass.data[DOMAIN] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + host = conf[CONF_HOST] + + if conf[CONF_TYPE] == TYPE_OAUTH2: + config_flow.AlmondFlowHandler.async_register_implementation( + hass, + config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, + DOMAIN, + conf[CONF_CLIENT_ID], + conf[CONF_CLIENT_SECRET], + f"{host}/me/api/oauth2/authorize", + f"{host}/me/api/oauth2/token", + ), + ) + return True + + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={"type": TYPE_LOCAL, "host": conf[CONF_HOST]}, + ) + ) + return True + + +async def async_setup_entry(hass, entry): + """Set up Almond config entry.""" + websession = aiohttp_client.async_get_clientsession(hass) + if entry.data["type"] == TYPE_LOCAL: + auth = AlmondLocalAuth(entry.data["host"], websession) + + else: + # OAuth2 + implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + oauth_session = config_entry_oauth2_flow.OAuth2Session( + hass, entry, implementation + ) + auth = AlmondOAuth(entry.data["host"], websession, oauth_session) + + api = WebAlmondAPI(auth) + agent = AlmondAgent(api) + + # Hass.io does its own configuration of Almond. + if entry.data.get("is_hassio"): + conversation.async_set_agent(hass, agent) + return True + + # Configure Almond to connect to Home Assistant + store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY) + data = await store.async_load() + + if data is None: + data = {} + + user = None + if "almond_user" in data: + user = await hass.auth.async_get_user(data["almond_user"]) + + if user is None: + user = await hass.auth.async_create_system_user("Almond", [GROUP_ID_ADMIN]) + data["almond_user"] = user.id + await store.async_save(data) + + refresh_token = await hass.auth.async_create_refresh_token( + user, + # Almond will be fine as long as we restart once every 5 years + access_token_expiration=timedelta(days=365 * 5), + ) + + # Create long lived access token + access_token = hass.auth.async_create_access_token(refresh_token) + + # Store token in Almond + try: + with async_timeout.timeout(10): + await api.async_create_device( + { + "kind": "io.home-assistant", + "hassUrl": hass.config.api.base_url, + "accessToken": access_token, + "refreshToken": "", + # 5 years from now in ms. + "accessTokenExpires": (time.time() + 60 * 60 * 24 * 365 * 5) * 1000, + } + ) + except (asyncio.TimeoutError, ClientError) as err: + if isinstance(err, asyncio.TimeoutError): + msg = "Request timeout" + else: + msg = err + _LOGGER.warning("Unable to configure Almond: %s", msg) + await hass.auth.async_remove_refresh_token(refresh_token) + raise ConfigEntryNotReady + + # Clear all other refresh tokens + for token in list(user.refresh_tokens.values()): + if token.id != refresh_token.id: + await hass.auth.async_remove_refresh_token(token) + + conversation.async_set_agent(hass, agent) + return True + + +async def async_unload_entry(hass, entry): + """Unload Almond.""" + conversation.async_set_agent(hass, None) + return True + + +class AlmondOAuth(AbstractAlmondWebAuth): + """Almond Authentication using OAuth2.""" + + def __init__( + self, + host: str, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ): + """Initialize Almond auth.""" + super().__init__(host, websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self): + """Return a valid access token.""" + if not self._oauth_session.is_valid: + await self._oauth_session.async_ensure_token_valid() + + return self._oauth_session.token + + +class AlmondAgent(conversation.AbstractConversationAgent): + """Almond conversation agent.""" + + def __init__(self, api: WebAlmondAPI): + """Initialize the agent.""" + self.api = api + + async def async_process(self, text: str) -> intent.IntentResponse: + """Process a sentence.""" + response = await self.api.async_converse_text(text) + + buffer = "" + for message in response["messages"]: + if message["type"] == "text": + buffer += "\n" + message["text"] + elif message["type"] == "picture": + buffer += "\n Picture: " + message["url"] + elif message["type"] == "rdl": + buffer += ( + "\n Link: " + + message["rdl"]["displayTitle"] + + " " + + message["rdl"]["webCallback"] + ) + elif message["type"] == "choice": + buffer += "\n Choice: " + message["title"] + + intent_result = intent.IntentResponse() + intent_result.async_set_speech(buffer.strip()) + return intent_result diff --git a/homeassistant/components/almond/config_flow.py b/homeassistant/components/almond/config_flow.py new file mode 100644 index 00000000000..d79bf6bd605 --- /dev/null +++ b/homeassistant/components/almond/config_flow.py @@ -0,0 +1,125 @@ +"""Config flow to connect with Home Assistant.""" +import asyncio +import logging + +import async_timeout +from aiohttp import ClientError +from yarl import URL +import voluptuous as vol +from pyalmond import AlmondLocalAuth, WebAlmondAPI + +from homeassistant import data_entry_flow, config_entries, core +from homeassistant.helpers import config_entry_oauth2_flow, aiohttp_client + +from .const import DOMAIN, TYPE_LOCAL, TYPE_OAUTH2 + + +async def async_verify_local_connection(hass: core.HomeAssistant, host: str): + """Verify that a local connection works.""" + websession = aiohttp_client.async_get_clientsession(hass) + api = WebAlmondAPI(AlmondLocalAuth(host, websession)) + + try: + with async_timeout.timeout(10): + await api.async_list_apps() + + return True + except (asyncio.TimeoutError, ClientError): + return False + + +@config_entries.HANDLERS.register(DOMAIN) +class AlmondFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): + """Implementation of the Almond OAuth2 config flow.""" + + DOMAIN = DOMAIN + + host = None + hassio_discovery = None + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": "profile user-read user-read-results user-exec-command"} + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + # Only allow 1 instance. + if self._async_current_entries(): + return self.async_abort(reason="already_setup") + + return await super().async_step_user(user_input) + + async def async_step_auth(self, user_input=None): + """Handle authorize step.""" + result = await super().async_step_auth(user_input) + + if result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP: + self.host = str(URL(result["url"]).with_path("me")) + + return result + + async def async_oauth_create_entry(self, data: dict) -> dict: + """Create an entry for the flow. + + Ok to override if you want to fetch extra info or even add another step. + """ + # pylint: disable=invalid-name + self.CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + data["type"] = TYPE_OAUTH2 + data["host"] = self.host + return self.async_create_entry(title=self.flow_impl.name, data=data) + + async def async_step_import(self, user_input: dict = None) -> dict: + """Import data.""" + # Only allow 1 instance. + if self._async_current_entries(): + return self.async_abort(reason="already_setup") + + if not await async_verify_local_connection(self.hass, user_input["host"]): + self.logger.warning( + "Aborting import of Almond because we're unable to connect" + ) + return self.async_abort(reason="cannot_connect") + + # pylint: disable=invalid-name + self.CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + return self.async_create_entry( + title="Configuration.yaml", + data={"type": TYPE_LOCAL, "host": user_input["host"]}, + ) + + async def async_step_hassio(self, user_input=None): + """Receive a Hass.io discovery.""" + if self._async_current_entries(): + return self.async_abort(reason="already_setup") + + self.hassio_discovery = user_input + + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm(self, user_input=None): + """Confirm a Hass.io discovery.""" + data = self.hassio_discovery + + if user_input is not None: + return self.async_create_entry( + title=data["addon"], + data={ + "is_hassio": True, + "type": TYPE_LOCAL, + "host": f"http://{data['host']}:{data['port']}", + }, + ) + + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders={"addon": data["addon"]}, + data_schema=vol.Schema({}), + ) diff --git a/homeassistant/components/almond/const.py b/homeassistant/components/almond/const.py new file mode 100644 index 00000000000..34dca28e957 --- /dev/null +++ b/homeassistant/components/almond/const.py @@ -0,0 +1,4 @@ +"""Constants for the Almond integration.""" +DOMAIN = "almond" +TYPE_OAUTH2 = "oauth2" +TYPE_LOCAL = "local" diff --git a/homeassistant/components/almond/manifest.json b/homeassistant/components/almond/manifest.json new file mode 100644 index 00000000000..44404b504f6 --- /dev/null +++ b/homeassistant/components/almond/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "almond", + "name": "Almond", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/almond", + "dependencies": ["http", "conversation"], + "codeowners": ["@gcampax", "@balloob"], + "requirements": ["pyalmond==0.0.2"] +} diff --git a/homeassistant/components/almond/strings.json b/homeassistant/components/almond/strings.json new file mode 100644 index 00000000000..9bc4b0e1b93 --- /dev/null +++ b/homeassistant/components/almond/strings.json @@ -0,0 +1,9 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure one Almond account.", + "cannot_connect": "Unable to connect to the Almond server." + }, + "title": "Almond" + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b694af1fb71..22d36fc46c6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -9,6 +9,7 @@ FLOWS = [ "abode", "adguard", "airly", + "almond", "ambiclimate", "ambient_station", "axis", diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 87832f60739..dc3d3c91f27 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -387,17 +387,20 @@ class OAuth2Session: @property def token(self) -> dict: - """Return the current token.""" + """Return the token.""" return cast(dict, self.config_entry.data["token"]) + @property + def valid_token(self) -> bool: + """Return if token is still valid.""" + return cast(float, self.token["expires_at"]) > time.time() + async def async_ensure_token_valid(self) -> None: """Ensure that the current token is valid.""" - token = self.token - - if token["expires_at"] > time.time(): + if self.valid_token: return - new_token = await self.implementation.async_refresh_token(token) + new_token = await self.implementation.async_refresh_token(self.token) self.hass.config_entries.async_update_entry( self.config_entry, data={**self.config_entry.data, "token": new_token} diff --git a/requirements_all.txt b/requirements_all.txt index a1db7bbc1f7..54831270ff6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1089,6 +1089,9 @@ pyairvisual==3.0.1 # homeassistant.components.alarmdotcom pyalarmdotcom==0.3.2 +# homeassistant.components.almond +pyalmond==0.0.2 + # homeassistant.components.arlo pyarlo==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4aaeb373cc..280146ec45d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -390,6 +390,9 @@ pyRFXtrx==0.23 # homeassistant.components.nextbus py_nextbusnext==0.1.4 +# homeassistant.components.almond +pyalmond==0.0.2 + # homeassistant.components.arlo pyarlo==0.2.3 diff --git a/tests/components/almond/__init__.py b/tests/components/almond/__init__.py new file mode 100644 index 00000000000..717271c3a6a --- /dev/null +++ b/tests/components/almond/__init__.py @@ -0,0 +1 @@ +"""Tests for the Almond integration.""" diff --git a/tests/components/almond/test_config_flow.py b/tests/components/almond/test_config_flow.py new file mode 100644 index 00000000000..afbe25dff5f --- /dev/null +++ b/tests/components/almond/test_config_flow.py @@ -0,0 +1,138 @@ +"""Test the Almond config flow.""" +import asyncio + +from unittest.mock import patch + + +from homeassistant import config_entries, setup, data_entry_flow +from homeassistant.components.almond.const import DOMAIN +from homeassistant.components.almond import config_flow +from homeassistant.helpers import config_entry_oauth2_flow + +from tests.common import MockConfigEntry, mock_coro + +CLIENT_ID_VALUE = "1234" +CLIENT_SECRET_VALUE = "5678" + + +async def test_import(hass): + """Test that we can import a config entry.""" + with patch("pyalmond.WebAlmondAPI.async_list_apps", side_effect=mock_coro): + assert await setup.async_setup_component( + hass, + "almond", + {"almond": {"type": "local", "host": "http://localhost:3000"}}, + ) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.data["type"] == "local" + assert entry.data["host"] == "http://localhost:3000" + + +async def test_import_cannot_connect(hass): + """Test that we won't import a config entry if we cannot connect.""" + with patch( + "pyalmond.WebAlmondAPI.async_list_apps", side_effect=asyncio.TimeoutError + ): + assert await setup.async_setup_component( + hass, + "almond", + {"almond": {"type": "local", "host": "http://localhost:3000"}}, + ) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + +async def test_hassio(hass): + """Test that Hass.io can discover this integration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "hassio"}, + data={"addon": "Almond add-on", "host": "almond-addon", "port": "1234"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "hassio_confirm" + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.data["type"] == "local" + assert entry.data["host"] == "http://almond-addon:1234" + + +async def test_abort_if_existing_entry(hass): + """Check flow abort when an entry already exist.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + flow = config_flow.AlmondFlowHandler() + flow.hass = hass + + result = await flow.async_step_user() + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_setup" + + result = await flow.async_step_import() + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_setup" + + result = await flow.async_step_hassio() + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_setup" + + +async def test_full_flow(hass, aiohttp_client, aioclient_mock): + """Check full flow.""" + assert await setup.async_setup_component( + hass, + "almond", + { + "almond": { + "type": "oauth2", + "client_id": CLIENT_ID_VALUE, + "client_secret": CLIENT_SECRET_VALUE, + }, + "http": {"base_url": "https://example.com"}, + }, + ) + + result = await hass.config_entries.flow.async_init( + "almond", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]}) + + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["url"] == ( + "https://almond.stanford.edu/me/api/oauth2/authorize" + f"?response_type=code&client_id={CLIENT_ID_VALUE}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=profile+user-read+user-read-results+user-exec-command" + ) + + client = await aiohttp_client(hass.http.app) + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + "https://almond.stanford.edu/me/api/oauth2/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.data["type"] == "oauth2" + assert entry.data["host"] == "https://almond.stanford.edu/me"