From 184f2be83e3c3f3824efa566fa29a779a57832e3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Mar 2018 20:15:40 -0700 Subject: [PATCH] Convert Hue to always use config entries (#13034) --- homeassistant/components/discovery.py | 26 +- homeassistant/components/hue/__init__.py | 371 +++----------------- homeassistant/components/hue/bridge.py | 143 ++++++++ homeassistant/components/hue/config_flow.py | 235 +++++++++++++ homeassistant/components/hue/const.py | 6 + homeassistant/components/hue/errors.py | 14 + homeassistant/components/hue/strings.json | 5 +- homeassistant/config_entries.py | 2 +- tests/components/hue/conftest.py | 17 - tests/components/hue/test_bridge.py | 136 +++---- tests/components/hue/test_config_flow.py | 213 +++++++++-- tests/components/hue/test_init.py | 169 +++++++++ tests/components/hue/test_setup.py | 70 ---- tests/components/test_discovery.py | 34 +- 14 files changed, 914 insertions(+), 527 deletions(-) create mode 100644 homeassistant/components/hue/bridge.py create mode 100644 homeassistant/components/hue/config_flow.py create mode 100644 homeassistant/components/hue/const.py create mode 100644 homeassistant/components/hue/errors.py delete mode 100644 tests/components/hue/conftest.py create mode 100644 tests/components/hue/test_init.py delete mode 100644 tests/components/hue/test_setup.py diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index eb53782d698..b2aa5b890a8 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -13,6 +13,7 @@ import os import voluptuous as vol +from homeassistant import config_entries from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_START import homeassistant.helpers.config_validation as cv @@ -40,6 +41,10 @@ SERVICE_DECONZ = 'deconz' SERVICE_DAIKIN = 'daikin' SERVICE_SAMSUNG_PRINTER = 'samsung_printer' +CONFIG_ENTRY_HANDLERS = { + SERVICE_HUE: 'hue', +} + SERVICE_HANDLERS = { SERVICE_HASS_IOS_APP: ('ios', None), SERVICE_NETGEAR: ('device_tracker', None), @@ -51,7 +56,6 @@ SERVICE_HANDLERS = { SERVICE_WINK: ('wink', None), SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), SERVICE_TELLDUSLIVE: ('tellduslive', None), - SERVICE_HUE: ('hue', None), SERVICE_DECONZ: ('deconz', None), SERVICE_DAIKIN: ('daikin', None), SERVICE_SAMSUNG_PRINTER: ('sensor', 'syncthru'), @@ -105,6 +109,20 @@ async def async_setup(hass, config): logger.info("Ignoring service: %s %s", service, info) return + discovery_hash = json.dumps([service, info], sort_keys=True) + if discovery_hash in already_discovered: + return + + already_discovered.add(discovery_hash) + + if service in CONFIG_ENTRY_HANDLERS: + await hass.config_entries.flow.async_init( + CONFIG_ENTRY_HANDLERS[service], + source=config_entries.SOURCE_DISCOVERY, + data=info + ) + return + comp_plat = SERVICE_HANDLERS.get(service) # We do not know how to handle this service. @@ -112,12 +130,6 @@ async def async_setup(hass, config): logger.info("Unknown service discovered: %s %s", service, info) return - discovery_hash = json.dumps([service, info], sort_keys=True) - if discovery_hash in already_discovered: - return - - already_discovered.add(discovery_hash) - logger.info("Found new service: %s %s", service, info) component, platform = comp_plat diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index b70021e0304..557a47f3e05 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -4,31 +4,23 @@ This component provides basic support for the Philips Hue system. For more details about this component, please refer to the documentation at https://home-assistant.io/components/hue/ """ -import asyncio -import json import ipaddress import logging -import os -import async_timeout import voluptuous as vol -from homeassistant.core import callback -from homeassistant.components.discovery import SERVICE_HUE from homeassistant.const import CONF_FILENAME, CONF_HOST -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers import discovery, aiohttp_client -from homeassistant import config_entries -from homeassistant.util.json import save_json +from homeassistant.helpers import aiohttp_client, config_validation as cv + +from .const import DOMAIN, API_NUPNP +from .bridge import HueBridge +# Loading the config flow file will register the flow +from .config_flow import configured_hosts REQUIREMENTS = ['aiohue==1.3.0'] _LOGGER = logging.getLogger(__name__) -DOMAIN = "hue" -SERVICE_HUE_SCENE = "hue_activate_scene" -API_NUPNP = 'https://www.meethue.com/api/nupnp' - CONF_BRIDGES = "bridges" CONF_ALLOW_UNREACHABLE = 'allow_unreachable' @@ -42,6 +34,7 @@ DEFAULT_ALLOW_HUE_GROUPS = True 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, @@ -56,19 +49,6 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) -ATTR_GROUP_NAME = "group_name" -ATTR_SCENE_NAME = "scene_name" -SCENE_SCHEMA = vol.Schema({ - vol.Required(ATTR_GROUP_NAME): cv.string, - vol.Required(ATTR_SCENE_NAME): cv.string, -}) - -CONFIG_INSTRUCTIONS = """ -Press the button on the bridge to register Philips Hue with Home Assistant. - -![Location of button on bridge](/static/images/config_philips_hue.jpg) -""" - async def async_setup(hass, config): """Set up the Hue platform.""" @@ -76,20 +56,8 @@ async def async_setup(hass, config): if conf is None: conf = {} - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - - async def async_bridge_discovered(service, discovery_info): - """Dispatcher for Hue discovery events.""" - # Ignore emulated hue - if "HASS Bridge" in discovery_info.get('name', ''): - return - - await async_setup_bridge( - hass, discovery_info['host'], - 'phue-{}.conf'.format(discovery_info['serial'])) - - discovery.async_listen(hass, SERVICE_HUE, async_bridge_discovered) + hass.data[DOMAIN] = {} + configured = configured_hosts(hass) # User has configured bridges if CONF_BRIDGES in conf: @@ -103,12 +71,19 @@ async def async_setup(hass, config): async with websession.get(API_NUPNP) as req: hosts = await req.json() - # Run through config schema to populate defaults - bridges = [BRIDGE_CONFIG_SCHEMA({ - CONF_HOST: entry['internalipaddress'], - CONF_FILENAME: '.hue_{}.conf'.format(entry['id']), - }) for entry in hosts] + bridges = [] + for entry in hosts: + # Filter out already configured hosts + if entry['internalipaddress'] in configured: + continue + # Run through config schema to populate defaults + bridges.append(BRIDGE_CONFIG_SCHEMA({ + CONF_HOST: entry['internalipaddress'], + # Careful with using entry['id'] for other reasons. The + # value is in lowercase but is returned uppercase from hub. + CONF_FILENAME: '.hue_{}.conf'.format(entry['id']), + })) else: # Component not specified in config, we're loaded via discovery bridges = [] @@ -116,277 +91,43 @@ async def async_setup(hass, config): if not bridges: return True - await asyncio.wait([ - async_setup_bridge( - hass, bridge[CONF_HOST], bridge[CONF_FILENAME], - bridge[CONF_ALLOW_UNREACHABLE], bridge[CONF_ALLOW_HUE_GROUPS] - ) for bridge in bridges - ]) + for bridge_conf in bridges: + host = bridge_conf[CONF_HOST] + + # Store config in hass.data so the config entry can find it + hass.data[DOMAIN][host] = bridge_conf + + # If configured, the bridge will be set up during config entry phase + if host in configured: + 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! + hass.async_add_job(hass.config_entries.flow.async_init( + DOMAIN, source='import', data={ + 'host': bridge_conf[CONF_HOST], + 'path': bridge_conf[CONF_FILENAME], + } + )) return True -async def async_setup_bridge( - hass, host, filename=None, - allow_unreachable=DEFAULT_ALLOW_UNREACHABLE, - allow_hue_groups=DEFAULT_ALLOW_HUE_GROUPS, - username=None): - """Set up a given Hue bridge.""" - assert filename or username, 'Need to pass at least a username or filename' - - # Only register a device once - if host in hass.data[DOMAIN]: - return - - if username is None: - username = await hass.async_add_job( - _find_username_from_config, hass, filename) - - bridge = HueBridge(host, hass, filename, username, allow_unreachable, - allow_hue_groups) - await bridge.async_setup() - - -def _find_username_from_config(hass, filename): - """Load username from config.""" - path = hass.config.path(filename) - - if not os.path.isfile(path): - return None - - with open(path) as inp: - return list(json.load(inp).values())[0]['username'] - - -class HueBridge(object): - """Manages a single Hue bridge.""" - - def __init__(self, host, hass, filename, username, - allow_unreachable=False, allow_groups=True): - """Initialize the system.""" - self.host = host - self.hass = hass - self.filename = filename - self.username = username - self.allow_unreachable = allow_unreachable - self.allow_groups = allow_groups - self.available = True - self.config_request_id = None - self.api = None - - async def async_setup(self): - """Set up a phue bridge based on host parameter.""" - import aiohue - - api = aiohue.Bridge( - self.host, - username=self.username, - websession=aiohttp_client.async_get_clientsession(self.hass) - ) - - try: - with async_timeout.timeout(5): - # Initialize bridge and validate our username - if not self.username: - await api.create_user('home-assistant') - await api.initialize() - except (aiohue.LinkButtonNotPressed, aiohue.Unauthorized): - _LOGGER.warning("Connected to Hue at %s but not registered.", - self.host) - self.async_request_configuration() - return - except (asyncio.TimeoutError, aiohue.RequestError): - _LOGGER.error("Error connecting to the Hue bridge at %s", - self.host) - return - except aiohue.AiohueException: - _LOGGER.exception('Unknown Hue linking error occurred') - self.async_request_configuration() - return - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unknown error connecting with Hue bridge at %s", - self.host) - return - - self.hass.data[DOMAIN][self.host] = self - - # If we came here and configuring this host, mark as done - if self.config_request_id: - request_id = self.config_request_id - self.config_request_id = None - self.hass.components.configurator.async_request_done(request_id) - - self.username = api.username - - # Save config file - await self.hass.async_add_job( - save_json, self.hass.config.path(self.filename), - {self.host: {'username': api.username}}) - - self.api = api - - self.hass.async_add_job(discovery.async_load_platform( - self.hass, 'light', DOMAIN, - {'host': self.host})) - - self.hass.services.async_register( - DOMAIN, SERVICE_HUE_SCENE, self.hue_activate_scene, - schema=SCENE_SCHEMA) - - @callback - def async_request_configuration(self): - """Request configuration steps from the user.""" - configurator = self.hass.components.configurator - - # We got an error if this method is called while we are configuring - if self.config_request_id: - configurator.async_notify_errors( - self.config_request_id, - "Failed to register, please try again.") - return - - async def config_callback(data): - """Callback for configurator data.""" - await self.async_setup() - - self.config_request_id = configurator.async_request_config( - "Philips Hue", config_callback, - description=CONFIG_INSTRUCTIONS, - entity_picture="/static/images/logo_philips_hue.png", - submit_caption="I have pressed the button" - ) - - async def hue_activate_scene(self, call, updated=False): - """Service to call directly into bridge to set scenes.""" - group_name = call.data[ATTR_GROUP_NAME] - scene_name = call.data[ATTR_SCENE_NAME] - - group = next( - (group for group in self.api.groups.values() - if group.name == group_name), None) - - scene_id = next( - (scene.id for scene in self.api.scenes.values() - if scene.name == scene_name), None) - - # If we can't find it, fetch latest info. - if not updated and (group is None or scene_id is None): - await self.api.groups.update() - await self.api.scenes.update() - await self.hue_activate_scene(call, updated=True) - return - - if group is None: - _LOGGER.warning('Unable to find group %s', group_name) - return - - if scene_id is None: - _LOGGER.warning('Unable to find scene %s', scene_name) - return - - await group.set_action(scene=scene_id) - - -@config_entries.HANDLERS.register(DOMAIN) -class HueFlowHandler(config_entries.ConfigFlowHandler): - """Handle a Hue config flow.""" - - VERSION = 1 - - def __init__(self): - """Initialize the Hue flow.""" - self.host = None - - @property - def _websession(self): - """Return a websession. - - Cannot assign in init because hass variable is not set yet. - """ - return aiohttp_client.async_get_clientsession(self.hass) - - async def async_step_init(self, user_input=None): - """Handle a flow start.""" - from aiohue.discovery import discover_nupnp - - if user_input is not None: - self.host = user_input['host'] - return await self.async_step_link() - - try: - with async_timeout.timeout(5): - bridges = await discover_nupnp(websession=self._websession) - except asyncio.TimeoutError: - return self.async_abort( - reason='discover_timeout' - ) - - if not bridges: - return self.async_abort( - reason='no_bridges' - ) - - # Find already configured hosts - configured_hosts = set( - entry.data['host'] for entry - in self.hass.config_entries.async_entries(DOMAIN)) - - hosts = [bridge.host for bridge in bridges - if bridge.host not in configured_hosts] - - if not hosts: - return self.async_abort( - reason='all_configured' - ) - - elif len(hosts) == 1: - self.host = hosts[0] - return await self.async_step_link() - - return self.async_show_form( - step_id='init', - data_schema=vol.Schema({ - vol.Required('host'): vol.In(hosts) - }) - ) - - async def async_step_link(self, user_input=None): - """Attempt to link with the Hue bridge.""" - import aiohue - errors = {} - - if user_input is not None: - bridge = aiohue.Bridge(self.host, websession=self._websession) - try: - with async_timeout.timeout(5): - # Create auth token - await bridge.create_user('home-assistant') - # Fetches name and id - await bridge.initialize() - except (asyncio.TimeoutError, aiohue.RequestError, - aiohue.LinkButtonNotPressed): - errors['base'] = 'register_failed' - except aiohue.AiohueException: - errors['base'] = 'linking' - _LOGGER.exception('Unknown Hue linking error occurred') - else: - return self.async_create_entry( - title=bridge.config.name, - data={ - 'host': bridge.host, - 'bridge_id': bridge.config.bridgeid, - 'username': bridge.username, - } - ) - - return self.async_show_form( - step_id='link', - errors=errors, - ) - - async def async_setup_entry(hass, entry): - """Set up a bridge for a config entry.""" - await async_setup_bridge(hass, entry.data['host'], - username=entry.data['username']) - return True + """Set up a bridge from a config entry.""" + host = entry.data['host'] + config = hass.data[DOMAIN].get(host) + + if config is None: + allow_unreachable = DEFAULT_ALLOW_UNREACHABLE + allow_groups = DEFAULT_ALLOW_HUE_GROUPS + else: + allow_unreachable = config[CONF_ALLOW_UNREACHABLE] + allow_groups = config[CONF_ALLOW_HUE_GROUPS] + + bridge = HueBridge(hass, entry, allow_unreachable, allow_groups) + hass.data[DOMAIN][host] = bridge + return await bridge.async_setup() diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py new file mode 100644 index 00000000000..790831a4d6c --- /dev/null +++ b/homeassistant/components/hue/bridge.py @@ -0,0 +1,143 @@ +"""Code to handle a Hue bridge.""" +import asyncio + +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.helpers import aiohttp_client, config_validation as cv + +from .const import DOMAIN, LOGGER +from .errors import AuthenticationRequired, CannotConnect + +SERVICE_HUE_SCENE = "hue_activate_scene" +ATTR_GROUP_NAME = "group_name" +ATTR_SCENE_NAME = "scene_name" +SCENE_SCHEMA = vol.Schema({ + vol.Required(ATTR_GROUP_NAME): cv.string, + vol.Required(ATTR_SCENE_NAME): cv.string, +}) + + +class HueBridge(object): + """Manages a single Hue bridge.""" + + def __init__(self, hass, config_entry, allow_unreachable, allow_groups): + """Initialize the system.""" + self.config_entry = config_entry + self.hass = hass + self.allow_unreachable = allow_unreachable + self.allow_groups = allow_groups + self.available = True + self.api = None + + async def async_setup(self, tries=0): + """Set up a phue bridge based on host parameter.""" + host = self.config_entry.data['host'] + + try: + self.api = await get_bridge( + self.hass, host, + self.config_entry.data['username'] + ) + 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 + # linking procedure. When linking succeeds, it will remove the + # old config entry. + self.hass.async_add_job(self.hass.config_entries.flow.async_init( + DOMAIN, source='import', data={ + 'host': host, + } + )) + return False + + except CannotConnect: + retry_delay = 2 ** (tries + 1) + LOGGER.error("Error connecting to the Hue bridge at %s. Retrying " + "in %d seconds", host, retry_delay) + + async def retry_setup(_now): + """Retry setup.""" + if await self.async_setup(tries + 1): + # This feels hacky, we should find a better way to do this + self.config_entry.state = config_entries.ENTRY_STATE_LOADED + + # Unhandled edge case: cancel this if we discover bridge on new IP + self.hass.helpers.event.async_call_later(retry_delay, retry_setup) + + return False + + except Exception: # pylint: disable=broad-except + LOGGER.exception('Unknown error connecting with Hue bridge at %s', + host) + return False + + self.hass.async_add_job( + self.hass.helpers.discovery.async_load_platform( + 'light', DOMAIN, {'host': host})) + + self.hass.services.async_register( + DOMAIN, SERVICE_HUE_SCENE, self.hue_activate_scene, + schema=SCENE_SCHEMA) + + return True + + async def hue_activate_scene(self, call, updated=False): + """Service to call directly into bridge to set scenes.""" + group_name = call.data[ATTR_GROUP_NAME] + scene_name = call.data[ATTR_SCENE_NAME] + + group = next( + (group for group in self.api.groups.values() + if group.name == group_name), None) + + scene_id = next( + (scene.id for scene in self.api.scenes.values() + if scene.name == scene_name), None) + + # If we can't find it, fetch latest info. + if not updated and (group is None or scene_id is None): + await self.api.groups.update() + await self.api.scenes.update() + await self.hue_activate_scene(call, updated=True) + return + + if group is None: + LOGGER.warning('Unable to find group %s', group_name) + return + + if scene_id is None: + LOGGER.warning('Unable to find scene %s', scene_name) + return + + await group.set_action(scene=scene_id) + + +async def get_bridge(hass, host, username=None): + """Create a bridge object and verify authentication.""" + import aiohue + + bridge = aiohue.Bridge( + host, username=username, + websession=aiohttp_client.async_get_clientsession(hass) + ) + + try: + with async_timeout.timeout(5): + # Create username if we don't have one + if not username: + await bridge.create_user('home-assistant') + # Initialize bridge (and validate our username) + await bridge.initialize() + + return bridge + except (aiohue.LinkButtonNotPressed, aiohue.Unauthorized): + LOGGER.warning("Connected to Hue at %s but not registered.", host) + raise AuthenticationRequired + except (asyncio.TimeoutError, aiohue.RequestError): + LOGGER.error("Error connecting to the Hue bridge at %s", host) + raise CannotConnect + except aiohue.AiohueException: + LOGGER.exception('Unknown Hue linking error occurred') + raise AuthenticationRequired diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py new file mode 100644 index 00000000000..11e399c984d --- /dev/null +++ b/homeassistant/components/hue/config_flow.py @@ -0,0 +1,235 @@ +"""Config flow to configure Philips Hue.""" +import asyncio +import json +import os + +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client + +from .bridge import get_bridge +from .const import DOMAIN, LOGGER +from .errors import AuthenticationRequired, CannotConnect + + +@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 + + +@config_entries.HANDLERS.register(DOMAIN) +class HueFlowHandler(config_entries.ConfigFlowHandler): + """Handle a Hue config flow.""" + + VERSION = 1 + + def __init__(self): + """Initialize the Hue flow.""" + self.host = None + + async def async_step_init(self, user_input=None): + """Handle a flow start.""" + from aiohue.discovery import discover_nupnp + + if user_input is not None: + self.host = user_input['host'] + return await self.async_step_link() + + websession = aiohttp_client.async_get_clientsession(self.hass) + + try: + with async_timeout.timeout(5): + bridges = await discover_nupnp(websession=websession) + except asyncio.TimeoutError: + return self.async_abort( + reason='discover_timeout' + ) + + if not bridges: + return self.async_abort( + reason='no_bridges' + ) + + # Find already configured hosts + configured = configured_hosts(self.hass) + + hosts = [bridge.host for bridge in bridges + if bridge.host not in configured] + + if not hosts: + return self.async_abort( + reason='all_configured' + ) + + elif len(hosts) == 1: + self.host = hosts[0] + return await self.async_step_link() + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema({ + vol.Required('host'): vol.In(hosts) + }) + ) + + async def async_step_link(self, user_input=None): + """Attempt to link with the Hue bridge. + + Given a configured host, will ask the user to press the link button + to connect to the bridge. + """ + 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 + ) + + return await self._entry_from_bridge(bridge) + except AuthenticationRequired: + errors['base'] = 'register_failed' + + except CannotConnect: + LOGGER.error("Error connecting to the Hue bridge at %s", self.host) + errors['base'] = 'linking' + + except Exception: # pylint: disable=broad-except + LOGGER.exception( + 'Unknown error connecting with Hue bridge at %s', + self.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_discovery(self, discovery_info): + """Handle a discovered Hue bridge. + + This flow is triggered by the discovery component. It will check if the + host is already configured and delegate to the import step if not. + """ + # Filter out emulated Hue + if "HASS Bridge" in discovery_info.get('name', ''): + return self.async_abort(reason='already_configured') + + host = discovery_info.get('host') + + if host in configured_hosts(self.hass): + return self.async_abort(reason='already_configured') + + # This value is based off host/description.xml and is, weirdly, missing + # 4 characters in the middle of the serial compared to results returned + # from the NUPNP API or when querying the bridge API for bridgeid. + # (on first gen Hue hub) + serial = discovery_info.get('serial') + + return await self.async_step_import({ + 'host': host, + # This format is the legacy format that Hue used for discovery + 'path': 'phue-{}.conf'.format(serial) + }) + + 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 = import_info['host'] + path = import_info.get('path') + + 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 + + same_hub_entries = [entry.entry_id for entry + in self.hass.config_entries.async_entries(DOMAIN) + if entry.data['bridge_id'] == bridge_id or + entry.data['host'] == host] + + if same_hub_entries: + await asyncio.wait([self.hass.config_entries.async_remove(entry_id) + for entry_id in same_hub_entries]) + + return self.async_create_entry( + title=bridge.config.name, + data={ + 'host': host, + 'bridge_id': bridge_id, + 'username': bridge.username, + } + ) diff --git a/homeassistant/components/hue/const.py b/homeassistant/components/hue/const.py new file mode 100644 index 00000000000..2eb30d47804 --- /dev/null +++ b/homeassistant/components/hue/const.py @@ -0,0 +1,6 @@ +"""Constants for the Hue component.""" +import logging + +LOGGER = logging.getLogger('homeassistant.components.hue') +DOMAIN = "hue" +API_NUPNP = 'https://www.meethue.com/api/nupnp' diff --git a/homeassistant/components/hue/errors.py b/homeassistant/components/hue/errors.py new file mode 100644 index 00000000000..dd217c3bc26 --- /dev/null +++ b/homeassistant/components/hue/errors.py @@ -0,0 +1,14 @@ +"""Errors for the Hue component.""" +from homeassistant.exceptions import HomeAssistantError + + +class HueException(HomeAssistantError): + """Base class for Hue exceptions.""" + + +class CannotConnect(HueException): + """Unable to connect to the bridge.""" + + +class AuthenticationRequired(HueException): + """Unknown error occurred.""" diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 59b1ecd3cd1..fc9e91c93d7 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -20,7 +20,10 @@ "abort": { "discover_timeout": "Unable to discover Hue bridges", "no_bridges": "No Philips Hue bridges discovered", - "all_configured": "All Philips Hue bridges are already configured" + "all_configured": "All Philips Hue bridges are already configured", + "unknown": "Unknown error occurred", + "cannot_connect": "Unable to connect to the bridge", + "already_configured": "Bridge is already configured" } } } diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index eb05e800683..b02026ac6dd 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -384,7 +384,7 @@ class FlowManager: handler = HANDLERS.get(domain) if handler is None: - raise self.hass.helpers.UnknownHandler + raise UnknownHandler # Make sure requirements and dependencies of component are resolved await async_process_deps_reqs( diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py deleted file mode 100644 index 7ccc202b31b..00000000000 --- a/tests/components/hue/conftest.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Fixtures for Hue tests.""" -from unittest.mock import patch - -import pytest - -from tests.common import mock_coro_func - - -@pytest.fixture -def mock_bridge(): - """Mock the HueBridge from initializing.""" - with patch('homeassistant.components.hue._find_username_from_config', - return_value=None), \ - patch('homeassistant.components.hue.HueBridge') as mock_bridge: - mock_bridge().async_setup = mock_coro_func() - mock_bridge.reset_mock() - yield mock_bridge diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index 39351699df5..0845aa2f077 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -1,99 +1,57 @@ """Test Hue bridge.""" -import asyncio from unittest.mock import Mock, patch -import aiohue -import pytest - -from homeassistant.components import hue +from homeassistant.components.hue import bridge, errors from tests.common import mock_coro -class MockBridge(hue.HueBridge): - """Class that sets default for constructor.""" +async def test_bridge_setup(): + """Test a successful setup.""" + hass = Mock() + entry = Mock() + api = Mock() + entry.data = {'host': '1.2.3.4', 'username': 'mock-username'} + hue_bridge = bridge.HueBridge(hass, entry, False, False) - def __init__(self, hass, host='1.2.3.4', filename='mock-bridge.conf', - username=None, **kwargs): - """Initialize a mock bridge.""" - super().__init__(host, hass, filename, username, **kwargs) + with patch.object(bridge, 'get_bridge', return_value=mock_coro(api)): + assert await hue_bridge.async_setup() is True - -@pytest.fixture -def mock_request(): - """Mock configurator.async_request_config.""" - with patch('homeassistant.components.configurator.' - 'async_request_config') as mock_request: - yield mock_request - - -async def test_setup_request_config_button_not_pressed(hass, mock_request): - """Test we request config if link button has not been pressed.""" - with patch('aiohue.Bridge.create_user', - side_effect=aiohue.LinkButtonNotPressed): - await MockBridge(hass).async_setup() - - assert len(mock_request.mock_calls) == 1 - - -async def test_setup_request_config_invalid_username(hass, mock_request): - """Test we request config if username is no longer whitelisted.""" - with patch('aiohue.Bridge.create_user', - side_effect=aiohue.Unauthorized): - await MockBridge(hass).async_setup() - - assert len(mock_request.mock_calls) == 1 - - -async def test_setup_timeout(hass, mock_request): - """Test we give up when there is a timeout.""" - with patch('aiohue.Bridge.create_user', - side_effect=asyncio.TimeoutError): - await MockBridge(hass).async_setup() - - assert len(mock_request.mock_calls) == 0 - - -async def test_only_create_no_username(hass): - """.""" - with patch('aiohue.Bridge.create_user') as mock_create, \ - patch('aiohue.Bridge.initialize') as mock_init: - await MockBridge(hass, username='bla').async_setup() - - assert len(mock_create.mock_calls) == 0 - assert len(mock_init.mock_calls) == 1 - - -async def test_configurator_callback(hass, mock_request): - """.""" - hass.data[hue.DOMAIN] = {} - with patch('aiohue.Bridge.create_user', - side_effect=aiohue.LinkButtonNotPressed): - await MockBridge(hass).async_setup() - - assert len(mock_request.mock_calls) == 1 - - callback = mock_request.mock_calls[0][1][2] - - mock_init = Mock(return_value=mock_coro()) - mock_create = Mock(return_value=mock_coro()) - - with patch('aiohue.Bridge') as mock_bridge, \ - patch('homeassistant.helpers.discovery.async_load_platform', - return_value=mock_coro()) as mock_load_platform, \ - patch('homeassistant.components.hue.save_json') as mock_save: - inst = mock_bridge() - inst.username = 'mock-user' - inst.create_user = mock_create - inst.initialize = mock_init - await callback(None) - - assert len(mock_create.mock_calls) == 1 - assert len(mock_init.mock_calls) == 1 - assert len(mock_save.mock_calls) == 1 - assert mock_save.mock_calls[0][1][1] == { - '1.2.3.4': { - 'username': 'mock-user' - } + assert hue_bridge.api is api + assert len(hass.helpers.discovery.async_load_platform.mock_calls) == 1 + assert hass.helpers.discovery.async_load_platform.mock_calls[0][1][2] == { + 'host': '1.2.3.4' } - assert len(mock_load_platform.mock_calls) == 1 + + +async def test_bridge_setup_invalid_username(): + """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_add_job.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): + assert await hue_bridge.async_setup() is False + + assert len(hass.helpers.event.async_call_later.mock_calls) == 1 + # Assert we are going to wait 2 seconds + assert hass.helpers.event.async_call_later.mock_calls[0][1][0] == 2 diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 959e3c6241b..fe3bffe5357 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -1,28 +1,29 @@ """Tests for Philips Hue config flow.""" import asyncio -from unittest.mock import patch +from unittest.mock import Mock, patch import aiohue import pytest import voluptuous as vol -from homeassistant.components import hue +from homeassistant.components.hue import config_flow, const, errors from tests.common import MockConfigEntry, mock_coro async def test_flow_works(hass, aioclient_mock): """Test config flow .""" - aioclient_mock.get(hue.API_NUPNP, json=[ + aioclient_mock.get(const.API_NUPNP, json=[ {'internalipaddress': '1.2.3.4', 'id': 'bla'} ]) - flow = hue.HueFlowHandler() + flow = config_flow.HueFlowHandler() flow.hass = hass await flow.async_step_init() with patch('aiohue.Bridge') as mock_bridge: - def mock_constructor(host, websession): + def mock_constructor(host, websession, username=None): + """Fake the bridge constructor.""" mock_bridge.host = host return mock_bridge @@ -50,8 +51,8 @@ async def test_flow_works(hass, aioclient_mock): async def test_flow_no_discovered_bridges(hass, aioclient_mock): """Test config flow discovers no bridges.""" - aioclient_mock.get(hue.API_NUPNP, json=[]) - flow = hue.HueFlowHandler() + aioclient_mock.get(const.API_NUPNP, json=[]) + flow = config_flow.HueFlowHandler() flow.hass = hass result = await flow.async_step_init() @@ -60,13 +61,13 @@ async def test_flow_no_discovered_bridges(hass, aioclient_mock): async def test_flow_all_discovered_bridges_exist(hass, aioclient_mock): """Test config flow discovers only already configured bridges.""" - aioclient_mock.get(hue.API_NUPNP, json=[ + 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) - flow = hue.HueFlowHandler() + flow = config_flow.HueFlowHandler() flow.hass = hass result = await flow.async_step_init() @@ -75,10 +76,10 @@ async def test_flow_all_discovered_bridges_exist(hass, aioclient_mock): async def test_flow_one_bridge_discovered(hass, aioclient_mock): """Test config flow discovers one bridge.""" - aioclient_mock.get(hue.API_NUPNP, json=[ + aioclient_mock.get(const.API_NUPNP, json=[ {'internalipaddress': '1.2.3.4', 'id': 'bla'} ]) - flow = hue.HueFlowHandler() + flow = config_flow.HueFlowHandler() flow.hass = hass result = await flow.async_step_init() @@ -88,11 +89,11 @@ async def test_flow_one_bridge_discovered(hass, aioclient_mock): async def test_flow_two_bridges_discovered(hass, aioclient_mock): """Test config flow discovers two bridges.""" - aioclient_mock.get(hue.API_NUPNP, json=[ + aioclient_mock.get(const.API_NUPNP, json=[ {'internalipaddress': '1.2.3.4', 'id': 'bla'}, {'internalipaddress': '5.6.7.8', 'id': 'beer'} ]) - flow = hue.HueFlowHandler() + flow = config_flow.HueFlowHandler() flow.hass = hass result = await flow.async_step_init() @@ -108,14 +109,14 @@ async def test_flow_two_bridges_discovered(hass, aioclient_mock): async def test_flow_two_bridges_discovered_one_new(hass, aioclient_mock): """Test config flow discovers two bridges.""" - aioclient_mock.get(hue.API_NUPNP, json=[ + aioclient_mock.get(const.API_NUPNP, json=[ {'internalipaddress': '1.2.3.4', 'id': 'bla'}, {'internalipaddress': '5.6.7.8', 'id': 'beer'} ]) MockConfigEntry(domain='hue', data={ 'host': '1.2.3.4' }).add_to_hass(hass) - flow = hue.HueFlowHandler() + flow = config_flow.HueFlowHandler() flow.hass = hass result = await flow.async_step_init() @@ -126,7 +127,7 @@ async def test_flow_two_bridges_discovered_one_new(hass, aioclient_mock): async def test_flow_timeout_discovery(hass): """Test config flow .""" - flow = hue.HueFlowHandler() + flow = config_flow.HueFlowHandler() flow.hass = hass with patch('aiohue.discovery.discover_nupnp', @@ -138,7 +139,7 @@ async def test_flow_timeout_discovery(hass): async def test_flow_link_timeout(hass): """Test config flow .""" - flow = hue.HueFlowHandler() + flow = config_flow.HueFlowHandler() flow.hass = hass with patch('aiohue.Bridge.create_user', @@ -148,13 +149,13 @@ async def test_flow_link_timeout(hass): assert result['type'] == 'form' assert result['step_id'] == 'link' assert result['errors'] == { - 'base': 'register_failed' + 'base': 'linking' } async def test_flow_link_button_not_pressed(hass): """Test config flow .""" - flow = hue.HueFlowHandler() + flow = config_flow.HueFlowHandler() flow.hass = hass with patch('aiohue.Bridge.create_user', @@ -170,7 +171,7 @@ async def test_flow_link_button_not_pressed(hass): async def test_flow_link_unknown_host(hass): """Test config flow .""" - flow = hue.HueFlowHandler() + flow = config_flow.HueFlowHandler() flow.hass = hass with patch('aiohue.Bridge.create_user', @@ -180,5 +181,175 @@ async def test_flow_link_unknown_host(hass): assert result['type'] == 'form' assert result['step_id'] == 'link' assert result['errors'] == { - 'base': 'register_failed' + 'base': 'linking' } + + +async def test_bridge_discovery(hass): + """Test a bridge being discovered.""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + + with patch.object(config_flow, 'get_bridge', + side_effect=errors.AuthenticationRequired): + result = await flow.async_step_discovery({ + 'host': '0.0.0.0', + 'serial': '1234' + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_bridge_discovery_emulated_hue(hass): + """Test if discovery info is from an emulated hue instance.""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery({ + 'name': 'HASS Bridge', + 'host': '0.0.0.0', + 'serial': '1234' + }) + + assert result['type'] == 'abort' + + +async def test_bridge_discovery_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) + + flow = config_flow.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery({ + 'host': '0.0.0.0', + 'serial': '1234' + }) + + 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 + + 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' + } + + +async def test_import_with_no_config(hass): + """Test importing a host without an existing config file.""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + + with patch.object(config_flow, 'get_bridge', + side_effect=errors.AuthenticationRequired): + 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 + + 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 + + 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. + + An IP can only hold a single bridge and a single bridge can only be + accessible via a single IP. So when we create a new entry, we'll remove + all existing entries that either have same IP or same bridge_id. + """ + MockConfigEntry(domain='hue', data={ + 'host': '0.0.0.0', + 'bridge_id': 'id-1234' + }).add_to_hass(hass) + + MockConfigEntry(domain='hue', data={ + 'host': '1.2.3.4', + 'bridge_id': 'id-1234' + }).add_to_hass(hass) + + assert len(hass.config_entries.async_entries('hue')) == 2 + + flow = config_flow.HueFlowHandler() + flow.hass = hass + + bridge = Mock() + bridge.username = 'username-abc' + bridge.config.bridgeid = 'id-1234' + bridge.config.name = 'Mock Bridge' + bridge.host = '0.0.0.0' + + with patch.object(config_flow, 'get_bridge', + return_value=mock_coro(bridge)): + result = await flow.async_step_import({ + 'host': '0.0.0.0', + }) + + 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' + } + # We did not process the result of this entry but already removed the old + # ones. So we should have 0 entries. + assert len(hass.config_entries.async_entries('hue')) == 0 diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py new file mode 100644 index 00000000000..47e74b70e83 --- /dev/null +++ b/tests/components/hue/test_init.py @@ -0,0 +1,169 @@ +"""Test Hue setup process.""" +from unittest.mock import patch + +from homeassistant.setup import async_setup_component +from homeassistant.components import hue + +from tests.common import mock_coro, MockConfigEntry + + +async def test_setup_with_no_config(hass): + """Test that we do not discover anything or try to setup 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 + + # No flows started + assert len(mock_config_entries.flow.mock_calls) == 0 + + # No configs stored + assert hass.data[hue.DOMAIN] == {} + + +async def test_setup_with_discovery_no_known_auth(hass, aioclient_mock): + """Test discovering a bridge and not having known auth.""" + aioclient_mock.get(hue.API_NUPNP, json=[ + { + 'internalipaddress': '0.0.0.0', + 'id': 'abcd1234' + } + ]) + + 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: {} + }) 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': '.hue_abcd1234.conf', + } + + # Config stored for domain. + assert hass.data[hue.DOMAIN] == { + '0.0.0.0': { + hue.CONF_HOST: '0.0.0.0', + hue.CONF_FILENAME: '.hue_abcd1234.conf', + hue.CONF_ALLOW_HUE_GROUPS: hue.DEFAULT_ALLOW_HUE_GROUPS, + hue.CONF_ALLOW_UNREACHABLE: hue.DEFAULT_ALLOW_UNREACHABLE, + } + } + + +async def test_setup_with_discovery_known_auth(hass, aioclient_mock): + """Test we don't do anything if we discover already configured hub.""" + aioclient_mock.get(hue.API_NUPNP, json=[ + { + 'internalipaddress': '0.0.0.0', + 'id': 'abcd1234' + } + ]) + + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(hue, 'configured_hosts', return_value=['0.0.0.0']): + assert await async_setup_component(hass, hue.DOMAIN, { + hue.DOMAIN: {} + }) is True + + # Flow started for discovered bridge + assert len(mock_config_entries.flow.mock_calls) == 0 + + # Config stored for domain. + assert hass.data[hue.DOMAIN] == {} + + +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']): + 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 + } + } + }) is True + + # Flow started for discovered bridge + assert len(mock_config_entries.flow.mock_calls) == 0 + + # Config stored for domain. + assert hass.data[hue.DOMAIN] == { + '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 + } + } + + +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 + } + } + }) 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', + } + + # Config stored for domain. + assert hass.data[hue.DOMAIN] == { + '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 + } + } + + +async def test_config_passed_to_config_entry(hass): + """Test that configured options for a host are loaded via config entry.""" + entry = MockConfigEntry(domain=hue.DOMAIN, data={ + 'host': '0.0.0.0', + }) + entry.add_to_hass(hass) + + with patch.object(hue, 'HueBridge') as mock_bridge: + mock_bridge.return_value.async_setup.return_value = mock_coro(True) + 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 + } + } + }) is True + + assert len(mock_bridge.mock_calls) == 2 + p_hass, p_entry, p_allow_unreachable, p_allow_groups = \ + mock_bridge.mock_calls[0][1] + + assert p_hass is hass + assert p_entry is entry + assert p_allow_unreachable is True + assert p_allow_groups is False diff --git a/tests/components/hue/test_setup.py b/tests/components/hue/test_setup.py deleted file mode 100644 index f90f58a50c3..00000000000 --- a/tests/components/hue/test_setup.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Test Hue setup process.""" -from homeassistant.setup import async_setup_component -from homeassistant.components import hue -from homeassistant.components.discovery import SERVICE_HUE - - -async def test_setup_with_multiple_hosts(hass, mock_bridge): - """Multiple hosts specified in the config file.""" - assert await async_setup_component(hass, hue.DOMAIN, { - hue.DOMAIN: { - hue.CONF_BRIDGES: [ - {hue.CONF_HOST: '127.0.0.1'}, - {hue.CONF_HOST: '192.168.1.10'}, - ] - } - }) - - assert len(mock_bridge.mock_calls) == 2 - hosts = sorted(mock_call[1][0] for mock_call in mock_bridge.mock_calls) - assert hosts == ['127.0.0.1', '192.168.1.10'] - - -async def test_bridge_discovered(hass, mock_bridge): - """Bridge discovery.""" - assert await async_setup_component(hass, hue.DOMAIN, {}) - - await hass.helpers.discovery.async_discover(SERVICE_HUE, { - 'host': '192.168.1.10', - 'serial': '1234567', - }) - await hass.async_block_till_done() - - assert len(mock_bridge.mock_calls) == 1 - assert mock_bridge.mock_calls[0][1][0] == '192.168.1.10' - - -async def test_bridge_configure_and_discovered(hass, mock_bridge): - """Bridge is in the config file, then we discover it.""" - assert await async_setup_component(hass, hue.DOMAIN, { - hue.DOMAIN: { - hue.CONF_BRIDGES: { - hue.CONF_HOST: '192.168.1.10' - } - } - }) - - assert len(mock_bridge.mock_calls) == 1 - assert mock_bridge.mock_calls[0][1][0] == '192.168.1.10' - hass.data[hue.DOMAIN] = {'192.168.1.10': {}} - - mock_bridge.reset_mock() - - await hass.helpers.discovery.async_discover(SERVICE_HUE, { - 'host': '192.168.1.10', - 'serial': '1234567', - }) - await hass.async_block_till_done() - - assert len(mock_bridge.mock_calls) == 0 - - -async def test_setup_no_host(hass, aioclient_mock): - """Check we call discovery if domain specified but no bridges.""" - aioclient_mock.get(hue.API_NUPNP, json=[]) - - result = await async_setup_component( - hass, hue.DOMAIN, {hue.DOMAIN: {}}) - assert result - - assert len(aioclient_mock.mock_calls) == 1 diff --git a/tests/components/test_discovery.py b/tests/components/test_discovery.py index 580d876982d..b4c80bf3210 100644 --- a/tests/components/test_discovery.py +++ b/tests/components/test_discovery.py @@ -5,6 +5,7 @@ from unittest.mock import patch, MagicMock import pytest +from homeassistant import config_entries from homeassistant.bootstrap import async_setup_component from homeassistant.components import discovery from homeassistant.util.dt import utcnow @@ -44,13 +45,12 @@ def netdisco_mock(): yield -@asyncio.coroutine -def mock_discovery(hass, discoveries, config=BASE_CONFIG): +async def mock_discovery(hass, discoveries, config=BASE_CONFIG): """Helper to mock discoveries.""" - result = yield from async_setup_component(hass, 'discovery', config) + result = await async_setup_component(hass, 'discovery', config) assert result - yield from hass.async_start() + await hass.async_start() with patch.object(discovery, '_discover', discoveries), \ patch('homeassistant.components.discovery.async_discover', @@ -59,8 +59,8 @@ def mock_discovery(hass, discoveries, config=BASE_CONFIG): return_value=mock_coro()) as mock_platform: async_fire_time_changed(hass, utcnow()) # Work around an issue where our loop.call_soon not get caught - yield from hass.async_block_till_done() - yield from hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() return mock_discover, mock_platform @@ -154,3 +154,25 @@ def test_load_component_hassio(hass): yield from mock_discovery(hass, discover) assert mock_hassio.called + + +async def test_discover_config_flow(hass): + """Test discovery triggering a config flow.""" + discovery_info = { + 'hello': 'world' + } + + def discover(netdisco): + """Fake discovery.""" + return [('mock-service', discovery_info)] + + with patch.dict(discovery.CONFIG_ENTRY_HANDLERS, { + 'mock-service': 'mock-component'}), patch( + 'homeassistant.config_entries.FlowManager.async_init') as m_init: + await mock_discovery(hass, discover) + + assert len(m_init.mock_calls) == 1 + args, kwargs = m_init.mock_calls[0][1:] + assert args == ('mock-component',) + assert kwargs['source'] == config_entries.SOURCE_DISCOVERY + assert kwargs['data'] == discovery_info