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

View File

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

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"