Convert Hue to use unique ID (#30000)

* Convert Hue to use unique ID

* Fix normalization

* Store/restore unique ID

* Fix tests
This commit is contained in:
Paulus Schoutsen 2019-12-16 19:45:09 +01:00 committed by GitHub
parent 575eb48feb
commit 58b5833d64
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 385 additions and 440 deletions

View file

@ -2,16 +2,14 @@
import ipaddress
import logging
from aiohue.util import normalize_bridge_id
import voluptuous as vol
from homeassistant import config_entries, core
from homeassistant.const import CONF_FILENAME, CONF_HOST
from homeassistant.const import CONF_HOST
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .bridge import HueBridge, normalize_bridge_id
from .config_flow import ( # Loading the config flow file will register the flow
configured_hosts,
)
from .bridge import HueBridge
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@ -32,8 +30,6 @@ BRIDGE_CONFIG_SCHEMA = vol.Schema(
{
# Validate as IP address and then convert back to a string.
vol.Required(CONF_HOST): vol.All(ipaddress.ip_address, cv.string),
# This is for legacy reasons and is only used for importing auth.
vol.Optional(CONF_FILENAME, default=PHUE_CONFIG_FILE): cv.string,
vol.Optional(
CONF_ALLOW_UNREACHABLE, default=DEFAULT_ALLOW_UNREACHABLE
): cv.boolean,
@ -65,7 +61,6 @@ async def async_setup(hass, config):
hass.data[DOMAIN] = {}
hass.data[DATA_CONFIGS] = {}
configured = configured_hosts(hass)
# User has configured bridges
if CONF_BRIDGES not in conf:
@ -73,29 +68,28 @@ async def async_setup(hass, config):
bridges = conf[CONF_BRIDGES]
configured_hosts = set(
entry.data["host"] for entry in hass.config_entries.async_entries(DOMAIN)
)
for bridge_conf in bridges:
host = bridge_conf[CONF_HOST]
# Store config in hass.data so the config entry can find it
hass.data[DATA_CONFIGS][host] = bridge_conf
# If configured, the bridge will be set up during config entry phase
if host in configured:
if host in configured_hosts:
continue
# No existing config entry found, try importing it or trigger link
# config flow if no existing auth. Because we're inside the setup of
# this component we'll have to use hass.async_add_job to avoid a
# deadlock: creating a config entry will set up the component but the
# setup would block till the entry is created!
# No existing config entry found, trigger link config flow. Because we're
# inside the setup of this component we'll have to use hass.async_add_job
# to avoid a deadlock: creating a config entry will set up the component
# but the setup would block till the entry is created!
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
"host": bridge_conf[CONF_HOST],
"path": bridge_conf[CONF_FILENAME],
},
data={"host": bridge_conf[CONF_HOST]},
)
)

View file

@ -6,6 +6,7 @@ import async_timeout
import slugify as unicode_slug
import voluptuous as vol
from homeassistant import core
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_validation as cv
@ -45,8 +46,15 @@ class HueBridge:
host = self.host
hass = self.hass
bridge = aiohue.Bridge(
host,
username=self.config_entry.data["username"],
websession=aiohttp_client.async_get_clientsession(hass),
)
try:
self.api = await get_bridge(hass, host, self.config_entry.data["username"])
await authenticate_bridge(hass, bridge)
except AuthenticationRequired:
# Usernames can become invalid if hub is reset or user removed.
# We are going to fail the config entry setup and initiate a new
@ -63,6 +71,8 @@ class HueBridge:
LOGGER.exception("Unknown error connecting with Hue bridge at %s", host)
return False
self.api = bridge
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(self.config_entry, "light")
)
@ -175,16 +185,12 @@ class HueBridge:
create_config_flow(self.hass, self.host)
async def get_bridge(hass, host, username=None):
async def authenticate_bridge(hass: core.HomeAssistant, bridge: aiohue.Bridge):
"""Create a bridge object and verify authentication."""
bridge = aiohue.Bridge(
host, username=username, websession=aiohttp_client.async_get_clientsession(hass)
)
try:
with async_timeout.timeout(10):
# Create username if we don't have one
if not username:
if not bridge.username:
device_name = unicode_slug.slugify(
hass.config.location_name, max_length=19
)
@ -193,7 +199,6 @@ async def get_bridge(hass, host, username=None):
# Initialize bridge (and validate our username)
await bridge.initialize()
return bridge
except (aiohue.LinkButtonNotPressed, aiohue.Unauthorized):
raise AuthenticationRequired
except (asyncio.TimeoutError, aiohue.RequestError):
@ -201,25 +206,3 @@ async def get_bridge(hass, host, username=None):
except aiohue.AiohueException:
LOGGER.exception("Unknown Hue linking error occurred")
raise AuthenticationRequired
def normalize_bridge_id(bridge_id: str):
"""Normalize a bridge identifier.
There are three sources where we receive bridge ID from:
- ssdp/upnp: <host>/description.xml, field root/device/serialNumber
- nupnp: "id" field
- Hue Bridge API: config.bridgeid
The SSDP/UPNP source does not contain the middle 4 characters compared
to the other sources. In all our tests the middle 4 characters are "fffe".
"""
if len(bridge_id) == 16:
return bridge_id[0:6] + bridge_id[-6:]
if len(bridge_id) == 12:
return bridge_id
LOGGER.warning("Unexpected bridge id number found: %s", bridge_id)
return bridge_id

View file

@ -1,51 +1,24 @@
"""Config flow to configure Philips Hue."""
import asyncio
import json
import os
from typing import Dict, Optional
from aiohue.discovery import discover_nupnp
import aiohue
from aiohue.discovery import discover_nupnp, normalize_bridge_id
import async_timeout
import voluptuous as vol
from homeassistant import config_entries
from homeassistant import config_entries, core
from homeassistant.components.ssdp import ATTR_MANUFACTURERURL, ATTR_NAME
from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client
from .bridge import get_bridge, normalize_bridge_id
from .const import DOMAIN, LOGGER
from .bridge import authenticate_bridge
from .const import DOMAIN, LOGGER # pylint: disable=unused-import
from .errors import AuthenticationRequired, CannotConnect
HUE_MANUFACTURERURL = "http://www.philips.com"
HUE_IGNORED_BRIDGE_NAMES = ["HASS Bridge", "Espalexa"]
@callback
def configured_hosts(hass):
"""Return a set of the configured hosts."""
return set(
entry.data["host"] for entry in hass.config_entries.async_entries(DOMAIN)
)
def _find_username_from_config(hass, filename):
"""Load username from config.
This was a legacy way of configuring Hue until Home Assistant 0.67.
"""
path = hass.config.path(filename)
if not os.path.isfile(path):
return None
with open(path) as inp:
try:
return list(json.load(inp).values())[0]["username"]
except ValueError:
# If we get invalid JSON
return None
class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a Hue config flow."""
@ -56,23 +29,45 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
def __init__(self):
"""Initialize the Hue flow."""
self.host = None
self.bridge: Optional[aiohue.Bridge] = None
self.discovered_bridges: Optional[Dict[str, aiohue.Bridge]] = None
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
# This is for backwards compatibility.
return await self.async_step_init(user_input)
@core.callback
def _async_get_bridge(self, host: str, bridge_id: Optional[str] = None):
"""Return a bridge object."""
if bridge_id is not None:
bridge_id = normalize_bridge_id(bridge_id)
return aiohue.Bridge(
host,
websession=aiohttp_client.async_get_clientsession(self.hass),
bridge_id=bridge_id,
)
async def async_step_init(self, user_input=None):
"""Handle a flow start."""
if user_input is not None:
self.host = self.context["host"] = user_input["host"]
return await self.async_step_link()
websession = aiohttp_client.async_get_clientsession(self.hass)
if (
user_input is not None
and self.discovered_bridges is not None
# pylint: disable=unsupported-membership-test
and user_input["id"] in self.discovered_bridges
):
# pylint: disable=unsubscriptable-object
self.bridge = self.discovered_bridges[user_input["id"]]
await self.async_set_unique_id(self.bridge.id, raise_on_progress=False)
# We pass user input to link so it will attempt to link right away
return await self.async_step_link({})
try:
with async_timeout.timeout(5):
bridges = await discover_nupnp(websession=websession)
bridges = await discover_nupnp(
websession=aiohttp_client.async_get_clientsession(self.hass)
)
except asyncio.TimeoutError:
return self.async_abort(reason="discover_timeout")
@ -80,20 +75,28 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="no_bridges")
# Find already configured hosts
configured = configured_hosts(self.hass)
already_configured = self._async_current_ids()
bridges = [bridge for bridge in bridges if bridge.id not in already_configured]
hosts = [bridge.host for bridge in bridges if bridge.host not in configured]
if not hosts:
if not bridges:
return self.async_abort(reason="all_configured")
if len(hosts) == 1:
self.host = hosts[0]
if len(bridges) == 1:
self.bridge = bridges[0]
await self.async_set_unique_id(self.bridge.id, raise_on_progress=False)
return await self.async_step_link()
self.discovered_bridges = {bridge.id: bridge for bridge in bridges}
return self.async_show_form(
step_id="init",
data_schema=vol.Schema({vol.Required("host"): vol.In(hosts)}),
data_schema=vol.Schema(
{
vol.Required("id"): vol.In(
{bridge.id: bridge.host for bridge in bridges}
)
}
),
)
async def async_step_link(self, user_input=None):
@ -102,31 +105,39 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
Given a configured host, will ask the user to press the link button
to connect to the bridge.
"""
if user_input is None:
return self.async_show_form(step_id="link")
bridge = self.bridge
assert bridge is not None
errors = {}
# We will always try linking in case the user has already pressed
# the link button.
try:
bridge = await get_bridge(self.hass, self.host, username=None)
await authenticate_bridge(self.hass, bridge)
return await self._entry_from_bridge(bridge)
# Can happen if we come from import.
if self.unique_id is None:
await self.async_set_unique_id(
normalize_bridge_id(bridge.id), raise_on_progress=False
)
return self.async_create_entry(
title=bridge.config.name,
data={"host": bridge.host, "username": bridge.username},
)
except AuthenticationRequired:
errors["base"] = "register_failed"
except CannotConnect:
LOGGER.error("Error connecting to the Hue bridge at %s", self.host)
LOGGER.error("Error connecting to the Hue bridge at %s", bridge.host)
errors["base"] = "linking"
except Exception: # pylint: disable=broad-except
LOGGER.exception(
"Unknown error connecting with Hue bridge at %s", self.host
"Unknown error connecting with Hue bridge at %s", bridge.host
)
errors["base"] = "linking"
# If there was no user input, do not show the errors.
if user_input is None:
errors = {}
return self.async_show_form(step_id="link", errors=errors)
async def async_step_ssdp(self, discovery_info):
@ -135,113 +146,55 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
This flow is triggered by the SSDP component. It will check if the
host is already configured and delegate to the import step if not.
"""
# Filter out non-Hue bridges #1
if discovery_info[ATTR_MANUFACTURERURL] != HUE_MANUFACTURERURL:
return self.async_abort(reason="not_hue_bridge")
# Filter out non-Hue bridges #2
if any(
name in discovery_info.get(ATTR_NAME, "")
for name in HUE_IGNORED_BRIDGE_NAMES
):
return self.async_abort(reason="not_hue_bridge")
host = self.context["host"] = discovery_info.get("host")
if "host" not in discovery_info or "serial" not in discovery_info:
return self.async_abort(reason="not_hue_bridge")
if any(
host == flow["context"].get("host") for flow in self._async_in_progress()
):
return self.async_abort(reason="already_in_progress")
if host in configured_hosts(self.hass):
return self.async_abort(reason="already_configured")
bridge_id = discovery_info.get("serial")
await self.async_set_unique_id(normalize_bridge_id(bridge_id))
return await self.async_step_import(
{
"host": host,
# This format is the legacy format that Hue used for discovery
"path": f"phue-{bridge_id}.conf",
}
bridge = self._async_get_bridge(
discovery_info["host"], discovery_info["serial"]
)
await self.async_set_unique_id(bridge.id)
self._abort_if_unique_id_configured()
self.bridge = bridge
return await self.async_step_link()
async def async_step_homekit(self, homekit_info):
"""Handle HomeKit discovery."""
host = self.context["host"] = homekit_info.get("host")
if any(
host == flow["context"].get("host") for flow in self._async_in_progress()
):
return self.async_abort(reason="already_in_progress")
if host in configured_hosts(self.hass):
return self.async_abort(reason="already_configured")
await self.async_set_unique_id(
normalize_bridge_id(homekit_info["properties"]["id"].replace(":", ""))
bridge = self._async_get_bridge(
homekit_info["host"], homekit_info["properties"]["id"]
)
return await self.async_step_import({"host": host})
await self.async_set_unique_id(bridge.id)
self._abort_if_unique_id_configured()
self.bridge = bridge
return await self.async_step_link()
async def async_step_import(self, import_info):
"""Import a new bridge as a config entry.
Will read authentication from Phue config file if available.
This flow is triggered by `async_setup` for both configured and
discovered bridges. Triggered for any bridge that does not have a
config entry yet (based on host).
This flow is also triggered by `async_step_discovery`.
If an existing config file is found, we will validate the credentials
and create an entry. Otherwise we will delegate to `link` step which
will ask user to link the bridge.
"""
host = self.context["host"] = import_info["host"]
path = import_info.get("path")
# Check if host exists, abort if so.
if any(
import_info["host"] == entry.data["host"]
for entry in self._async_current_entries()
):
return self.async_abort(reason="already_configured")
if path is not None:
username = await self.hass.async_add_job(
_find_username_from_config, self.hass, self.hass.config.path(path)
)
else:
username = None
try:
bridge = await get_bridge(self.hass, host, username)
LOGGER.info("Imported authentication for %s from %s", host, path)
return await self._entry_from_bridge(bridge)
except AuthenticationRequired:
self.host = host
LOGGER.info("Invalid authentication for %s, requesting link.", host)
return await self.async_step_link()
except CannotConnect:
LOGGER.error("Error connecting to the Hue bridge at %s", host)
return self.async_abort(reason="cannot_connect")
except Exception: # pylint: disable=broad-except
LOGGER.exception("Unknown error connecting with Hue bridge at %s", host)
return self.async_abort(reason="unknown")
async def _entry_from_bridge(self, bridge):
"""Return a config entry from an initialized bridge."""
# Remove all other entries of hubs with same ID or host
host = bridge.host
bridge_id = bridge.config.bridgeid
if self.unique_id is None:
await self.async_set_unique_id(
normalize_bridge_id(bridge_id), raise_on_progress=False
)
return self.async_create_entry(
title=bridge.config.name,
data={"host": host, "bridge_id": bridge_id, "username": bridge.username},
)
self.bridge = self._async_get_bridge(import_info["host"])
return await self.async_step_link()

View file

@ -3,21 +3,15 @@
"name": "Philips Hue",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hue",
"requirements": [
"aiohue==1.9.2"
],
"requirements": ["aiohue==1.10.1"],
"ssdp": [
{
"manufacturer": "Royal Philips Electronics"
}
],
"homekit": {
"models": [
"BSB002"
]
"models": ["BSB002"]
},
"dependencies": [],
"codeowners": [
"@balloob"
]
"codeowners": ["@balloob"]
}

View file

@ -75,10 +75,6 @@ class OperationNotAllowed(ConfigError):
"""Raised when a config entry operation is not allowed."""
class UniqueIdInProgress(data_entry_flow.AbortFlow):
"""Error to indicate that the unique Id is in progress."""
class ConfigEntry:
"""Hold a configuration entry."""
@ -379,6 +375,7 @@ class ConfigEntry:
"system_options": self.system_options.as_dict(),
"source": self.source,
"connection_class": self.connection_class,
"unique_id": self.unique_id,
}
@ -482,6 +479,8 @@ class ConfigEntries:
options=entry.get("options"),
# New in 0.98
system_options=entry.get("system_options", {}),
# New in 0.104
unique_id=entry.get("unique_id"),
)
for entry in config["entries"]
]
@ -617,11 +616,20 @@ class ConfigEntries:
# Check if config entry exists with unique ID. Unload it.
existing_entry = None
unique_id = flow.context.get("unique_id")
if unique_id is not None:
if flow.unique_id is not None:
# Abort all flows in progress with same unique ID.
for progress_flow in self.flow.async_progress():
if (
progress_flow["handler"] == flow.handler
and progress_flow["flow_id"] != flow.flow_id
and progress_flow["context"].get("unique_id") == flow.unique_id
):
self.flow.async_abort(progress_flow["flow_id"])
# Find existing entry.
for check_entry in self.async_entries(result["handler"]):
if check_entry.unique_id == unique_id:
if check_entry.unique_id == flow.unique_id:
existing_entry = check_entry
break
@ -643,16 +651,17 @@ class ConfigEntries:
system_options={},
source=flow.context["source"],
connection_class=flow.CONNECTION_CLASS,
unique_id=unique_id,
unique_id=flow.unique_id,
)
self._entries.append(entry)
self._async_schedule_save()
await self.async_setup(entry.entry_id)
if existing_entry is not None:
await self.async_remove(existing_entry.entry_id)
self._async_schedule_save()
result["result"] = entry
return result
@ -723,8 +732,6 @@ async def _old_conf_migrator(old_config: Dict[str, Any]) -> Dict[str, Any]:
class ConfigFlow(data_entry_flow.FlowHandler):
"""Base class for config flows with some helpers."""
unique_id = None
def __init_subclass__(cls, domain: Optional[str] = None, **kwargs: Any) -> None:
"""Initialize a subclass, register if possible."""
super().__init_subclass__(**kwargs) # type: ignore
@ -733,12 +740,30 @@ class ConfigFlow(data_entry_flow.FlowHandler):
CONNECTION_CLASS = CONN_CLASS_UNKNOWN
@property
def unique_id(self) -> Optional[str]:
"""Return unique ID if available."""
# pylint: disable=no-member
if not self.context:
return None
return cast(Optional[str], self.context.get("unique_id"))
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> "OptionsFlow":
"""Get the options flow for this handler."""
raise data_entry_flow.UnknownHandler
@callback
def _abort_if_unique_id_configured(self) -> None:
"""Abort if the unique ID is already configured."""
if self.unique_id is None:
return
if self.unique_id in self._async_current_ids():
raise data_entry_flow.AbortFlow("already_configured")
async def async_set_unique_id(
self, unique_id: str, *, raise_on_progress: bool = True
) -> Optional[ConfigEntry]:
@ -749,7 +774,7 @@ class ConfigFlow(data_entry_flow.FlowHandler):
if raise_on_progress:
for progress in self._async_in_progress():
if progress["context"].get("unique_id") == unique_id:
raise UniqueIdInProgress("already_in_progress")
raise data_entry_flow.AbortFlow("already_in_progress")
# pylint: disable=no-member
self.context["unique_id"] = unique_id
@ -766,6 +791,15 @@ class ConfigFlow(data_entry_flow.FlowHandler):
assert self.hass is not None
return self.hass.config_entries.async_entries(self.handler)
@callback
def _async_current_ids(self) -> Set[Optional[str]]:
"""Return current unique IDs."""
assert self.hass is not None
return set(
entry.unique_id
for entry in self.hass.config_entries.async_entries(self.handler)
)
@callback
def _async_in_progress(self) -> List[Dict]:
"""Return other in progress flows for current domain."""

View file

@ -1134,6 +1134,9 @@ class ServiceRegistry:
self._services[domain].pop(service)
if not self._services[domain]:
self._services.pop(domain)
self._hass.bus.async_fire(
EVENT_SERVICE_REMOVED, {ATTR_DOMAIN: domain, ATTR_SERVICE: service}
)

View file

@ -163,7 +163,7 @@ aioharmony==0.1.13
aiohttp_cors==0.7.0
# homeassistant.components.hue
aiohue==1.9.2
aiohue==1.10.1
# homeassistant.components.imap
aioimaplib==0.7.15

View file

@ -63,7 +63,7 @@ aioesphomeapi==2.6.1
aiohttp_cors==0.7.0
# homeassistant.components.hue
aiohue==1.9.2
aiohue==1.10.1
# homeassistant.components.notion
aionotion==1.1.0

View file

@ -9,107 +9,110 @@ from homeassistant.exceptions import ConfigEntryNotReady
from tests.common import mock_coro
async def test_bridge_setup():
async def test_bridge_setup(hass):
"""Test a successful setup."""
hass = Mock()
entry = Mock()
api = Mock()
api = Mock(initialize=mock_coro)
entry.data = {"host": "1.2.3.4", "username": "mock-username"}
hue_bridge = bridge.HueBridge(hass, entry, False, False)
with patch.object(bridge, "get_bridge", return_value=mock_coro(api)):
with patch("aiohue.Bridge", return_value=api), patch.object(
hass.config_entries, "async_forward_entry_setup"
) as mock_forward:
assert await hue_bridge.async_setup() is True
assert hue_bridge.api is api
forward_entries = set(
c[1][1] for c in hass.config_entries.async_forward_entry_setup.mock_calls
)
assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 3
assert len(mock_forward.mock_calls) == 3
forward_entries = set(c[1][1] for c in mock_forward.mock_calls)
assert forward_entries == set(["light", "binary_sensor", "sensor"])
async def test_bridge_setup_invalid_username():
async def test_bridge_setup_invalid_username(hass):
"""Test we start config flow if username is no longer whitelisted."""
hass = Mock()
entry = Mock()
entry.data = {"host": "1.2.3.4", "username": "mock-username"}
hue_bridge = bridge.HueBridge(hass, entry, False, False)
with patch.object(bridge, "get_bridge", side_effect=errors.AuthenticationRequired):
assert await hue_bridge.async_setup() is False
assert len(hass.async_create_task.mock_calls) == 1
assert len(hass.config_entries.flow.async_init.mock_calls) == 1
assert hass.config_entries.flow.async_init.mock_calls[0][2]["data"] == {
"host": "1.2.3.4"
}
async def test_bridge_setup_timeout(hass):
"""Test we retry to connect if we cannot connect."""
hass = Mock()
entry = Mock()
entry.data = {"host": "1.2.3.4", "username": "mock-username"}
hue_bridge = bridge.HueBridge(hass, entry, False, False)
with patch.object(
bridge, "get_bridge", side_effect=errors.CannotConnect
bridge, "authenticate_bridge", side_effect=errors.AuthenticationRequired
), patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
assert await hue_bridge.async_setup() is False
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][2]["data"] == {"host": "1.2.3.4"}
async def test_bridge_setup_timeout(hass):
"""Test we retry to connect if we cannot connect."""
entry = Mock()
entry.data = {"host": "1.2.3.4", "username": "mock-username"}
hue_bridge = bridge.HueBridge(hass, entry, False, False)
with patch.object(
bridge, "authenticate_bridge", side_effect=errors.CannotConnect
), pytest.raises(ConfigEntryNotReady):
await hue_bridge.async_setup()
async def test_reset_if_entry_had_wrong_auth():
async def test_reset_if_entry_had_wrong_auth(hass):
"""Test calling reset when the entry contained wrong auth."""
hass = Mock()
entry = Mock()
entry.data = {"host": "1.2.3.4", "username": "mock-username"}
hue_bridge = bridge.HueBridge(hass, entry, False, False)
with patch.object(bridge, "get_bridge", side_effect=errors.AuthenticationRequired):
with patch.object(
bridge, "authenticate_bridge", side_effect=errors.AuthenticationRequired
), patch.object(bridge, "create_config_flow") as mock_create:
assert await hue_bridge.async_setup() is False
assert len(hass.async_create_task.mock_calls) == 1
assert len(mock_create.mock_calls) == 1
assert await hue_bridge.async_reset()
async def test_reset_unloads_entry_if_setup():
async def test_reset_unloads_entry_if_setup(hass):
"""Test calling reset while the entry has been setup."""
hass = Mock()
entry = Mock()
entry.data = {"host": "1.2.3.4", "username": "mock-username"}
hue_bridge = bridge.HueBridge(hass, entry, False, False)
with patch.object(bridge, "get_bridge", return_value=mock_coro(Mock())):
with patch.object(
bridge, "authenticate_bridge", return_value=mock_coro(Mock())
), patch("aiohue.Bridge", return_value=Mock()), patch.object(
hass.config_entries, "async_forward_entry_setup"
) as mock_forward:
assert await hue_bridge.async_setup() is True
assert len(hass.services.async_register.mock_calls) == 1
assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 3
assert len(hass.services.async_services()) == 1
assert len(mock_forward.mock_calls) == 3
hass.config_entries.async_forward_entry_unload.return_value = mock_coro(True)
assert await hue_bridge.async_reset()
with patch.object(
hass.config_entries, "async_forward_entry_unload", return_value=mock_coro(True)
) as mock_forward:
assert await hue_bridge.async_reset()
assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 3
assert len(hass.services.async_remove.mock_calls) == 1
assert len(mock_forward.mock_calls) == 3
assert len(hass.services.async_services()) == 0
async def test_handle_unauthorized():
async def test_handle_unauthorized(hass):
"""Test handling an unauthorized error on update."""
hass = Mock()
entry = Mock()
entry.data = {"host": "1.2.3.4", "username": "mock-username"}
hue_bridge = bridge.HueBridge(hass, entry, False, False)
with patch.object(bridge, "get_bridge", return_value=mock_coro(Mock())):
with patch.object(
bridge, "authenticate_bridge", return_value=mock_coro(Mock())
), patch("aiohue.Bridge", return_value=Mock()):
assert await hue_bridge.async_setup() is True
assert hue_bridge.authorized is True
await hue_bridge.handle_unauthorized_error()
with patch.object(bridge, "create_config_flow") as mock_create:
await hue_bridge.handle_unauthorized_error()
assert hue_bridge.authorized is False
assert len(hass.async_create_task.mock_calls) == 4
assert len(hass.config_entries.flow.async_init.mock_calls) == 1
assert hass.config_entries.flow.async_init.mock_calls[0][2]["data"] == {
"host": "1.2.3.4"
}
assert len(mock_create.mock_calls) == 1
assert mock_create.mock_calls[0][1][1] == "1.2.3.4"

View file

@ -6,50 +6,52 @@ import aiohue
import pytest
import voluptuous as vol
from homeassistant.components.hue import config_flow, const, errors
from homeassistant import data_entry_flow
from homeassistant.components.hue import config_flow, const
from tests.common import MockConfigEntry, mock_coro
async def test_flow_works(hass, aioclient_mock):
async def test_flow_works(hass):
"""Test config flow ."""
aioclient_mock.get(
const.API_NUPNP, json=[{"internalipaddress": "1.2.3.4", "id": "bla"}]
)
mock_bridge = Mock()
mock_bridge.host = "1.2.3.4"
mock_bridge.username = None
mock_bridge.config.name = "Mock Bridge"
mock_bridge.id = "aabbccddeeff"
async def mock_create_user(username):
mock_bridge.username = username
mock_bridge.create_user = mock_create_user
mock_bridge.initialize.return_value = mock_coro()
flow = config_flow.HueFlowHandler()
flow.hass = hass
flow.context = {}
await flow.async_step_init()
with patch("aiohue.Bridge") as mock_bridge:
with patch(
"homeassistant.components.hue.config_flow.discover_nupnp",
return_value=mock_coro([mock_bridge]),
):
result = await flow.async_step_init()
def mock_constructor(host, websession, username=None):
"""Fake the bridge constructor."""
mock_bridge.host = host
return mock_bridge
assert result["type"] == "form"
assert result["step_id"] == "link"
mock_bridge.side_effect = mock_constructor
mock_bridge.username = "username-abc"
mock_bridge.config.name = "Mock Bridge"
mock_bridge.config.bridgeid = "bridge-id-1234"
mock_bridge.create_user.return_value = mock_coro()
mock_bridge.initialize.return_value = mock_coro()
assert flow.context["unique_id"] == "aabbccddeeff"
result = await flow.async_step_link(user_input={})
assert mock_bridge.host == "1.2.3.4"
assert len(mock_bridge.create_user.mock_calls) == 1
assert len(mock_bridge.initialize.mock_calls) == 1
result = await flow.async_step_link(user_input={})
assert result["type"] == "create_entry"
assert result["title"] == "Mock Bridge"
assert result["data"] == {
"host": "1.2.3.4",
"bridge_id": "bridge-id-1234",
"username": "username-abc",
"username": "home-assistant#test-home",
}
assert len(mock_bridge.initialize.mock_calls) == 1
async def test_flow_no_discovered_bridges(hass, aioclient_mock):
"""Test config flow discovers no bridges."""
@ -66,9 +68,12 @@ async def test_flow_all_discovered_bridges_exist(hass, aioclient_mock):
aioclient_mock.get(
const.API_NUPNP, json=[{"internalipaddress": "1.2.3.4", "id": "bla"}]
)
MockConfigEntry(domain="hue", data={"host": "1.2.3.4"}).add_to_hass(hass)
MockConfigEntry(
domain="hue", unique_id="bla", data={"host": "1.2.3.4"}
).add_to_hass(hass)
flow = config_flow.HueFlowHandler()
flow.hass = hass
flow.context = {}
result = await flow.async_step_init()
assert result["type"] == "abort"
@ -81,6 +86,7 @@ async def test_flow_one_bridge_discovered(hass, aioclient_mock):
)
flow = config_flow.HueFlowHandler()
flow.hass = hass
flow.context = {}
result = await flow.async_step_init()
assert result["type"] == "form"
@ -104,10 +110,10 @@ async def test_flow_two_bridges_discovered(hass, aioclient_mock):
assert result["step_id"] == "init"
with pytest.raises(vol.Invalid):
assert result["data_schema"]({"host": "0.0.0.0"})
assert result["data_schema"]({"id": "not-discovered"})
result["data_schema"]({"host": "1.2.3.4"})
result["data_schema"]({"host": "5.6.7.8"})
result["data_schema"]({"id": "bla"})
result["data_schema"]({"id": "beer"})
async def test_flow_two_bridges_discovered_one_new(hass, aioclient_mock):
@ -119,14 +125,17 @@ async def test_flow_two_bridges_discovered_one_new(hass, aioclient_mock):
{"internalipaddress": "5.6.7.8", "id": "beer"},
],
)
MockConfigEntry(domain="hue", data={"host": "1.2.3.4"}).add_to_hass(hass)
MockConfigEntry(
domain="hue", unique_id="bla", data={"host": "1.2.3.4"}
).add_to_hass(hass)
flow = config_flow.HueFlowHandler()
flow.hass = hass
flow.context = {}
result = await flow.async_step_init()
assert result["type"] == "form"
assert result["step_id"] == "link"
assert flow.host == "5.6.7.8"
assert flow.bridge.host == "5.6.7.8"
async def test_flow_timeout_discovery(hass):
@ -147,6 +156,7 @@ async def test_flow_link_timeout(hass):
"""Test config flow ."""
flow = config_flow.HueFlowHandler()
flow.hass = hass
flow.bridge = Mock()
with patch("aiohue.Bridge.create_user", side_effect=asyncio.TimeoutError):
result = await flow.async_step_link({})
@ -160,9 +170,11 @@ async def test_flow_link_button_not_pressed(hass):
"""Test config flow ."""
flow = config_flow.HueFlowHandler()
flow.hass = hass
flow.bridge = Mock(
username=None, create_user=Mock(side_effect=aiohue.LinkButtonNotPressed)
)
with patch("aiohue.Bridge.create_user", side_effect=aiohue.LinkButtonNotPressed):
result = await flow.async_step_link({})
result = await flow.async_step_link({})
assert result["type"] == "form"
assert result["step_id"] == "link"
@ -173,6 +185,7 @@ async def test_flow_link_unknown_host(hass):
"""Test config flow ."""
flow = config_flow.HueFlowHandler()
flow.hass = hass
flow.bridge = Mock()
with patch("aiohue.Bridge.create_user", side_effect=aiohue.RequestError):
result = await flow.async_step_link({})
@ -188,16 +201,13 @@ async def test_bridge_ssdp(hass):
flow.hass = hass
flow.context = {}
with patch.object(
config_flow, "get_bridge", side_effect=errors.AuthenticationRequired
):
result = await flow.async_step_ssdp(
{
"host": "0.0.0.0",
"serial": "1234",
"manufacturerURL": config_flow.HUE_MANUFACTURERURL,
}
)
result = await flow.async_step_ssdp(
{
"host": "0.0.0.0",
"serial": "1234",
"manufacturerURL": config_flow.HUE_MANUFACTURERURL,
}
)
assert result["type"] == "form"
assert result["step_id"] == "link"
@ -255,47 +265,22 @@ async def test_bridge_ssdp_espalexa(hass):
async def test_bridge_ssdp_already_configured(hass):
"""Test if a discovered bridge has already been configured."""
MockConfigEntry(domain="hue", data={"host": "0.0.0.0"}).add_to_hass(hass)
MockConfigEntry(
domain="hue", unique_id="1234", data={"host": "0.0.0.0"}
).add_to_hass(hass)
flow = config_flow.HueFlowHandler()
flow.hass = hass
flow.context = {}
result = await flow.async_step_ssdp(
{
"host": "0.0.0.0",
"serial": "1234",
"manufacturerURL": config_flow.HUE_MANUFACTURERURL,
}
)
assert result["type"] == "abort"
async def test_import_with_existing_config(hass):
"""Test importing a host with an existing config file."""
flow = config_flow.HueFlowHandler()
flow.hass = hass
flow.context = {}
bridge = Mock()
bridge.username = "username-abc"
bridge.config.bridgeid = "bridge-id-1234"
bridge.config.name = "Mock Bridge"
bridge.host = "0.0.0.0"
with patch.object(
config_flow, "_find_username_from_config", return_value="mock-user"
), patch.object(config_flow, "get_bridge", return_value=mock_coro(bridge)):
result = await flow.async_step_import({"host": "0.0.0.0", "path": "bla.conf"})
assert result["type"] == "create_entry"
assert result["title"] == "Mock Bridge"
assert result["data"] == {
"host": "0.0.0.0",
"bridge_id": "bridge-id-1234",
"username": "username-abc",
}
with pytest.raises(data_entry_flow.AbortFlow):
await flow.async_step_ssdp(
{
"host": "0.0.0.0",
"serial": "1234",
"manufacturerURL": config_flow.HUE_MANUFACTURERURL,
}
)
async def test_import_with_no_config(hass):
@ -304,45 +289,12 @@ async def test_import_with_no_config(hass):
flow.hass = hass
flow.context = {}
with patch.object(
config_flow, "get_bridge", side_effect=errors.AuthenticationRequired
):
result = await flow.async_step_import({"host": "0.0.0.0"})
result = await flow.async_step_import({"host": "0.0.0.0"})
assert result["type"] == "form"
assert result["step_id"] == "link"
async def test_import_with_existing_but_invalid_config(hass):
"""Test importing a host with a config file with invalid username."""
flow = config_flow.HueFlowHandler()
flow.hass = hass
flow.context = {}
with patch.object(
config_flow, "_find_username_from_config", return_value="mock-user"
), patch.object(
config_flow, "get_bridge", side_effect=errors.AuthenticationRequired
):
result = await flow.async_step_import({"host": "0.0.0.0", "path": "bla.conf"})
assert result["type"] == "form"
assert result["step_id"] == "link"
async def test_import_cannot_connect(hass):
"""Test importing a host that we cannot conncet to."""
flow = config_flow.HueFlowHandler()
flow.hass = hass
flow.context = {}
with patch.object(config_flow, "get_bridge", side_effect=errors.CannotConnect):
result = await flow.async_step_import({"host": "0.0.0.0"})
assert result["type"] == "abort"
assert result["reason"] == "cannot_connect"
async def test_creating_entry_removes_entries_for_same_host_or_bridge(hass):
"""Test that we clean up entries for same host and bridge.
@ -351,38 +303,45 @@ async def test_creating_entry_removes_entries_for_same_host_or_bridge(hass):
all existing entries that either have same IP or same bridge_id.
"""
orig_entry = MockConfigEntry(
domain="hue",
data={"host": "0.0.0.0", "bridge_id": "id-1234"},
unique_id="id-1234",
domain="hue", data={"host": "0.0.0.0", "username": "aaaa"}, unique_id="id-1234",
)
orig_entry.add_to_hass(hass)
MockConfigEntry(
domain="hue",
data={"host": "1.2.3.4", "bridge_id": "id-5678"},
unique_id="id-5678",
domain="hue", data={"host": "1.2.3.4", "username": "bbbb"}, unique_id="id-5678",
).add_to_hass(hass)
assert len(hass.config_entries.async_entries("hue")) == 2
bridge = Mock()
bridge.username = "username-abc"
bridge.config.bridgeid = "id-1234"
bridge.config.name = "Mock Bridge"
bridge.host = "0.0.0.0"
bridge.id = "id-1234"
with patch.object(
config_flow, "_find_username_from_config", return_value="mock-user"
), patch.object(config_flow, "get_bridge", return_value=mock_coro(bridge)):
with patch(
"aiohue.Bridge", return_value=bridge,
):
result = await hass.config_entries.flow.async_init(
"hue", data={"host": "2.2.2.2"}, context={"source": "import"}
)
assert result["type"] == "form"
assert result["step_id"] == "link"
with patch(
"homeassistant.components.hue.config_flow.authenticate_bridge",
return_value=mock_coro(),
), patch(
"homeassistant.components.hue.async_setup_entry",
side_effect=lambda _, _2: mock_coro(True),
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == "create_entry"
assert result["title"] == "Mock Bridge"
assert result["data"] == {
"host": "0.0.0.0",
"bridge_id": "id-1234",
"username": "username-abc",
}
entries = hass.config_entries.async_entries("hue")
@ -398,17 +357,14 @@ async def test_bridge_homekit(hass):
flow.hass = hass
flow.context = {}
with patch.object(
config_flow, "get_bridge", side_effect=errors.AuthenticationRequired
):
result = await flow.async_step_homekit(
{
"host": "0.0.0.0",
"serial": "1234",
"manufacturerURL": config_flow.HUE_MANUFACTURERURL,
"properties": {"id": "aa:bb:cc:dd:ee:ff"},
}
)
result = await flow.async_step_homekit(
{
"host": "0.0.0.0",
"serial": "1234",
"manufacturerURL": config_flow.HUE_MANUFACTURERURL,
"properties": {"id": "aa:bb:cc:dd:ee:ff"},
}
)
assert result["type"] == "form"
assert result["step_id"] == "link"
@ -416,12 +372,15 @@ async def test_bridge_homekit(hass):
async def test_bridge_homekit_already_configured(hass):
"""Test if a HomeKit discovered bridge has already been configured."""
MockConfigEntry(domain="hue", data={"host": "0.0.0.0"}).add_to_hass(hass)
MockConfigEntry(
domain="hue", unique_id="aabbccddeeff", data={"host": "0.0.0.0"}
).add_to_hass(hass)
flow = config_flow.HueFlowHandler()
flow.hass = hass
flow.context = {}
result = await flow.async_step_homekit({"host": "0.0.0.0"})
assert result["type"] == "abort"
with pytest.raises(data_entry_flow.AbortFlow):
await flow.async_step_homekit(
{"host": "0.0.0.0", "properties": {"id": "aa:bb:cc:dd:ee:ff"}}
)

View file

@ -9,13 +9,10 @@ from tests.common import MockConfigEntry, mock_coro
async def test_setup_with_no_config(hass):
"""Test that we do not discover anything or try to set up a bridge."""
with patch.object(hass, "config_entries") as mock_config_entries, patch.object(
hue, "configured_hosts", return_value=[]
):
assert await async_setup_component(hass, hue.DOMAIN, {}) is True
assert await async_setup_component(hass, hue.DOMAIN, {}) is True
# No flows started
assert len(mock_config_entries.flow.mock_calls) == 0
assert len(hass.config_entries.flow.async_progress()) == 0
# No configs stored
assert hass.data[hue.DOMAIN] == {}
@ -23,9 +20,9 @@ async def test_setup_with_no_config(hass):
async def test_setup_defined_hosts_known_auth(hass):
"""Test we don't initiate a config entry if config bridge is known."""
with patch.object(hass, "config_entries") as mock_config_entries, patch.object(
hue, "configured_hosts", return_value=["0.0.0.0"]
):
MockConfigEntry(domain="hue", data={"host": "0.0.0.0"}).add_to_hass(hass)
with patch.object(hue, "async_setup_entry", return_value=mock_coro(True)):
assert (
await async_setup_component(
hass,
@ -34,7 +31,6 @@ async def test_setup_defined_hosts_known_auth(hass):
hue.DOMAIN: {
hue.CONF_BRIDGES: {
hue.CONF_HOST: "0.0.0.0",
hue.CONF_FILENAME: "bla.conf",
hue.CONF_ALLOW_HUE_GROUPS: False,
hue.CONF_ALLOW_UNREACHABLE: True,
}
@ -45,13 +41,12 @@ async def test_setup_defined_hosts_known_auth(hass):
)
# Flow started for discovered bridge
assert len(mock_config_entries.flow.mock_calls) == 0
assert len(hass.config_entries.flow.async_progress()) == 0
# Config stored for domain.
assert hass.data[hue.DATA_CONFIGS] == {
"0.0.0.0": {
hue.CONF_HOST: "0.0.0.0",
hue.CONF_FILENAME: "bla.conf",
hue.CONF_ALLOW_HUE_GROUPS: False,
hue.CONF_ALLOW_UNREACHABLE: True,
}
@ -60,40 +55,30 @@ async def test_setup_defined_hosts_known_auth(hass):
async def test_setup_defined_hosts_no_known_auth(hass):
"""Test we initiate config entry if config bridge is not known."""
with patch.object(hass, "config_entries") as mock_config_entries, patch.object(
hue, "configured_hosts", return_value=[]
):
mock_config_entries.flow.async_init.return_value = mock_coro()
assert (
await async_setup_component(
hass,
hue.DOMAIN,
{
hue.DOMAIN: {
hue.CONF_BRIDGES: {
hue.CONF_HOST: "0.0.0.0",
hue.CONF_FILENAME: "bla.conf",
hue.CONF_ALLOW_HUE_GROUPS: False,
hue.CONF_ALLOW_UNREACHABLE: True,
}
assert (
await async_setup_component(
hass,
hue.DOMAIN,
{
hue.DOMAIN: {
hue.CONF_BRIDGES: {
hue.CONF_HOST: "0.0.0.0",
hue.CONF_ALLOW_HUE_GROUPS: False,
hue.CONF_ALLOW_UNREACHABLE: True,
}
},
)
is True
}
},
)
is True
)
# Flow started for discovered bridge
assert len(mock_config_entries.flow.mock_calls) == 1
assert mock_config_entries.flow.mock_calls[0][2]["data"] == {
"host": "0.0.0.0",
"path": "bla.conf",
}
assert len(hass.config_entries.flow.async_progress()) == 1
# Config stored for domain.
assert hass.data[hue.DATA_CONFIGS] == {
"0.0.0.0": {
hue.CONF_HOST: "0.0.0.0",
hue.CONF_FILENAME: "bla.conf",
hue.CONF_ALLOW_HUE_GROUPS: False,
hue.CONF_ALLOW_UNREACHABLE: True,
}
@ -126,7 +111,6 @@ async def test_config_passed_to_config_entry(hass):
hue.DOMAIN: {
hue.CONF_BRIDGES: {
hue.CONF_HOST: "0.0.0.0",
hue.CONF_FILENAME: "bla.conf",
hue.CONF_ALLOW_HUE_GROUPS: False,
hue.CONF_ALLOW_UNREACHABLE: True,
}
@ -166,7 +150,7 @@ async def test_unload_entry(hass):
return_value=mock_coro(Mock()),
):
mock_bridge.return_value.async_setup.return_value = mock_coro(True)
mock_bridge.return_value.api.config = Mock()
mock_bridge.return_value.api.config = Mock(bridgeid="aabbccddeeff")
assert await async_setup_component(hass, hue.DOMAIN, {}) is True
assert len(mock_bridge.return_value.mock_calls) == 1

View file

@ -434,8 +434,8 @@ async def test_saving_and_loading(hass):
VERSION = 5
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
@asyncio.coroutine
def async_step_user(self, user_input=None):
async def async_step_user(self, user_input=None):
await self.async_set_unique_id("unique")
return self.async_create_entry(title="Test Title", data={"token": "abcd"})
with patch.dict(config_entries.HANDLERS, {"test": TestFlow}):
@ -477,6 +477,7 @@ async def test_saving_and_loading(hass):
assert orig.data == loaded.data
assert orig.source == loaded.source
assert orig.connection_class == loaded.connection_class
assert orig.unique_id == loaded.unique_id
async def test_forward_entry_sets_up_component(hass):
@ -1108,3 +1109,40 @@ async def test_unique_id_in_progress(hass, manager):
assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result2["reason"] == "already_in_progress"
async def test_finish_flow_aborts_progress(hass, manager):
"""Test that when finishing a flow, we abort other flows in progress with unique ID."""
mock_integration(
hass,
MockModule("comp", async_setup_entry=MagicMock(return_value=mock_coro(True))),
)
mock_entity_platform(hass, "config_flow.comp", None)
class TestFlow(config_entries.ConfigFlow):
VERSION = 1
async def async_step_user(self, user_input=None):
await self.async_set_unique_id("mock-unique-id", raise_on_progress=False)
if user_input is None:
return self.async_show_form(step_id="discovery")
return self.async_create_entry(title="yo", data={})
with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}):
# Create one to be in progress
result = await manager.flow.async_init(
"comp", context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
# Will finish and cancel other one.
result2 = await manager.flow.async_init(
"comp", context={"source": config_entries.SOURCE_USER}, data={}
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert len(hass.config_entries.flow.async_progress()) == 0