mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 12:47:08 +00:00
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:
parent
a1f2b6d402
commit
5961215e6e
@ -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
|
||||||
|
@ -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.",
|
||||||
|
38
homeassistant/helpers/network.py
Normal file
38
homeassistant/helpers/network.py
Normal 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)
|
113
tests/components/almond/test_init.py
Normal file
113
tests/components/almond/test_init.py
Normal 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
|
34
tests/helpers/test_network.py
Normal file
34
tests/helpers/test_network.py
Normal 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"
|
Loading…
x
Reference in New Issue
Block a user