From 5961215e6e52131e0d59cdaf6a8aae9e2a0c0b94 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 12 Nov 2019 11:01:19 -0800 Subject: [PATCH] Set up Almond Web to connect to HA (#28603) * Set up Almond Web to connect to HA * Add missing string * Add type --- homeassistant/components/almond/__init__.py | 69 ++++++++--- homeassistant/components/almond/strings.json | 5 + homeassistant/helpers/network.py | 38 +++++++ tests/components/almond/test_init.py | 113 +++++++++++++++++++ tests/helpers/test_network.py | 34 ++++++ 5 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 homeassistant/helpers/network.py create mode 100644 tests/components/almond/test_init.py create mode 100644 tests/helpers/test_network.py diff --git a/homeassistant/components/almond/__init__.py b/homeassistant/components/almond/__init__.py index a1983288f92..6d4ab31bf17 100644 --- a/homeassistant/components/almond/__init__.py +++ b/homeassistant/components/almond/__init__.py @@ -10,16 +10,18 @@ from aiohttp import ClientSession, ClientError from pyalmond import AlmondLocalAuth, AbstractAlmondWebAuth, WebAlmondAPI import voluptuous as vol -from homeassistant import core -from homeassistant.const import CONF_TYPE, CONF_HOST +from homeassistant.core import HomeAssistant, CoreState +from homeassistant.const import CONF_TYPE, CONF_HOST, EVENT_HOMEASSISTANT_START 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, + event, intent, aiohttp_client, storage, + network, ) from homeassistant import config_entries from homeassistant.components import conversation @@ -33,6 +35,8 @@ CONF_CLIENT_SECRET = "client_secret" STORAGE_VERSION = 1 STORAGE_KEY = DOMAIN +ALMOND_SETUP_DELAY = 30 + DEFAULT_OAUTH2_HOST = "https://almond.stanford.edu" DEFAULT_LOCAL_HOST = "http://localhost:3000" @@ -93,7 +97,7 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: config_entries.ConfigEntry): """Set up Almond config entry.""" websession = aiohttp_client.async_get_clientsession(hass) @@ -112,12 +116,50 @@ async def async_setup_entry(hass, entry): api = WebAlmondAPI(auth) agent = AlmondAgent(hass, api, entry) - # Hass.io does its own configuration of Almond. - if entry.data.get("is_hassio") or entry.data["type"] != TYPE_LOCAL: - conversation.async_set_agent(hass, agent) - return True + # Hass.io does its own configuration. + if not entry.data.get("is_hassio"): + # If we're not starting or local, set up Almond right away + if hass.state != CoreState.not_running or entry.data["type"] == TYPE_LOCAL: + await _configure_almond_for_ha(hass, entry, api) - # Configure Almond to connect to Home Assistant + else: + # OAuth2 implementations can potentially rely on the HA Cloud url. + # This url is not be available until 30 seconds after boot. + + async def configure_almond(_now): + try: + await _configure_almond_for_ha(hass, entry, api) + except ConfigEntryNotReady: + _LOGGER.warning( + "Unable to configure Almond to connect to Home Assistant" + ) + + async def almond_hass_start(_event): + event.async_call_later(hass, ALMOND_SETUP_DELAY, configure_almond) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, almond_hass_start) + + conversation.async_set_agent(hass, agent) + return True + + +async def _configure_almond_for_ha( + hass: HomeAssistant, entry: config_entries.ConfigEntry, api: WebAlmondAPI +): + """Configure Almond to connect to HA.""" + + if entry.data["type"] == TYPE_OAUTH2: + # If we're connecting over OAuth2, we will only set up connection + # with Home Assistant if we're remotely accessible. + hass_url = network.async_get_external_url(hass) + else: + hass_url = hass.config.api.base_url + + # If hass_url is None, we're not going to configure Almond to connect to HA. + if hass_url is None: + return + + _LOGGER.debug("Configuring Almond to connect to Home Assistant at %s", hass_url) store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY) data = await store.async_load() @@ -144,11 +186,11 @@ async def async_setup_entry(hass, entry): # Store token in Almond try: - with async_timeout.timeout(10): + with async_timeout.timeout(30): await api.async_create_device( { "kind": "io.home-assistant", - "hassUrl": hass.config.api.base_url, + "hassUrl": hass_url, "accessToken": access_token, "refreshToken": "", # 5 years from now in ms. @@ -169,9 +211,6 @@ async def async_setup_entry(hass, entry): 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.""" @@ -203,7 +242,9 @@ class AlmondOAuth(AbstractAlmondWebAuth): class AlmondAgent(conversation.AbstractConversationAgent): """Almond conversation agent.""" - def __init__(self, hass: core.HomeAssistant, api: WebAlmondAPI, entry): + def __init__( + self, hass: HomeAssistant, api: WebAlmondAPI, entry: config_entries.ConfigEntry + ): """Initialize the agent.""" self.hass = hass self.api = api diff --git a/homeassistant/components/almond/strings.json b/homeassistant/components/almond/strings.json index 5cfc52044bb..872367eb862 100644 --- a/homeassistant/components/almond/strings.json +++ b/homeassistant/components/almond/strings.json @@ -1,5 +1,10 @@ { "config": { + "step": { + "pick_implementation": { + "title": "Pick Authentication Method" + } + }, "abort": { "already_setup": "You can only configure one Almond account.", "cannot_connect": "Unable to connect to the Almond server.", diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py new file mode 100644 index 00000000000..671e7f1fa56 --- /dev/null +++ b/homeassistant/helpers/network.py @@ -0,0 +1,38 @@ +"""Network helpers.""" +from typing import Optional, cast +from ipaddress import ip_address + +import yarl + +from homeassistant.core import HomeAssistant, callback +from homeassistant.loader import bind_hass +from homeassistant.util.network import is_local + + +@bind_hass +@callback +def async_get_external_url(hass: HomeAssistant) -> Optional[str]: + """Get external url of this instance. + + Note: currently it takes 30 seconds after Home Assistant starts for + cloud.async_remote_ui_url to work. + """ + if "cloud" in hass.config.components: + try: + return cast(str, hass.components.cloud.async_remote_ui_url()) + except hass.components.cloud.CloudNotAvailable: + pass + + if hass.config.api is None: + return None + + base_url = yarl.URL(hass.config.api.base_url) + + try: + if is_local(ip_address(base_url.host)): + return None + except ValueError: + # ip_address raises ValueError if host is not an IP address + pass + + return str(base_url) diff --git a/tests/components/almond/test_init.py b/tests/components/almond/test_init.py new file mode 100644 index 00000000000..dd44ea1c8f0 --- /dev/null +++ b/tests/components/almond/test_init.py @@ -0,0 +1,113 @@ +"""Tests for Almond set up.""" +from unittest.mock import patch +from time import time + +import pytest + +from homeassistant import config_entries, core +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.setup import async_setup_component +from homeassistant.components.almond import const +from homeassistant.util.dt import utcnow + +from tests.common import MockConfigEntry, mock_coro, async_fire_time_changed + + +@pytest.fixture(autouse=True) +def patch_hass_state(hass): + """Mock the hass.state to be not_running.""" + hass.state = core.CoreState.not_running + + +async def test_set_up_oauth_remote_url(hass, aioclient_mock): + """Test we set up Almond to connect to HA if we have external url.""" + entry = MockConfigEntry( + domain="almond", + data={ + "type": const.TYPE_OAUTH2, + "auth_implementation": "local", + "host": "http://localhost:9999", + "token": {"expires_at": time() + 1000, "access_token": "abcd"}, + }, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + return_value=mock_coro(), + ): + assert await async_setup_component(hass, "almond", {}) + + assert entry.state == config_entries.ENTRY_STATE_LOADED + + with patch("homeassistant.components.almond.ALMOND_SETUP_DELAY", 0), patch( + "homeassistant.helpers.network.async_get_external_url", + return_value="https://example.nabu.casa", + ), patch( + "pyalmond.WebAlmondAPI.async_create_device", return_value=mock_coro() + ) as mock_create_device: + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow()) + await hass.async_block_till_done() + + assert len(mock_create_device.mock_calls) == 1 + + +async def test_set_up_oauth_no_external_url(hass, aioclient_mock): + """Test we do not set up Almond to connect to HA if we have no external url.""" + entry = MockConfigEntry( + domain="almond", + data={ + "type": const.TYPE_OAUTH2, + "auth_implementation": "local", + "host": "http://localhost:9999", + "token": {"expires_at": time() + 1000, "access_token": "abcd"}, + }, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + return_value=mock_coro(), + ), patch("pyalmond.WebAlmondAPI.async_create_device") as mock_create_device: + assert await async_setup_component(hass, "almond", {}) + + assert entry.state == config_entries.ENTRY_STATE_LOADED + assert len(mock_create_device.mock_calls) == 0 + + +async def test_set_up_hassio(hass, aioclient_mock): + """Test we do not set up Almond to connect to HA if we use hassio.""" + entry = MockConfigEntry( + domain="almond", + data={ + "is_hassio": True, + "type": const.TYPE_LOCAL, + "host": "http://localhost:9999", + }, + ) + entry.add_to_hass(hass) + + with patch("pyalmond.WebAlmondAPI.async_create_device") as mock_create_device: + assert await async_setup_component(hass, "almond", {}) + + assert entry.state == config_entries.ENTRY_STATE_LOADED + assert len(mock_create_device.mock_calls) == 0 + + +async def test_set_up_local(hass, aioclient_mock): + """Test we do not set up Almond to connect to HA if we use hassio.""" + entry = MockConfigEntry( + domain="almond", + data={"type": const.TYPE_LOCAL, "host": "http://localhost:9999"}, + ) + entry.add_to_hass(hass) + + with patch( + "pyalmond.WebAlmondAPI.async_create_device", return_value=mock_coro() + ) as mock_create_device: + assert await async_setup_component(hass, "almond", {}) + + assert entry.state == config_entries.ENTRY_STATE_LOADED + assert len(mock_create_device.mock_calls) == 1 diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py new file mode 100644 index 00000000000..afb9e88c5a4 --- /dev/null +++ b/tests/helpers/test_network.py @@ -0,0 +1,34 @@ +"""Test network helper.""" +from unittest.mock import Mock, patch + +from homeassistant.helpers import network +from homeassistant.components import cloud + + +async def test_get_external_url(hass): + """Test get_external_url.""" + hass.config.api = Mock(base_url="http://192.168.1.100:8123") + + assert network.async_get_external_url(hass) is None + + hass.config.api = Mock(base_url="http://example.duckdns.org:8123") + + assert network.async_get_external_url(hass) == "http://example.duckdns.org:8123" + + hass.config.components.add("cloud") + + assert network.async_get_external_url(hass) == "http://example.duckdns.org:8123" + + with patch.object( + hass.components.cloud, + "async_remote_ui_url", + side_effect=cloud.CloudNotAvailable, + ): + assert network.async_get_external_url(hass) == "http://example.duckdns.org:8123" + + with patch.object( + hass.components.cloud, + "async_remote_ui_url", + return_value="https://example.nabu.casa", + ): + assert network.async_get_external_url(hass) == "https://example.nabu.casa"