Set up Almond Web to connect to HA (#28603)

* Set up Almond Web to connect to HA

* Add missing string

* Add type
This commit is contained in:
Paulus Schoutsen 2019-11-12 11:01:19 -08:00 committed by GitHub
parent a1f2b6d402
commit 5961215e6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 245 additions and 14 deletions

View File

@ -10,16 +10,18 @@ from aiohttp import ClientSession, ClientError
from pyalmond import AlmondLocalAuth, AbstractAlmondWebAuth, WebAlmondAPI from pyalmond import AlmondLocalAuth, AbstractAlmondWebAuth, WebAlmondAPI
import voluptuous as vol import voluptuous as vol
from homeassistant import core from homeassistant.core import HomeAssistant, CoreState
from homeassistant.const import CONF_TYPE, CONF_HOST from homeassistant.const import CONF_TYPE, CONF_HOST, EVENT_HOMEASSISTANT_START
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.helpers import ( from homeassistant.helpers import (
config_validation as cv, config_validation as cv,
config_entry_oauth2_flow, config_entry_oauth2_flow,
event,
intent, intent,
aiohttp_client, aiohttp_client,
storage, storage,
network,
) )
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import conversation from homeassistant.components import conversation
@ -33,6 +35,8 @@ CONF_CLIENT_SECRET = "client_secret"
STORAGE_VERSION = 1 STORAGE_VERSION = 1
STORAGE_KEY = DOMAIN STORAGE_KEY = DOMAIN
ALMOND_SETUP_DELAY = 30
DEFAULT_OAUTH2_HOST = "https://almond.stanford.edu" DEFAULT_OAUTH2_HOST = "https://almond.stanford.edu"
DEFAULT_LOCAL_HOST = "http://localhost:3000" DEFAULT_LOCAL_HOST = "http://localhost:3000"
@ -93,7 +97,7 @@ async def async_setup(hass, config):
return True 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.""" """Set up Almond config entry."""
websession = aiohttp_client.async_get_clientsession(hass) websession = aiohttp_client.async_get_clientsession(hass)
@ -112,12 +116,50 @@ async def async_setup_entry(hass, entry):
api = WebAlmondAPI(auth) api = WebAlmondAPI(auth)
agent = AlmondAgent(hass, api, entry) agent = AlmondAgent(hass, api, entry)
# Hass.io does its own configuration of Almond. # Hass.io does its own configuration.
if entry.data.get("is_hassio") or entry.data["type"] != TYPE_LOCAL: if not entry.data.get("is_hassio"):
conversation.async_set_agent(hass, agent) # If we're not starting or local, set up Almond right away
return True 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) store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY)
data = await store.async_load() data = await store.async_load()
@ -144,11 +186,11 @@ async def async_setup_entry(hass, entry):
# Store token in Almond # Store token in Almond
try: try:
with async_timeout.timeout(10): with async_timeout.timeout(30):
await api.async_create_device( await api.async_create_device(
{ {
"kind": "io.home-assistant", "kind": "io.home-assistant",
"hassUrl": hass.config.api.base_url, "hassUrl": hass_url,
"accessToken": access_token, "accessToken": access_token,
"refreshToken": "", "refreshToken": "",
# 5 years from now in ms. # 5 years from now in ms.
@ -169,9 +211,6 @@ async def async_setup_entry(hass, entry):
if token.id != refresh_token.id: if token.id != refresh_token.id:
await hass.auth.async_remove_refresh_token(token) await hass.auth.async_remove_refresh_token(token)
conversation.async_set_agent(hass, agent)
return True
async def async_unload_entry(hass, entry): async def async_unload_entry(hass, entry):
"""Unload Almond.""" """Unload Almond."""
@ -203,7 +242,9 @@ class AlmondOAuth(AbstractAlmondWebAuth):
class AlmondAgent(conversation.AbstractConversationAgent): class AlmondAgent(conversation.AbstractConversationAgent):
"""Almond conversation agent.""" """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.""" """Initialize the agent."""
self.hass = hass self.hass = hass
self.api = api self.api = api

View File

@ -1,5 +1,10 @@
{ {
"config": { "config": {
"step": {
"pick_implementation": {
"title": "Pick Authentication Method"
}
},
"abort": { "abort": {
"already_setup": "You can only configure one Almond account.", "already_setup": "You can only configure one Almond account.",
"cannot_connect": "Unable to connect to the Almond server.", "cannot_connect": "Unable to connect to the Almond server.",

View File

@ -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)

View File

@ -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

View File

@ -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"