mirror of
https://github.com/home-assistant/core.git
synced 2025-05-06 06:59:15 +00:00
Almond integration (#28282)
* Initial Almond integration * Hassfest * Update library * Address comments * Fix inheritance issue py36 * Remove no longer needed check * Fix time
This commit is contained in:
parent
a4ec4d5a18
commit
79ac77a93d
@ -19,6 +19,7 @@ homeassistant/components/airly/* @bieniu
|
|||||||
homeassistant/components/airvisual/* @bachya
|
homeassistant/components/airvisual/* @bachya
|
||||||
homeassistant/components/alarm_control_panel/* @colinodell
|
homeassistant/components/alarm_control_panel/* @colinodell
|
||||||
homeassistant/components/alexa/* @home-assistant/cloud @ochlocracy
|
homeassistant/components/alexa/* @home-assistant/cloud @ochlocracy
|
||||||
|
homeassistant/components/almond/* @gcampax @balloob
|
||||||
homeassistant/components/alpha_vantage/* @fabaff
|
homeassistant/components/alpha_vantage/* @fabaff
|
||||||
homeassistant/components/amazon_polly/* @robbiet480
|
homeassistant/components/amazon_polly/* @robbiet480
|
||||||
homeassistant/components/ambiclimate/* @danielhiversen
|
homeassistant/components/ambiclimate/* @danielhiversen
|
||||||
|
@ -261,7 +261,7 @@ class AuthManager:
|
|||||||
"""Enable a multi-factor auth module for user."""
|
"""Enable a multi-factor auth module for user."""
|
||||||
if user.system_generated:
|
if user.system_generated:
|
||||||
raise ValueError(
|
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)
|
module = self.get_auth_mfa_module(mfa_module_id)
|
||||||
@ -276,7 +276,7 @@ class AuthManager:
|
|||||||
"""Disable a multi-factor auth module for user."""
|
"""Disable a multi-factor auth module for user."""
|
||||||
if user.system_generated:
|
if user.system_generated:
|
||||||
raise ValueError(
|
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)
|
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):
|
if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM):
|
||||||
raise ValueError(
|
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:
|
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
|
token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
|
||||||
and client_name is None
|
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:
|
if token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN:
|
||||||
for token in user.refresh_tokens.values():
|
for token in user.refresh_tokens.values():
|
||||||
|
8
homeassistant/components/almond/.translations/en.json
Normal file
8
homeassistant/components/almond/.translations/en.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_setup": "You can only configure one Almond account."
|
||||||
|
},
|
||||||
|
"title": "Almond"
|
||||||
|
}
|
||||||
|
}
|
230
homeassistant/components/almond/__init__.py
Normal file
230
homeassistant/components/almond/__init__.py
Normal file
@ -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
|
125
homeassistant/components/almond/config_flow.py
Normal file
125
homeassistant/components/almond/config_flow.py
Normal file
@ -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({}),
|
||||||
|
)
|
4
homeassistant/components/almond/const.py
Normal file
4
homeassistant/components/almond/const.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""Constants for the Almond integration."""
|
||||||
|
DOMAIN = "almond"
|
||||||
|
TYPE_OAUTH2 = "oauth2"
|
||||||
|
TYPE_LOCAL = "local"
|
9
homeassistant/components/almond/manifest.json
Normal file
9
homeassistant/components/almond/manifest.json
Normal file
@ -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"]
|
||||||
|
}
|
9
homeassistant/components/almond/strings.json
Normal file
9
homeassistant/components/almond/strings.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
@ -9,6 +9,7 @@ FLOWS = [
|
|||||||
"abode",
|
"abode",
|
||||||
"adguard",
|
"adguard",
|
||||||
"airly",
|
"airly",
|
||||||
|
"almond",
|
||||||
"ambiclimate",
|
"ambiclimate",
|
||||||
"ambient_station",
|
"ambient_station",
|
||||||
"axis",
|
"axis",
|
||||||
|
@ -387,17 +387,20 @@ class OAuth2Session:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def token(self) -> dict:
|
def token(self) -> dict:
|
||||||
"""Return the current token."""
|
"""Return the token."""
|
||||||
return cast(dict, self.config_entry.data["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:
|
async def async_ensure_token_valid(self) -> None:
|
||||||
"""Ensure that the current token is valid."""
|
"""Ensure that the current token is valid."""
|
||||||
token = self.token
|
if self.valid_token:
|
||||||
|
|
||||||
if token["expires_at"] > time.time():
|
|
||||||
return
|
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.hass.config_entries.async_update_entry(
|
||||||
self.config_entry, data={**self.config_entry.data, "token": new_token}
|
self.config_entry, data={**self.config_entry.data, "token": new_token}
|
||||||
|
@ -1089,6 +1089,9 @@ pyairvisual==3.0.1
|
|||||||
# homeassistant.components.alarmdotcom
|
# homeassistant.components.alarmdotcom
|
||||||
pyalarmdotcom==0.3.2
|
pyalarmdotcom==0.3.2
|
||||||
|
|
||||||
|
# homeassistant.components.almond
|
||||||
|
pyalmond==0.0.2
|
||||||
|
|
||||||
# homeassistant.components.arlo
|
# homeassistant.components.arlo
|
||||||
pyarlo==0.2.3
|
pyarlo==0.2.3
|
||||||
|
|
||||||
|
@ -390,6 +390,9 @@ pyRFXtrx==0.23
|
|||||||
# homeassistant.components.nextbus
|
# homeassistant.components.nextbus
|
||||||
py_nextbusnext==0.1.4
|
py_nextbusnext==0.1.4
|
||||||
|
|
||||||
|
# homeassistant.components.almond
|
||||||
|
pyalmond==0.0.2
|
||||||
|
|
||||||
# homeassistant.components.arlo
|
# homeassistant.components.arlo
|
||||||
pyarlo==0.2.3
|
pyarlo==0.2.3
|
||||||
|
|
||||||
|
1
tests/components/almond/__init__.py
Normal file
1
tests/components/almond/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for the Almond integration."""
|
138
tests/components/almond/test_config_flow.py
Normal file
138
tests/components/almond/test_config_flow.py
Normal file
@ -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"
|
Loading…
x
Reference in New Issue
Block a user