From 5a9013cda58a3a99e1f1e9fa73e18c5895a4f56c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 16 Mar 2018 20:27:05 -0700 Subject: [PATCH] Refactor Hue: phue -> aiohue (#13043) * phue -> aiohue * Clean up * Fix config * Address comments * Typo * Fix rebase error * Mark light as unavailable when bridge is disconnected * Tests * Make Throttle work with double delay and async * Rework update logic * Don't resolve host to IP * Clarify comment * No longer do unnecessary updates * Add more doc * Another comment update * Wrap up tests * Lint * Fix tests * PyLint does not like mix 'n match async and coroutine * Lint * Update aiohue to 1.2 * Lint * Fix await MagicMock --- homeassistant/components/discovery.py | 16 +- homeassistant/components/hue/__init__.py | 261 ++--- homeassistant/components/light/hue.py | 367 ++++--- homeassistant/components/mqtt/discovery.py | 14 +- homeassistant/components/zwave/__init__.py | 20 +- homeassistant/core.py | 2 +- homeassistant/helpers/discovery.py | 18 +- homeassistant/util/__init__.py | 20 +- requirements_all.txt | 5 +- requirements_test_all.txt | 2 +- tests/components/hue/__init__.py | 1 + tests/components/hue/conftest.py | 17 + tests/components/hue/test_bridge.py | 98 ++ tests/components/hue/test_config_flow.py | 184 ++++ tests/components/hue/test_setup.py | 74 ++ tests/components/light/test_hue.py | 1051 ++++++++++---------- tests/components/test_hue.py | 588 ----------- tests/components/zwave/test_init.py | 14 +- tests/helpers/test_discovery.py | 14 +- tests/util/test_init.py | 8 + 20 files changed, 1289 insertions(+), 1485 deletions(-) create mode 100644 tests/components/hue/__init__.py create mode 100644 tests/components/hue/conftest.py create mode 100644 tests/components/hue/test_bridge.py create mode 100644 tests/components/hue/test_config_flow.py create mode 100644 tests/components/hue/test_setup.py delete mode 100644 tests/components/test_hue.py diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 21a339602dd..6ab7f42558b 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -6,7 +6,6 @@ Will emit EVENT_PLATFORM_DISCOVERED whenever a new service has been discovered. Knows which components handle certain types, will make sure they are loaded before the EVENT_PLATFORM_DISCOVERED is fired. """ -import asyncio import json from datetime import timedelta import logging @@ -84,8 +83,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Start a discovery service.""" from netdisco.discovery import NetworkDiscovery @@ -99,8 +97,7 @@ def async_setup(hass, config): # Platforms ignore by config ignored_platforms = config[DOMAIN][CONF_IGNORE] - @asyncio.coroutine - def new_service_found(service, info): + async def new_service_found(service, info): """Handle a new service if one is found.""" if service in ignored_platforms: logger.info("Ignoring service: %s %s", service, info) @@ -124,15 +121,14 @@ def async_setup(hass, config): component, platform = comp_plat if platform is None: - yield from async_discover(hass, service, info, component, config) + await async_discover(hass, service, info, component, config) else: - yield from async_load_platform( + await async_load_platform( hass, component, platform, info, config) - @asyncio.coroutine - def scan_devices(now): + async def scan_devices(now): """Scan for devices.""" - results = yield from hass.async_add_job(_discover, netdisco) + results = await hass.async_add_job(_discover, netdisco) for result in results: hass.async_add_job(new_service_found(*result)) diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index f15052fbd67..2fb55f8f6e0 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -6,22 +6,22 @@ https://home-assistant.io/components/hue/ """ import asyncio import json -from functools import partial +import ipaddress import logging import os -import socket import async_timeout -import requests 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 -REQUIREMENTS = ['phue==1.0', 'aiohue==0.3.0'] +REQUIREMENTS = ['aiohue==1.2.0'] _LOGGER = logging.getLogger(__name__) @@ -36,26 +36,23 @@ DEFAULT_ALLOW_UNREACHABLE = False PHUE_CONFIG_FILE = 'phue.conf' -CONF_ALLOW_IN_EMULATED_HUE = "allow_in_emulated_hue" -DEFAULT_ALLOW_IN_EMULATED_HUE = True - CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" DEFAULT_ALLOW_HUE_GROUPS = True -BRIDGE_CONFIG_SCHEMA = vol.Schema([{ - vol.Optional(CONF_HOST): cv.string, +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), vol.Optional(CONF_FILENAME, default=PHUE_CONFIG_FILE): cv.string, vol.Optional(CONF_ALLOW_UNREACHABLE, default=DEFAULT_ALLOW_UNREACHABLE): cv.boolean, - vol.Optional(CONF_ALLOW_IN_EMULATED_HUE, - default=DEFAULT_ALLOW_IN_EMULATED_HUE): cv.boolean, vol.Optional(CONF_ALLOW_HUE_GROUPS, default=DEFAULT_ALLOW_HUE_GROUPS): cv.boolean, -}]) +}) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_BRIDGES): BRIDGE_CONFIG_SCHEMA, + vol.Optional(CONF_BRIDGES): + vol.All(cv.ensure_list, [BRIDGE_CONFIG_SCHEMA]), }), }, extra=vol.ALLOW_EXTRA) @@ -73,7 +70,7 @@ Press the button on the bridge to register Philips Hue with Home Assistant. """ -def setup(hass, config): +async def async_setup(hass, config): """Set up the Hue platform.""" conf = config.get(DOMAIN) if conf is None: @@ -82,135 +79,130 @@ def setup(hass, config): if DOMAIN not in hass.data: hass.data[DOMAIN] = {} - discovery.listen( - hass, - SERVICE_HUE, - lambda service, discovery_info: - bridge_discovered(hass, service, discovery_info)) + 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) # User has configured bridges if CONF_BRIDGES in conf: bridges = conf[CONF_BRIDGES] + # Component is part of config but no bridges specified, discover. elif DOMAIN in config: # discover from nupnp - hosts = requests.get(API_NUPNP).json() - bridges = [{ + websession = aiohttp_client.async_get_clientsession(hass) + + 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] + }) for entry in hosts] + else: # Component not specified in config, we're loaded via discovery bridges = [] - for bridge in bridges: - filename = bridge.get(CONF_FILENAME) - allow_unreachable = bridge.get(CONF_ALLOW_UNREACHABLE) - allow_in_emulated_hue = bridge.get(CONF_ALLOW_IN_EMULATED_HUE) - allow_hue_groups = bridge.get(CONF_ALLOW_HUE_GROUPS) + if not bridges: + return True - host = bridge.get(CONF_HOST) - - if host is None: - host = _find_host_from_config(hass, filename) - - if host is None: - _LOGGER.error("No host found in configuration") - return False - - setup_bridge(host, hass, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups) + 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 + ]) return True -def bridge_discovered(hass, service, discovery_info): - """Dispatcher for Hue discovery events.""" - if "HASS Bridge" in discovery_info.get('name', ''): - return - - host = discovery_info.get('host') - serial = discovery_info.get('serial') - - filename = 'phue-{}.conf'.format(serial) - setup_bridge(host, hass, filename) - - -def setup_bridge(host, hass, filename=None, allow_unreachable=False, - allow_in_emulated_hue=True, allow_hue_groups=True, - username=None): +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 socket.gethostbyname(host) in hass.data[DOMAIN]: + 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_in_emulated_hue, allow_hue_groups) - bridge.setup() + allow_hue_groups) + await bridge.async_setup() + hass.data[DOMAIN][host] = bridge -def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): - """Attempt to detect host based on existing configuration.""" +def _find_username_from_config(hass, filename): + """Load username from config.""" path = hass.config.path(filename) if not os.path.isfile(path): return None - try: - with open(path) as inp: - return next(iter(json.load(inp).keys())) - except (ValueError, AttributeError, StopIteration): - # ValueError if can't parse as JSON - # AttributeError if JSON value is not a dict - # StopIteration if no keys - 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_in_emulated_hue=True, allow_hue_groups=True): + def __init__(self, host, hass, filename, username, + allow_unreachable=False, allow_groups=True): """Initialize the system.""" self.host = host - self.bridge_id = socket.gethostbyname(host) self.hass = hass self.filename = filename self.username = username self.allow_unreachable = allow_unreachable - self.allow_in_emulated_hue = allow_in_emulated_hue - self.allow_hue_groups = allow_hue_groups - + self.allow_groups = allow_groups self.available = True - self.bridge = None - self.lights = {} - self.lightgroups = {} - - self.configured = False self.config_request_id = None + self.api = None - hass.data[DOMAIN][self.bridge_id] = self - - def setup(self): + async def async_setup(self): """Set up a phue bridge based on host parameter.""" - import phue + import aiohue + + api = aiohue.Bridge( + self.host, + username=self.username, + websession=aiohttp_client.async_get_clientsession(self.hass) + ) try: - kwargs = {} - if self.username is not None: - kwargs['username'] = self.username - if self.filename is not None: - kwargs['config_file_path'] = \ - self.hass.config.path(self.filename) - self.bridge = phue.Bridge(self.host, **kwargs) - except OSError: # Wrong host was given + 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 phue.PhueRegistrationException: - _LOGGER.warning("Connected to Hue at %s but not registered.", - self.host) - self.request_configuration() + 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", @@ -221,57 +213,77 @@ class HueBridge(object): if self.config_request_id: request_id = self.config_request_id self.config_request_id = None - configurator = self.hass.components.configurator - configurator.request_done(request_id) + self.hass.components.configurator.async_request_done(request_id) - self.configured = True + self.username = api.username - discovery.load_platform( + # 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, - {'bridge_id': self.bridge_id}) + {'host': self.host})) - # create a service for calling run_scene directly on the bridge, - # used to simplify automation rules. - def hue_activate_scene(call): - """Service to call directly into bridge to set scenes.""" - group_name = call.data[ATTR_GROUP_NAME] - scene_name = call.data[ATTR_SCENE_NAME] - self.bridge.run_scene(group_name, scene_name) - - self.hass.services.register( - DOMAIN, SERVICE_HUE_SCENE, hue_activate_scene, + self.hass.services.async_register( + DOMAIN, SERVICE_HUE_SCENE, self.hue_activate_scene, schema=SCENE_SCHEMA) - def request_configuration(self): + @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.notify_errors( + configurator.async_notify_errors( self.config_request_id, "Failed to register, please try again.") return - self.config_request_id = configurator.request_config( - "Philips Hue", - lambda data: self.setup(), + 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" ) - def get_api(self): - """Return the full api dictionary from phue.""" - return self.bridge.get_api() + 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] - def set_light(self, light_id, command): - """Adjust properties of one or more lights. See phue for details.""" - return self.bridge.set_light(light_id, command) + group = next( + (group for group in self.api.groups.values() + if group.name == group_name), None) - def set_group(self, light_id, command): - """Change light settings for a group. See phue for detail.""" - return self.bridge.set_group(light_id, command) + 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) @@ -374,7 +386,6 @@ class HueFlowHandler(config_entries.ConfigFlowHandler): async def async_setup_entry(hass, entry): """Set up a bridge for a config entry.""" - await hass.async_add_job(partial( - setup_bridge, entry.data['host'], hass, - username=entry.data['username'])) + await async_setup_bridge(hass, entry.data['host'], + username=entry.data['username']) return True diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 661b7c2b3a1..c45d9c5c44e 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -8,31 +8,23 @@ import asyncio from datetime import timedelta import logging import random -import re -import socket -import voluptuous as vol +import async_timeout import homeassistant.components.hue as hue from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, EFFECT_COLORLOOP, EFFECT_RANDOM, - FLASH_LONG, FLASH_SHORT, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, + FLASH_LONG, FLASH_SHORT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, SUPPORT_XY_COLOR, Light) -from homeassistant.const import CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME -import homeassistant.helpers.config_validation as cv -import homeassistant.util as util -from homeassistant.util import yaml import homeassistant.util.color as color_util DEPENDENCIES = ['hue'] +SCAN_INTERVAL = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) - SUPPORT_HUE_ON_OFF = (SUPPORT_FLASH | SUPPORT_TRANSITION) SUPPORT_HUE_DIMMABLE = (SUPPORT_HUE_ON_OFF | SUPPORT_BRIGHTNESS) SUPPORT_HUE_COLOR_TEMP = (SUPPORT_HUE_DIMMABLE | SUPPORT_COLOR_TEMP) @@ -48,244 +40,232 @@ SUPPORT_HUE = { 'Color temperature light': SUPPORT_HUE_COLOR_TEMP } -ATTR_EMULATED_HUE_HIDDEN = 'emulated_hue_hidden' ATTR_IS_HUE_GROUP = 'is_hue_group' - -# Legacy configuration, will be removed in 0.60 -CONF_ALLOW_UNREACHABLE = 'allow_unreachable' -DEFAULT_ALLOW_UNREACHABLE = False -CONF_ALLOW_IN_EMULATED_HUE = 'allow_in_emulated_hue' -DEFAULT_ALLOW_IN_EMULATED_HUE = True -CONF_ALLOW_HUE_GROUPS = 'allow_hue_groups' -DEFAULT_ALLOW_HUE_GROUPS = True - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_ALLOW_UNREACHABLE): cv.boolean, - vol.Optional(CONF_FILENAME): cv.string, - vol.Optional(CONF_ALLOW_IN_EMULATED_HUE): cv.boolean, - vol.Optional(CONF_ALLOW_HUE_GROUPS, - default=DEFAULT_ALLOW_HUE_GROUPS): cv.boolean, -}) - -MIGRATION_ID = 'light_hue_config_migration' -MIGRATION_TITLE = 'Philips Hue Configuration Migration' -MIGRATION_INSTRUCTIONS = """ -Configuration for the Philips Hue component has changed; action required. - -You have configured at least one bridge: - - hue: -{config} - -This configuration is deprecated, please check the -[Hue component](https://home-assistant.io/components/hue/) page for more -information. -""" - -SIGNAL_CALLBACK = 'hue_light_callback_{}_{}' +# Minimum Hue Bridge API version to support groups +# 1.4.0 introduced extended group info +# 1.12 introduced the state object for groups +# 1.13 introduced "any_on" to group state objects +GROUP_MIN_API_VERSION = (1, 13, 0) -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Hue lights.""" - if discovery_info is None or 'bridge_id' not in discovery_info: + if discovery_info is None: return - if config is not None and config: - # Legacy configuration, will be removed in 0.60 - config_str = yaml.dump([config]) - # Indent so it renders in a fixed-width font - config_str = re.sub('(?m)^', ' ', config_str) - hass.components.persistent_notification.async_create( - MIGRATION_INSTRUCTIONS.format(config=config_str), - title=MIGRATION_TITLE, - notification_id=MIGRATION_ID) + bridge = hass.data[hue.DOMAIN][discovery_info['host']] + cur_lights = {} + cur_groups = {} - bridge_id = discovery_info['bridge_id'] - bridge = hass.data[hue.DOMAIN][bridge_id] - unthrottled_update_lights(hass, bridge, add_devices) + api_version = tuple( + int(v) for v in bridge.api.config.apiversion.split('.')) + + allow_groups = bridge.allow_groups + if allow_groups and api_version < GROUP_MIN_API_VERSION: + _LOGGER.warning('Please update your Hue bridge to support groups') + allow_groups = False + + # Hue updates all lights via a single API call. + # + # If we call a service to update 2 lights, we only want the API to be + # called once. + # + # The throttle decorator will return right away if a call is currently + # in progress. This means that if we are updating 2 lights, the first one + # is in the update method, the second one will skip it and assume the + # update went through and updates it's data, not good! + # + # The current mechanism will make sure that all lights will wait till + # the update call is done before writing their data to the state machine. + # + # An alternative approach would be to disable automatic polling by Home + # Assistant and take control ourselves. This works great for polling as now + # we trigger from 1 time update an update to all entities. However it gets + # tricky from inside async_turn_on and async_turn_off. + # + # If automatic polling is enabled, Home Assistant will call the entity + # update method after it is done calling all the services. This means that + # when we update, we know all commands have been processed. If we trigger + # the update from inside async_turn_on, the update will not capture the + # changes to the second entity until the next polling update because the + # throttle decorator will prevent the call. + + progress = None + light_progress = set() + group_progress = set() + + async def request_update(is_group, object_id): + """Request an update. + + We will only make 1 request to the server for updating at a time. If a + request is in progress, we will join the request that is in progress. + + This approach is possible because should_poll=True. That means that + Home Assistant will ask lights for updates during a polling cycle or + after it has called a service. + + We keep track of the lights that are waiting for the request to finish. + When new data comes in, we'll trigger an update for all non-waiting + lights. This covers the case where a service is called to enable 2 + lights but in the meanwhile some other light has changed too. + """ + nonlocal progress + + progress_set = group_progress if is_group else light_progress + progress_set.add(object_id) + + if progress is not None: + return await progress + + progress = asyncio.ensure_future(update_bridge()) + result = await progress + progress = None + light_progress.clear() + group_progress.clear() + return result + + async def update_bridge(): + """Update the values of the bridge. + + Will update lights and, if enabled, groups from the bridge. + """ + tasks = [] + tasks.append(async_update_items( + hass, bridge, async_add_devices, request_update, + False, cur_lights, light_progress + )) + + if allow_groups: + tasks.append(async_update_items( + hass, bridge, async_add_devices, request_update, + True, cur_groups, group_progress + )) + + await asyncio.wait(tasks) + + await update_bridge() -@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) -def update_lights(hass, bridge, add_devices): - """Update the Hue light objects with latest info from the bridge.""" - return unthrottled_update_lights(hass, bridge, add_devices) +async def async_update_items(hass, bridge, async_add_devices, + request_bridge_update, is_group, current, + progress_waiting): + """Update either groups or lights from the bridge.""" + import aiohue - -def unthrottled_update_lights(hass, bridge, add_devices): - """Update the lights (Internal version of update_lights).""" - import phue - - if not bridge.configured: - return + if is_group: + api = bridge.api.groups + else: + api = bridge.api.lights try: - api = bridge.get_api() - except phue.PhueRequestTimeout: - _LOGGER.warning("Timeout trying to reach the bridge") - bridge.available = False - return - except ConnectionRefusedError: - _LOGGER.error("The bridge refused the connection") - bridge.available = False - return - except socket.error: - # socket.error when we cannot reach Hue - _LOGGER.exception("Cannot reach the bridge") + with async_timeout.timeout(4): + await api.update() + except (asyncio.TimeoutError, aiohue.AiohueException): + if not bridge.available: + return + + _LOGGER.error('Unable to reach bridge %s', bridge.host) bridge.available = False + + for light_id, light in current.items(): + if light_id not in progress_waiting: + light.async_schedule_update_ha_state() + return - bridge.available = True + if not bridge.available: + _LOGGER.info('Reconnected to bridge %s', bridge.host) + bridge.available = True - new_lights = process_lights( - hass, api, bridge, - lambda **kw: update_lights(hass, bridge, add_devices, **kw)) - if bridge.allow_hue_groups: - new_lightgroups = process_groups( - hass, api, bridge, - lambda **kw: update_lights(hass, bridge, add_devices, **kw)) - new_lights.extend(new_lightgroups) + new_lights = [] + + for item_id in api: + if item_id not in current: + current[item_id] = HueLight( + api[item_id], request_bridge_update, bridge, is_group) + + new_lights.append(current[item_id]) + elif item_id not in progress_waiting: + current[item_id].async_schedule_update_ha_state() if new_lights: - add_devices(new_lights) - - -def process_lights(hass, api, bridge, update_lights_cb): - """Set up HueLight objects for all lights.""" - api_lights = api.get('lights') - - if not isinstance(api_lights, dict): - _LOGGER.error("Got unexpected result from Hue API") - return [] - - new_lights = [] - - for light_id, info in api_lights.items(): - if light_id not in bridge.lights: - bridge.lights[light_id] = HueLight( - int(light_id), info, bridge, - update_lights_cb, - bridge.allow_unreachable, - bridge.allow_in_emulated_hue) - new_lights.append(bridge.lights[light_id]) - else: - bridge.lights[light_id].info = info - hass.helpers.dispatcher.dispatcher_send( - SIGNAL_CALLBACK.format( - bridge.bridge_id, - bridge.lights[light_id].light_id)) - - return new_lights - - -def process_groups(hass, api, bridge, update_lights_cb): - """Set up HueLight objects for all groups.""" - api_groups = api.get('groups') - - if not isinstance(api_groups, dict): - _LOGGER.error('Got unexpected result from Hue API') - return [] - - new_lights = [] - - for lightgroup_id, info in api_groups.items(): - if 'state' not in info: - _LOGGER.warning( - "Group info does not contain state. Please update your hub") - return [] - - if lightgroup_id not in bridge.lightgroups: - bridge.lightgroups[lightgroup_id] = HueLight( - int(lightgroup_id), info, bridge, - update_lights_cb, - bridge.allow_unreachable, - bridge.allow_in_emulated_hue, True) - new_lights.append(bridge.lightgroups[lightgroup_id]) - else: - bridge.lightgroups[lightgroup_id].info = info - hass.helpers.dispatcher.dispatcher_send( - SIGNAL_CALLBACK.format( - bridge.bridge_id, - bridge.lightgroups[lightgroup_id].light_id)) - - return new_lights + async_add_devices(new_lights) class HueLight(Light): """Representation of a Hue light.""" - def __init__(self, light_id, info, bridge, update_lights_cb, - allow_unreachable, allow_in_emulated_hue, is_group=False): + def __init__(self, light, request_bridge_update, bridge, is_group=False): """Initialize the light.""" - self.light_id = light_id - self.info = info + self.light = light + self.async_request_bridge_update = request_bridge_update self.bridge = bridge - self.update_lights = update_lights_cb - self.allow_unreachable = allow_unreachable self.is_group = is_group - self.allow_in_emulated_hue = allow_in_emulated_hue if is_group: - self._command_func = self.bridge.set_group + self.is_osram = False + self.is_philips = False else: - self._command_func = self.bridge.set_light + self.is_osram = light.manufacturername == 'OSRAM' + self.is_philips = light.manufacturername == 'Philips' @property def unique_id(self): """Return the ID of this Hue light.""" - return self.info.get('uniqueid') + return self.light.uniqueid @property def name(self): """Return the name of the Hue light.""" - return self.info.get('name', DEVICE_DEFAULT_NAME) + return self.light.name @property def brightness(self): """Return the brightness of this light between 0..255.""" if self.is_group: - return self.info['action'].get('bri') - return self.info['state'].get('bri') + return self.light.action.get('bri') + return self.light.state.get('bri') @property def xy_color(self): """Return the XY color value.""" if self.is_group: - return self.info['action'].get('xy') - return self.info['state'].get('xy') + return self.light.action.get('xy') + return self.light.state.get('xy') @property def color_temp(self): """Return the CT color value.""" if self.is_group: - return self.info['action'].get('ct') - return self.info['state'].get('ct') + return self.light.action.get('ct') + return self.light.state.get('ct') @property def is_on(self): """Return true if device is on.""" if self.is_group: - return self.info['state']['any_on'] - return self.info['state']['on'] + return self.light.state['any_on'] + return self.light.state['on'] @property def available(self): """Return if light is available.""" return self.bridge.available and (self.is_group or - self.allow_unreachable or - self.info['state']['reachable']) + self.bridge.allow_unreachable or + self.light.state['reachable']) @property def supported_features(self): """Flag supported features.""" - return SUPPORT_HUE.get(self.info.get('type'), SUPPORT_HUE_EXTENDED) + return SUPPORT_HUE.get(self.light.type, SUPPORT_HUE_EXTENDED) @property def effect_list(self): """Return the list of supported effects.""" return [EFFECT_COLORLOOP, EFFECT_RANDOM] - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the specified or all lights on.""" command = {'on': True} @@ -293,7 +273,7 @@ class HueLight(Light): command['transitiontime'] = int(kwargs[ATTR_TRANSITION] * 10) if ATTR_XY_COLOR in kwargs: - if self.info.get('manufacturername') == 'OSRAM': + if self.is_osram: color_hue, sat = color_util.color_xy_to_hs( *kwargs[ATTR_XY_COLOR]) command['hue'] = color_hue / 360 * 65535 @@ -301,7 +281,7 @@ class HueLight(Light): else: command['xy'] = kwargs[ATTR_XY_COLOR] elif ATTR_RGB_COLOR in kwargs: - if self.info.get('manufacturername') == 'OSRAM': + if self.is_osram: hsv = color_util.color_RGB_to_hsv( *(int(val) for val in kwargs[ATTR_RGB_COLOR])) command['hue'] = hsv[0] / 360 * 65535 @@ -336,12 +316,15 @@ class HueLight(Light): elif effect == EFFECT_RANDOM: command['hue'] = random.randrange(0, 65535) command['sat'] = random.randrange(150, 254) - elif self.info.get('manufacturername') == 'Philips': + elif self.is_philips: command['effect'] = 'none' - self._command_func(self.light_id, command) + if self.is_group: + await self.light.set_action(**command) + else: + await self.light.set_state(**command) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the specified or all lights off.""" command = {'on': False} @@ -359,27 +342,19 @@ class HueLight(Light): else: command['alert'] = 'none' - self._command_func(self.light_id, command) + if self.is_group: + await self.light.set_action(**command) + else: + await self.light.set_state(**command) - def update(self): + async def async_update(self): """Synchronize state with bridge.""" - self.update_lights(no_throttle=True) + await self.async_request_bridge_update(self.is_group, self.light.id) @property def device_state_attributes(self): """Return the device state attributes.""" attributes = {} - if not self.allow_in_emulated_hue: - attributes[ATTR_EMULATED_HUE_HIDDEN] = \ - not self.allow_in_emulated_hue if self.is_group: attributes[ATTR_IS_HUE_GROUP] = self.is_group return attributes - - @asyncio.coroutine - def async_added_to_hass(self): - """Register update callback.""" - dev_id = self.bridge.bridge_id, self.light_id - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_CALLBACK.format(*dev_id), - self.async_schedule_update_ha_state) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index b6f6a1c5a92..d0164706626 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -4,7 +4,6 @@ Support for MQTT discovery. For more details about this component, please refer to the documentation at https://home-assistant.io/components/mqtt/#discovery """ -import asyncio import json import logging import re @@ -35,19 +34,16 @@ ALLOWED_PLATFORMS = { ALREADY_DISCOVERED = 'mqtt_discovered_components' -@asyncio.coroutine -def async_start(hass, discovery_topic, hass_config): +async def async_start(hass, discovery_topic, hass_config): """Initialize of MQTT Discovery.""" - # pylint: disable=unused-variable - @asyncio.coroutine - def async_device_message_received(topic, payload, qos): + async def async_device_message_received(topic, payload, qos): """Process the received message.""" match = TOPIC_MATCHER.match(topic) if not match: return - prefix_topic, component, node_id, object_id = match.groups() + _prefix_topic, component, node_id, object_id = match.groups() try: payload = json.loads(payload) @@ -88,10 +84,10 @@ def async_start(hass, discovery_topic, hass_config): _LOGGER.info("Found new component: %s %s", component, discovery_id) - yield from async_load_platform( + await async_load_platform( hass, component, platform, payload, hass_config) - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( hass, discovery_topic + '/#', async_device_message_received, 0) return True diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index ad4ae66df17..43aa996c799 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -203,8 +203,8 @@ def get_config_value(node, value_index, tries=5): return None -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Z-Wave platform (generic part).""" if discovery_info is None or DATA_NETWORK not in hass.data: return False @@ -504,8 +504,7 @@ def setup(hass, config): "target node:%s, instance=%s", node_id, group, target_node_id, instance) - @asyncio.coroutine - def async_refresh_entity(service): + async def async_refresh_entity(service): """Refresh values that specific entity depends on.""" entity_id = service.data.get(ATTR_ENTITY_ID) async_dispatcher_send( @@ -559,8 +558,7 @@ def setup(hass, config): network.start() hass.bus.fire(const.EVENT_NETWORK_START) - @asyncio.coroutine - def _check_awaked(): + async def _check_awaked(): """Wait for Z-wave awaked state (or timeout) and finalize start.""" _LOGGER.debug( "network state: %d %s", network.state, @@ -585,7 +583,7 @@ def setup(hass, config): network.state_str) break else: - yield from asyncio.sleep(1, loop=hass.loop) + await asyncio.sleep(1, loop=hass.loop) hass.async_add_job(_finalize_start) @@ -798,11 +796,10 @@ class ZWaveDeviceEntityValues(): dict_id = id(self) - @asyncio.coroutine - def discover_device(component, device, dict_id): + async def discover_device(component, device, dict_id): """Put device in a dictionary and call discovery on it.""" self._hass.data[DATA_DEVICES][dict_id] = device - yield from discovery.async_load_platform( + await discovery.async_load_platform( self._hass, component, DOMAIN, {const.DISCOVERY_DEVICE: dict_id}, self._zwave_config) self._hass.add_job(discover_device, component, device, dict_id) @@ -844,8 +841,7 @@ class ZWaveDeviceEntity(ZWaveBaseEntity): self.update_properties() self.maybe_schedule_update() - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Add device to dict.""" async_dispatcher_connect( self.hass, diff --git a/homeassistant/core.py b/homeassistant/core.py index b49b94f853d..65db82a1fbe 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -79,7 +79,7 @@ def callback(func: Callable[..., None]) -> Callable[..., None]: def is_callback(func: Callable[..., Any]) -> bool: """Check if function is safe to be called in the event loop.""" - return '_hass_callback' in func.__dict__ + return '_hass_callback' in getattr(func, '__dict__', {}) @callback diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 82322fec1e5..cb587c432c1 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -5,8 +5,6 @@ There are two different types of discoveries that can be fired/listened for. - listen_platform/discover_platform is for platforms. These are used by components to allow discovery of their platforms. """ -import asyncio - from homeassistant import setup, core from homeassistant.loader import bind_hass from homeassistant.const import ( @@ -58,17 +56,16 @@ def discover(hass, service, discovered=None, component=None, hass_config=None): async_discover(hass, service, discovered, component, hass_config)) -@asyncio.coroutine @bind_hass -def async_discover(hass, service, discovered=None, component=None, - hass_config=None): +async def async_discover(hass, service, discovered=None, component=None, + hass_config=None): """Fire discovery event. Can ensure a component is loaded.""" if component in DEPENDENCY_BLACKLIST: raise HomeAssistantError( 'Cannot discover the {} component.'.format(component)) if component is not None and component not in hass.config.components: - yield from setup.async_setup_component( + await setup.async_setup_component( hass, component, hass_config) data = { @@ -134,10 +131,9 @@ def load_platform(hass, component, platform, discovered=None, hass_config)) -@asyncio.coroutine @bind_hass -def async_load_platform(hass, component, platform, discovered=None, - hass_config=None): +async def async_load_platform(hass, component, platform, discovered=None, + hass_config=None): """Load a component and platform dynamically. Target components will be loaded and an EVENT_PLATFORM_DISCOVERED will be @@ -148,7 +144,7 @@ def async_load_platform(hass, component, platform, discovered=None, Use `listen_platform` to register a callback for these events. - Warning: Do not yield from this inside a setup method to avoid a dead lock. + Warning: Do not await this inside a setup method to avoid a dead lock. Use `hass.async_add_job(async_load_platform(..))` instead. This method is a coroutine. @@ -160,7 +156,7 @@ def async_load_platform(hass, component, platform, discovered=None, setup_success = True if component not in hass.config.components: - setup_success = yield from setup.async_setup_component( + setup_success = await setup.async_setup_component( hass, component, hass_config) # No need to fire event if we could not setup component diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index a869251dc3c..82ba6a734f8 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -261,6 +261,16 @@ class Throttle(object): def __call__(self, method): """Caller for the throttle.""" + # Make sure we return a coroutine if the method is async. + if asyncio.iscoroutinefunction(method): + async def throttled_value(): + """Stand-in function for when real func is being throttled.""" + return None + else: + def throttled_value(): + """Stand-in function for when real func is being throttled.""" + return None + if self.limit_no_throttle is not None: method = Throttle(self.limit_no_throttle)(method) @@ -277,16 +287,6 @@ class Throttle(object): is_func = (not hasattr(method, '__self__') and '.' not in method.__qualname__.split('..')[-1]) - # Make sure we return a coroutine if the method is async. - if asyncio.iscoroutinefunction(method): - async def throttled_value(): - """Stand-in function for when real func is being throttled.""" - return None - else: - def throttled_value(): - """Stand-in function for when real func is being throttled.""" - return None - @wraps(method) def wrapper(*args, **kwargs): """Wrap that allows wrapped to be called only once per min_time. diff --git a/requirements_all.txt b/requirements_all.txt index f25200ba49e..839987611bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -74,7 +74,7 @@ aiodns==1.1.1 aiohttp_cors==0.6.0 # homeassistant.components.hue -aiohue==0.3.0 +aiohue==1.2.0 # homeassistant.components.sensor.imap aioimaplib==0.7.13 @@ -568,9 +568,6 @@ pdunehd==1.3 # homeassistant.components.media_player.pandora pexpect==4.0.1 -# homeassistant.components.hue -phue==1.0 - # homeassistant.components.rpi_pfio pifacecommon==4.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b3cc8d207a..d41f9589de2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -35,7 +35,7 @@ aioautomatic==0.6.5 aiohttp_cors==0.6.0 # homeassistant.components.hue -aiohue==0.3.0 +aiohue==1.2.0 # homeassistant.components.notify.apns apns2==0.3.0 diff --git a/tests/components/hue/__init__.py b/tests/components/hue/__init__.py new file mode 100644 index 00000000000..8cff8700aaf --- /dev/null +++ b/tests/components/hue/__init__.py @@ -0,0 +1 @@ +"""Tests for the Hue component.""" diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py new file mode 100644 index 00000000000..7ccc202b31b --- /dev/null +++ b/tests/components/hue/conftest.py @@ -0,0 +1,17 @@ +"""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 new file mode 100644 index 00000000000..88a7223d91e --- /dev/null +++ b/tests/components/hue/test_bridge.py @@ -0,0 +1,98 @@ +"""Test Hue bridge.""" +import asyncio +from unittest.mock import Mock, patch + +import aiohue +import pytest + +from homeassistant.components import hue + +from tests.common import mock_coro + + +class MockBridge(hue.HueBridge): + """Class that sets default for constructor.""" + + 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) + + +@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): + """.""" + 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 len(mock_load_platform.mock_calls) == 1 diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py new file mode 100644 index 00000000000..959e3c6241b --- /dev/null +++ b/tests/components/hue/test_config_flow.py @@ -0,0 +1,184 @@ +"""Tests for Philips Hue config flow.""" +import asyncio +from unittest.mock import patch + +import aiohue +import pytest +import voluptuous as vol + +from homeassistant.components import hue + +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=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'} + ]) + + flow = hue.HueFlowHandler() + flow.hass = hass + await flow.async_step_init() + + with patch('aiohue.Bridge') as mock_bridge: + def mock_constructor(host, websession): + mock_bridge.host = host + return mock_bridge + + 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() + + 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 + + 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' + } + + +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() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'abort' + + +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=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'} + ]) + MockConfigEntry(domain='hue', data={ + 'host': '1.2.3.4' + }).add_to_hass(hass) + flow = hue.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'abort' + + +async def test_flow_one_bridge_discovered(hass, aioclient_mock): + """Test config flow discovers one bridge.""" + aioclient_mock.get(hue.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'} + ]) + flow = hue.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_flow_two_bridges_discovered(hass, aioclient_mock): + """Test config flow discovers two bridges.""" + aioclient_mock.get(hue.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'}, + {'internalipaddress': '5.6.7.8', 'id': 'beer'} + ]) + flow = hue.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'init' + + with pytest.raises(vol.Invalid): + assert result['data_schema']({'host': '0.0.0.0'}) + + result['data_schema']({'host': '1.2.3.4'}) + result['data_schema']({'host': '5.6.7.8'}) + + +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=[ + {'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.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert flow.host == '5.6.7.8' + + +async def test_flow_timeout_discovery(hass): + """Test config flow .""" + flow = hue.HueFlowHandler() + flow.hass = hass + + with patch('aiohue.discovery.discover_nupnp', + side_effect=asyncio.TimeoutError): + result = await flow.async_step_init() + + assert result['type'] == 'abort' + + +async def test_flow_link_timeout(hass): + """Test config flow .""" + flow = hue.HueFlowHandler() + flow.hass = hass + + with patch('aiohue.Bridge.create_user', + side_effect=asyncio.TimeoutError): + result = await flow.async_step_link({}) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert result['errors'] == { + 'base': 'register_failed' + } + + +async def test_flow_link_button_not_pressed(hass): + """Test config flow .""" + flow = hue.HueFlowHandler() + flow.hass = hass + + with patch('aiohue.Bridge.create_user', + side_effect=aiohue.LinkButtonNotPressed): + result = await flow.async_step_link({}) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert result['errors'] == { + 'base': 'register_failed' + } + + +async def test_flow_link_unknown_host(hass): + """Test config flow .""" + flow = hue.HueFlowHandler() + flow.hass = hass + + with patch('aiohue.Bridge.create_user', + side_effect=aiohue.RequestError): + result = await flow.async_step_link({}) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert result['errors'] == { + 'base': 'register_failed' + } diff --git a/tests/components/hue/test_setup.py b/tests/components/hue/test_setup.py new file mode 100644 index 00000000000..690419fcb7a --- /dev/null +++ b/tests/components/hue/test_setup.py @@ -0,0 +1,74 @@ +"""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'] + assert len(hass.data[hue.DOMAIN]) == 2 + + +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' + assert len(hass.data[hue.DOMAIN]) == 1 + + +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' + assert len(hass.data[hue.DOMAIN]) == 1 + + 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 + assert len(hass.data[hue.DOMAIN]) == 1 + + +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 + assert len(hass.data[hue.DOMAIN]) == 0 diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py index 559467d5e9a..8abf51fdf0c 100644 --- a/tests/components/light/test_hue.py +++ b/tests/components/light/test_hue.py @@ -1,545 +1,590 @@ """Philips Hue lights platform tests.""" - +import asyncio +from collections import deque import logging -import unittest -import unittest.mock as mock -from unittest.mock import call, MagicMock, patch +from unittest.mock import Mock + +import aiohue +from aiohue.lights import Lights +from aiohue.groups import Groups +import pytest from homeassistant.components import hue import homeassistant.components.light.hue as hue_light -from tests.common import get_test_home_assistant, MockDependency - _LOGGER = logging.getLogger(__name__) HUE_LIGHT_NS = 'homeassistant.components.light.hue.' - - -class TestSetup(unittest.TestCase): - """Test the Hue light platform.""" - - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.skip_teardown_stop = False - - def tearDown(self): - """Stop everything that was started.""" - if not self.skip_teardown_stop: - self.hass.stop() - - def setup_mocks_for_update_lights(self): - """Set up all mocks for update_lights tests.""" - self.mock_bridge = MagicMock() - self.mock_bridge.bridge_id = 'bridge-id' - self.mock_bridge.allow_hue_groups = False - self.mock_api = MagicMock() - self.mock_bridge.get_api.return_value = self.mock_api - self.mock_add_devices = MagicMock() - - def setup_mocks_for_process_lights(self): - """Set up all mocks for process_lights tests.""" - self.mock_bridge = self.create_mock_bridge('host') - self.mock_api = MagicMock() - self.mock_api.get.return_value = {} - self.mock_bridge.get_api.return_value = self.mock_api - - def setup_mocks_for_process_groups(self): - """Set up all mocks for process_groups tests.""" - self.mock_bridge = self.create_mock_bridge('host') - self.mock_bridge.get_group.return_value = { - 'name': 'Group 0', 'state': {'any_on': True}} - - self.mock_api = MagicMock() - self.mock_api.get.return_value = {} - self.mock_bridge.get_api.return_value = self.mock_api - - def create_mock_bridge(self, host, allow_hue_groups=True): - """Return a mock HueBridge with reasonable defaults.""" - mock_bridge = MagicMock() - mock_bridge.bridge_id = 'bridge-id' - mock_bridge.host = host - mock_bridge.allow_hue_groups = allow_hue_groups - mock_bridge.lights = {} - mock_bridge.lightgroups = {} - return mock_bridge - - def create_mock_lights(self, lights): - """Return a dict suitable for mocking api.get('lights').""" - mock_bridge_lights = lights - - for info in mock_bridge_lights.values(): - if 'state' not in info: - info['state'] = {'on': False} - - return mock_bridge_lights - - def build_mock_light(self, bridge, light_id, name): - """Return a mock HueLight.""" - light = MagicMock() - light.bridge = bridge - light.light_id = light_id - light.name = name - return light - - def test_setup_platform_no_discovery_info(self): - """Test setup_platform without discovery info.""" - self.hass.data[hue.DOMAIN] = {} - mock_add_devices = MagicMock() - - hue_light.setup_platform(self.hass, {}, mock_add_devices) - - mock_add_devices.assert_not_called() - - def test_setup_platform_no_bridge_id(self): - """Test setup_platform without a bridge.""" - self.hass.data[hue.DOMAIN] = {} - mock_add_devices = MagicMock() - - hue_light.setup_platform(self.hass, {}, mock_add_devices, {}) - - mock_add_devices.assert_not_called() - - def test_setup_platform_one_bridge(self): - """Test setup_platform with one bridge.""" - mock_bridge = MagicMock() - self.hass.data[hue.DOMAIN] = {'10.0.0.1': mock_bridge} - mock_add_devices = MagicMock() - - with patch(HUE_LIGHT_NS + 'unthrottled_update_lights') \ - as mock_update_lights: - hue_light.setup_platform( - self.hass, {}, mock_add_devices, - {'bridge_id': '10.0.0.1'}) - mock_update_lights.assert_called_once_with( - self.hass, mock_bridge, mock_add_devices) - - def test_setup_platform_multiple_bridges(self): - """Test setup_platform wuth multiple bridges.""" - mock_bridge = MagicMock() - mock_bridge2 = MagicMock() - self.hass.data[hue.DOMAIN] = { - '10.0.0.1': mock_bridge, - '192.168.0.10': mock_bridge2, +GROUP_RESPONSE = { + "1": { + "name": "Group 1", + "lights": [ + "1", + "2" + ], + "type": "LightGroup", + "action": { + "on": True, + "bri": 254, + "hue": 10000, + "sat": 254, + "effect": "none", + "xy": [ + 0.5, + 0.5 + ], + "ct": 250, + "alert": "select", + "colormode": "ct" + }, + "state": { + "any_on": True, + "all_on": False, } - mock_add_devices = MagicMock() - - with patch(HUE_LIGHT_NS + 'unthrottled_update_lights') \ - as mock_update_lights: - hue_light.setup_platform( - self.hass, {}, mock_add_devices, - {'bridge_id': '10.0.0.1'}) - hue_light.setup_platform( - self.hass, {}, mock_add_devices, - {'bridge_id': '192.168.0.10'}) - - mock_update_lights.assert_has_calls([ - call(self.hass, mock_bridge, mock_add_devices), - call(self.hass, mock_bridge2, mock_add_devices), - ]) - - @MockDependency('phue') - def test_update_lights_with_no_lights(self, mock_phue): - """Test the update_lights function when no lights are found.""" - self.setup_mocks_for_update_lights() - - with patch(HUE_LIGHT_NS + 'process_lights', return_value=[]) \ - as mock_process_lights: - with patch(HUE_LIGHT_NS + 'process_groups', return_value=[]) \ - as mock_process_groups: - with patch.object(self.hass.helpers.dispatcher, - 'dispatcher_send') as dispatcher_send: - hue_light.unthrottled_update_lights( - self.hass, self.mock_bridge, self.mock_add_devices) - - mock_process_lights.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - mock_process_groups.assert_not_called() - self.mock_add_devices.assert_not_called() - dispatcher_send.assert_not_called() - - @MockDependency('phue') - def test_update_lights_with_some_lights(self, mock_phue): - """Test the update_lights function with some lights.""" - self.setup_mocks_for_update_lights() - mock_lights = [ - self.build_mock_light(self.mock_bridge, 42, 'some'), - self.build_mock_light(self.mock_bridge, 84, 'light'), - ] - - with patch(HUE_LIGHT_NS + 'process_lights', - return_value=mock_lights) as mock_process_lights: - with patch(HUE_LIGHT_NS + 'process_groups', return_value=[]) \ - as mock_process_groups: - with patch.object(self.hass.helpers.dispatcher, - 'dispatcher_send') as dispatcher_send: - hue_light.unthrottled_update_lights( - self.hass, self.mock_bridge, self.mock_add_devices) - - mock_process_lights.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - mock_process_groups.assert_not_called() - self.mock_add_devices.assert_called_once_with( - mock_lights) - dispatcher_send.assert_not_called() - - @MockDependency('phue') - def test_update_lights_no_groups(self, mock_phue): - """Test the update_lights function when no groups are found.""" - self.setup_mocks_for_update_lights() - self.mock_bridge.allow_hue_groups = True - mock_lights = [ - self.build_mock_light(self.mock_bridge, 42, 'some'), - self.build_mock_light(self.mock_bridge, 84, 'light'), - ] - - with patch(HUE_LIGHT_NS + 'process_lights', - return_value=mock_lights) as mock_process_lights: - with patch(HUE_LIGHT_NS + 'process_groups', return_value=[]) \ - as mock_process_groups: - with patch.object(self.hass.helpers.dispatcher, - 'dispatcher_send') as dispatcher_send: - hue_light.unthrottled_update_lights( - self.hass, self.mock_bridge, self.mock_add_devices) - - mock_process_lights.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - mock_process_groups.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - self.mock_add_devices.assert_called_once_with( - mock_lights) - dispatcher_send.assert_not_called() - - @MockDependency('phue') - def test_update_lights_with_lights_and_groups(self, mock_phue): - """Test the update_lights function with both lights and groups.""" - self.setup_mocks_for_update_lights() - self.mock_bridge.allow_hue_groups = True - mock_lights = [ - self.build_mock_light(self.mock_bridge, 42, 'some'), - self.build_mock_light(self.mock_bridge, 84, 'light'), - ] - mock_groups = [ - self.build_mock_light(self.mock_bridge, 15, 'and'), - self.build_mock_light(self.mock_bridge, 72, 'groups'), - ] - - with patch(HUE_LIGHT_NS + 'process_lights', - return_value=mock_lights) as mock_process_lights: - with patch(HUE_LIGHT_NS + 'process_groups', - return_value=mock_groups) as mock_process_groups: - with patch.object(self.hass.helpers.dispatcher, - 'dispatcher_send') as dispatcher_send: - hue_light.unthrottled_update_lights( - self.hass, self.mock_bridge, self.mock_add_devices) - - mock_process_lights.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - mock_process_groups.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - # note that mock_lights has been modified in place and - # now contains both lights and groups - self.mock_add_devices.assert_called_once_with( - mock_lights) - dispatcher_send.assert_not_called() - - @MockDependency('phue') - def test_update_lights_with_two_bridges(self, mock_phue): - """Test the update_lights function with two bridges.""" - self.setup_mocks_for_update_lights() - - mock_bridge_one = self.create_mock_bridge('one', False) - mock_bridge_one_lights = self.create_mock_lights( - {1: {'name': 'b1l1'}, 2: {'name': 'b1l2'}}) - - mock_bridge_two = self.create_mock_bridge('two', False) - mock_bridge_two_lights = self.create_mock_lights( - {1: {'name': 'b2l1'}, 3: {'name': 'b2l3'}}) - - with patch('homeassistant.components.light.hue.HueLight.' - 'schedule_update_ha_state'): - mock_api = MagicMock() - mock_api.get.return_value = mock_bridge_one_lights - with patch.object(mock_bridge_one, 'get_api', - return_value=mock_api): - hue_light.unthrottled_update_lights( - self.hass, mock_bridge_one, self.mock_add_devices) - - mock_api = MagicMock() - mock_api.get.return_value = mock_bridge_two_lights - with patch.object(mock_bridge_two, 'get_api', - return_value=mock_api): - hue_light.unthrottled_update_lights( - self.hass, mock_bridge_two, self.mock_add_devices) - - self.assertEqual(sorted(mock_bridge_one.lights.keys()), [1, 2]) - self.assertEqual(sorted(mock_bridge_two.lights.keys()), [1, 3]) - - self.assertEqual(len(self.mock_add_devices.mock_calls), 2) - - # first call - name, args, kwargs = self.mock_add_devices.mock_calls[0] - self.assertEqual(len(args), 1) - self.assertEqual(len(kwargs), 0) - - # second call works the same - name, args, kwargs = self.mock_add_devices.mock_calls[1] - self.assertEqual(len(args), 1) - self.assertEqual(len(kwargs), 0) - - def test_process_lights_api_error(self): - """Test the process_lights function when the bridge errors out.""" - self.setup_mocks_for_process_lights() - self.mock_api.get.return_value = None - - ret = hue_light.process_lights( - self.hass, self.mock_api, self.mock_bridge, None) - - self.assertEqual([], ret) - self.assertEqual(self.mock_bridge.lights, {}) - - def test_process_lights_no_lights(self): - """Test the process_lights function when bridge returns no lights.""" - self.setup_mocks_for_process_lights() - - with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ - as mock_dispatcher_send: - ret = hue_light.process_lights( - self.hass, self.mock_api, self.mock_bridge, None) - - self.assertEqual([], ret) - mock_dispatcher_send.assert_not_called() - self.assertEqual(self.mock_bridge.lights, {}) - - @patch(HUE_LIGHT_NS + 'HueLight') - def test_process_lights_some_lights(self, mock_hue_light): - """Test the process_lights function with multiple groups.""" - self.setup_mocks_for_process_lights() - self.mock_api.get.return_value = { - 1: {'state': 'on'}, 2: {'state': 'off'}} - - with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ - as mock_dispatcher_send: - ret = hue_light.process_lights( - self.hass, self.mock_api, self.mock_bridge, None) - - self.assertEqual(len(ret), 2) - mock_hue_light.assert_has_calls([ - call( - 1, {'state': 'on'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue), - call( - 2, {'state': 'off'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue), - ]) - mock_dispatcher_send.assert_not_called() - self.assertEqual(len(self.mock_bridge.lights), 2) - - @patch(HUE_LIGHT_NS + 'HueLight') - def test_process_lights_new_light(self, mock_hue_light): - """ - Test the process_lights function with new groups. - - Test what happens when we already have a light and a new one shows up. - """ - self.setup_mocks_for_process_lights() - self.mock_api.get.return_value = { - 1: {'state': 'on'}, 2: {'state': 'off'}} - self.mock_bridge.lights = { - 1: self.build_mock_light(self.mock_bridge, 1, 'foo')} - - with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ - as mock_dispatcher_send: - ret = hue_light.process_lights( - self.hass, self.mock_api, self.mock_bridge, None) - - self.assertEqual(len(ret), 1) - mock_hue_light.assert_has_calls([ - call( - 2, {'state': 'off'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue), - ]) - mock_dispatcher_send.assert_called_once_with( - 'hue_light_callback_bridge-id_1') - self.assertEqual(len(self.mock_bridge.lights), 2) - - def test_process_groups_api_error(self): - """Test the process_groups function when the bridge errors out.""" - self.setup_mocks_for_process_groups() - self.mock_api.get.return_value = None - - ret = hue_light.process_groups( - self.hass, self.mock_api, self.mock_bridge, None) - - self.assertEqual([], ret) - self.assertEqual(self.mock_bridge.lightgroups, {}) - - def test_process_groups_no_state(self): - """Test the process_groups function when bridge returns no status.""" - self.setup_mocks_for_process_groups() - self.mock_bridge.get_group.return_value = {'name': 'Group 0'} - - with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ - as mock_dispatcher_send: - ret = hue_light.process_groups( - self.hass, self.mock_api, self.mock_bridge, None) - - self.assertEqual([], ret) - mock_dispatcher_send.assert_not_called() - self.assertEqual(self.mock_bridge.lightgroups, {}) - - @patch(HUE_LIGHT_NS + 'HueLight') - def test_process_groups_some_groups(self, mock_hue_light): - """Test the process_groups function with multiple groups.""" - self.setup_mocks_for_process_groups() - self.mock_api.get.return_value = { - 1: {'state': 'on'}, 2: {'state': 'off'}} - - with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ - as mock_dispatcher_send: - ret = hue_light.process_groups( - self.hass, self.mock_api, self.mock_bridge, None) - - self.assertEqual(len(ret), 2) - mock_hue_light.assert_has_calls([ - call( - 1, {'state': 'on'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue, True), - call( - 2, {'state': 'off'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue, True), - ]) - mock_dispatcher_send.assert_not_called() - self.assertEqual(len(self.mock_bridge.lightgroups), 2) - - @patch(HUE_LIGHT_NS + 'HueLight') - def test_process_groups_new_group(self, mock_hue_light): - """ - Test the process_groups function with new groups. - - Test what happens when we already have a light and a new one shows up. - """ - self.setup_mocks_for_process_groups() - self.mock_api.get.return_value = { - 1: {'state': 'on'}, 2: {'state': 'off'}} - self.mock_bridge.lightgroups = { - 1: self.build_mock_light(self.mock_bridge, 1, 'foo')} - - with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ - as mock_dispatcher_send: - ret = hue_light.process_groups( - self.hass, self.mock_api, self.mock_bridge, None) - - self.assertEqual(len(ret), 1) - mock_hue_light.assert_has_calls([ - call( - 2, {'state': 'off'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue, True), - ]) - mock_dispatcher_send.assert_called_once_with( - 'hue_light_callback_bridge-id_1') - self.assertEqual(len(self.mock_bridge.lightgroups), 2) + }, + "2": { + "name": "Group 2", + "lights": [ + "3", + "4", + "5" + ], + "type": "LightGroup", + "action": { + "on": True, + "bri": 153, + "hue": 4345, + "sat": 254, + "effect": "none", + "xy": [ + 0.5, + 0.5 + ], + "ct": 250, + "alert": "select", + "colormode": "ct" + }, + "state": { + "any_on": True, + "all_on": False, + } + } +} +LIGHT_1_ON = { + "state": { + "on": True, + "bri": 144, + "hue": 13088, + "sat": 212, + "xy": [0.5128, 0.4147], + "ct": 467, + "alert": "none", + "effect": "none", + "colormode": "xy", + "reachable": True + }, + "type": "Extended color light", + "name": "Hue Lamp 1", + "modelid": "LCT001", + "swversion": "66009461", + "manufacturername": "Philips", + "uniqueid": "456", +} +LIGHT_1_OFF = { + "state": { + "on": False, + "bri": 0, + "hue": 0, + "sat": 0, + "xy": [0, 0], + "ct": 0, + "alert": "none", + "effect": "none", + "colormode": "xy", + "reachable": True + }, + "type": "Extended color light", + "name": "Hue Lamp 1", + "modelid": "LCT001", + "swversion": "66009461", + "manufacturername": "Philips", + "uniqueid": "456", +} +LIGHT_2_OFF = { + "state": { + "on": False, + "bri": 0, + "hue": 0, + "sat": 0, + "xy": [0, 0], + "ct": 0, + "alert": "none", + "effect": "none", + "colormode": "hs", + "reachable": True + }, + "type": "Extended color light", + "name": "Hue Lamp 2", + "modelid": "LCT001", + "swversion": "66009461", + "manufacturername": "Philips", + "uniqueid": "123", +} +LIGHT_2_ON = { + "state": { + "on": True, + "bri": 100, + "hue": 13088, + "sat": 210, + "xy": [.5, .4], + "ct": 420, + "alert": "none", + "effect": "none", + "colormode": "hs", + "reachable": True + }, + "type": "Extended color light", + "name": "Hue Lamp 2 new", + "modelid": "LCT001", + "swversion": "66009461", + "manufacturername": "Philips", + "uniqueid": "123", +} +LIGHT_RESPONSE = { + "1": LIGHT_1_ON, + "2": LIGHT_2_OFF, +} -class TestHueLight(unittest.TestCase): - """Test the HueLight class.""" +@pytest.fixture +def mock_bridge(hass): + """Mock a Hue bridge.""" + bridge = Mock(available=True, allow_groups=False, host='1.1.1.1') + bridge.mock_requests = [] + # We're using a deque so we can schedule multiple responses + # and also means that `popleft()` will blow up if we get more updates + # than expected. + bridge.mock_light_responses = deque() + bridge.mock_group_responses = deque() - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.skip_teardown_stop = False + async def mock_request(method, path, **kwargs): + kwargs['method'] = method + kwargs['path'] = path + bridge.mock_requests.append(kwargs) - self.light_id = 42 - self.mock_info = MagicMock() - self.mock_bridge = MagicMock() - self.mock_update_lights = MagicMock() - self.mock_allow_unreachable = MagicMock() - self.mock_is_group = MagicMock() - self.mock_allow_in_emulated_hue = MagicMock() - self.mock_is_group = False + if path == 'lights': + return bridge.mock_light_responses.popleft() + elif path == 'groups': + return bridge.mock_group_responses.popleft() + return None - def tearDown(self): - """Stop everything that was started.""" - if not self.skip_teardown_stop: - self.hass.stop() + bridge.api.config.apiversion = '9.9.9' + bridge.api.lights = Lights({}, mock_request) + bridge.api.groups = Groups({}, mock_request) - def buildLight( - self, light_id=None, info=None, update_lights=None, is_group=None): - """Helper to build a HueLight object with minimal fuss.""" - if 'state' not in info: - on_key = 'any_on' if is_group is not None else 'on' - info['state'] = {on_key: False} + return bridge - return hue_light.HueLight( - light_id if light_id is not None else self.light_id, - info if info is not None else self.mock_info, - self.mock_bridge, - (update_lights - if update_lights is not None - else self.mock_update_lights), - self.mock_allow_unreachable, self.mock_allow_in_emulated_hue, - is_group if is_group is not None else self.mock_is_group) - def test_unique_id_for_light(self): - """Test the unique_id method with lights.""" - light = self.buildLight(info={'uniqueid': 'foobar'}) - self.assertEqual('foobar', light.unique_id) +async def setup_bridge(hass, mock_bridge): + """Load the Hue light platform with the provided bridge.""" + hass.config.components.add(hue.DOMAIN) + hass.data[hue.DOMAIN] = {'mock-host': mock_bridge} + await hass.helpers.discovery.async_load_platform('light', 'hue', { + 'host': 'mock-host' + }) + await hass.async_block_till_done() - light = self.buildLight(info={}) - self.assertIsNone(light.unique_id) - def test_unique_id_for_group(self): - """Test the unique_id method with groups.""" - light = self.buildLight(info={'uniqueid': 'foobar'}, is_group=True) - self.assertEqual('foobar', light.unique_id) +async def test_not_load_groups_if_old_bridge(hass, mock_bridge): + """Test that we don't try to load gorups if bridge runs old software.""" + mock_bridge.api.config.apiversion = '1.12.0' + mock_bridge.mock_light_responses.append({}) + mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + assert len(hass.states.async_all()) == 0 - light = self.buildLight(info={}, is_group=True) - self.assertIsNone(light.unique_id) + +async def test_no_lights_or_groups(hass, mock_bridge): + """Test the update_lights function when no lights are found.""" + mock_bridge.allow_groups = True + mock_bridge.mock_light_responses.append({}) + mock_bridge.mock_group_responses.append({}) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 2 + assert len(hass.states.async_all()) == 0 + + +async def test_lights(hass, mock_bridge): + """Test the update_lights function with some lights.""" + mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + # 1 All Lights group, 2 lights + assert len(hass.states.async_all()) == 3 + + lamp_1 = hass.states.get('light.hue_lamp_1') + assert lamp_1 is not None + assert lamp_1.state == 'on' + assert lamp_1.attributes['brightness'] == 144 + assert lamp_1.attributes['color_temp'] == 467 + + lamp_2 = hass.states.get('light.hue_lamp_2') + assert lamp_2 is not None + assert lamp_2.state == 'off' + + +async def test_groups(hass, mock_bridge): + """Test the update_lights function with some lights.""" + mock_bridge.allow_groups = True + mock_bridge.mock_light_responses.append({}) + mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 2 + # 1 all lights group, 2 hue group lights + assert len(hass.states.async_all()) == 3 + + lamp_1 = hass.states.get('light.group_1') + assert lamp_1 is not None + assert lamp_1.state == 'on' + assert lamp_1.attributes['brightness'] == 254 + assert lamp_1.attributes['color_temp'] == 250 + + lamp_2 = hass.states.get('light.group_2') + assert lamp_2 is not None + assert lamp_2.state == 'on' + + +async def test_new_group_discovered(hass, mock_bridge): + """Test if 2nd update has a new group.""" + mock_bridge.allow_groups = True + mock_bridge.mock_light_responses.append({}) + mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 2 + assert len(hass.states.async_all()) == 3 + + new_group_response = dict(GROUP_RESPONSE) + new_group_response['3'] = { + "name": "Group 3", + "lights": [ + "3", + "4", + "5" + ], + "type": "LightGroup", + "action": { + "on": True, + "bri": 153, + "hue": 4345, + "sat": 254, + "effect": "none", + "xy": [ + 0.5, + 0.5 + ], + "ct": 250, + "alert": "select", + "colormode": "ct" + }, + "state": { + "any_on": True, + "all_on": False, + } + } + + mock_bridge.mock_light_responses.append({}) + mock_bridge.mock_group_responses.append(new_group_response) + + # Calling a service will trigger the updates to run + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.group_1' + }, blocking=True) + # 2x group update, 2x light update, 1 turn on request + assert len(mock_bridge.mock_requests) == 5 + assert len(hass.states.async_all()) == 4 + + new_group = hass.states.get('light.group_3') + assert new_group is not None + assert new_group.state == 'on' + assert new_group.attributes['brightness'] == 153 + assert new_group.attributes['color_temp'] == 250 + + +async def test_new_light_discovered(hass, mock_bridge): + """Test if 2nd update has a new light.""" + mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) + + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + assert len(hass.states.async_all()) == 3 + + new_light_response = dict(LIGHT_RESPONSE) + new_light_response['3'] = { + "state": { + "on": False, + "bri": 0, + "hue": 0, + "sat": 0, + "xy": [0, 0], + "ct": 0, + "alert": "none", + "effect": "none", + "colormode": "hs", + "reachable": True + }, + "type": "Extended color light", + "name": "Hue Lamp 3", + "modelid": "LCT001", + "swversion": "66009461", + "manufacturername": "Philips", + "uniqueid": "789", + } + + mock_bridge.mock_light_responses.append(new_light_response) + + # Calling a service will trigger the updates to run + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.hue_lamp_1' + }, blocking=True) + # 2x light update, 1 turn on request + assert len(mock_bridge.mock_requests) == 3 + assert len(hass.states.async_all()) == 4 + + light = hass.states.get('light.hue_lamp_3') + assert light is not None + assert light.state == 'off' + + +async def test_other_group_update(hass, mock_bridge): + """Test changing one group that will impact the state of other light.""" + mock_bridge.allow_groups = True + mock_bridge.mock_light_responses.append({}) + mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 2 + assert len(hass.states.async_all()) == 3 + + group_2 = hass.states.get('light.group_2') + assert group_2 is not None + assert group_2.name == 'Group 2' + assert group_2.state == 'on' + assert group_2.attributes['brightness'] == 153 + assert group_2.attributes['color_temp'] == 250 + + updated_group_response = dict(GROUP_RESPONSE) + updated_group_response['2'] = { + "name": "Group 2 new", + "lights": [ + "3", + "4", + "5" + ], + "type": "LightGroup", + "action": { + "on": False, + "bri": 0, + "hue": 0, + "sat": 0, + "effect": "none", + "xy": [ + 0, + 0 + ], + "ct": 0, + "alert": "none", + "colormode": "ct" + }, + "state": { + "any_on": False, + "all_on": False, + } + } + + mock_bridge.mock_light_responses.append({}) + mock_bridge.mock_group_responses.append(updated_group_response) + + # Calling a service will trigger the updates to run + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.group_1' + }, blocking=True) + # 2x group update, 2x light update, 1 turn on request + assert len(mock_bridge.mock_requests) == 5 + assert len(hass.states.async_all()) == 3 + + group_2 = hass.states.get('light.group_2') + assert group_2 is not None + assert group_2.name == 'Group 2 new' + assert group_2.state == 'off' + + +async def test_other_light_update(hass, mock_bridge): + """Test changing one light that will impact state of other light.""" + mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) + + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + assert len(hass.states.async_all()) == 3 + + lamp_2 = hass.states.get('light.hue_lamp_2') + assert lamp_2 is not None + assert lamp_2.name == 'Hue Lamp 2' + assert lamp_2.state == 'off' + + updated_light_response = dict(LIGHT_RESPONSE) + updated_light_response['2'] = { + "state": { + "on": True, + "bri": 100, + "hue": 13088, + "sat": 210, + "xy": [.5, .4], + "ct": 420, + "alert": "none", + "effect": "none", + "colormode": "hs", + "reachable": True + }, + "type": "Extended color light", + "name": "Hue Lamp 2 new", + "modelid": "LCT001", + "swversion": "66009461", + "manufacturername": "Philips", + "uniqueid": "123", + } + + mock_bridge.mock_light_responses.append(updated_light_response) + + # Calling a service will trigger the updates to run + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.hue_lamp_1' + }, blocking=True) + # 2x light update, 1 turn on request + assert len(mock_bridge.mock_requests) == 3 + assert len(hass.states.async_all()) == 3 + + lamp_2 = hass.states.get('light.hue_lamp_2') + assert lamp_2 is not None + assert lamp_2.name == 'Hue Lamp 2 new' + assert lamp_2.state == 'on' + assert lamp_2.attributes['brightness'] == 100 + + +async def test_update_timeout(hass, mock_bridge): + """Test bridge marked as not available if timeout error during update.""" + mock_bridge.api.lights.update = Mock(side_effect=asyncio.TimeoutError) + mock_bridge.api.groups.update = Mock(side_effect=asyncio.TimeoutError) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 0 + assert len(hass.states.async_all()) == 0 + assert mock_bridge.available is False + + +async def test_update_unauthorized(hass, mock_bridge): + """Test bridge marked as not available if unauthorized during update.""" + mock_bridge.api.lights.update = Mock(side_effect=aiohue.Unauthorized) + mock_bridge.api.groups.update = Mock(side_effect=aiohue.Unauthorized) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 0 + assert len(hass.states.async_all()) == 0 + assert mock_bridge.available is False + + +async def test_light_turn_on_service(hass, mock_bridge): + """Test calling the turn on service on a light.""" + mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) + await setup_bridge(hass, mock_bridge) + light = hass.states.get('light.hue_lamp_2') + assert light is not None + assert light.state == 'off' + + updated_light_response = dict(LIGHT_RESPONSE) + updated_light_response['2'] = LIGHT_2_ON + + mock_bridge.mock_light_responses.append(updated_light_response) + + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.hue_lamp_2', + 'brightness': 100, + 'color_temp': 300, + }, blocking=True) + # 2x light update, 1 turn on request + assert len(mock_bridge.mock_requests) == 3 + + assert mock_bridge.mock_requests[1]['json'] == { + 'bri': 100, + 'on': True, + 'ct': 300, + 'effect': 'none', + 'alert': 'none', + } + + assert len(hass.states.async_all()) == 3 + + light = hass.states.get('light.hue_lamp_2') + assert light is not None + assert light.state == 'on' + + +async def test_light_turn_off_service(hass, mock_bridge): + """Test calling the turn on service on a light.""" + mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) + await setup_bridge(hass, mock_bridge) + light = hass.states.get('light.hue_lamp_1') + assert light is not None + assert light.state == 'on' + + updated_light_response = dict(LIGHT_RESPONSE) + updated_light_response['1'] = LIGHT_1_OFF + + mock_bridge.mock_light_responses.append(updated_light_response) + + await hass.services.async_call('light', 'turn_off', { + 'entity_id': 'light.hue_lamp_1', + }, blocking=True) + # 2x light update, 1 turn on request + assert len(mock_bridge.mock_requests) == 3 + + assert mock_bridge.mock_requests[1]['json'] == { + 'on': False, + 'alert': 'none', + } + + assert len(hass.states.async_all()) == 3 + + light = hass.states.get('light.hue_lamp_1') + assert light is not None + assert light.state == 'off' def test_available(): """Test available property.""" light = hue_light.HueLight( - info={'state': {'reachable': False}}, - allow_unreachable=False, + light=Mock(state={'reachable': False}), + request_bridge_update=None, + bridge=Mock(allow_unreachable=False), is_group=False, - - light_id=None, - bridge=mock.Mock(), - update_lights_cb=None, - allow_in_emulated_hue=False, ) assert light.available is False light = hue_light.HueLight( - info={'state': {'reachable': False}}, - allow_unreachable=True, + light=Mock(state={'reachable': False}), + request_bridge_update=None, + bridge=Mock(allow_unreachable=True), is_group=False, - - light_id=None, - bridge=mock.Mock(), - update_lights_cb=None, - allow_in_emulated_hue=False, ) assert light.available is True light = hue_light.HueLight( - info={'state': {'reachable': False}}, - allow_unreachable=False, + light=Mock(state={'reachable': False}), + request_bridge_update=None, + bridge=Mock(allow_unreachable=False), is_group=True, - - light_id=None, - bridge=mock.Mock(), - update_lights_cb=None, - allow_in_emulated_hue=False, ) assert light.available is True diff --git a/tests/components/test_hue.py b/tests/components/test_hue.py deleted file mode 100644 index 78f8b573666..00000000000 --- a/tests/components/test_hue.py +++ /dev/null @@ -1,588 +0,0 @@ -"""Generic Philips Hue component tests.""" -import asyncio -import logging -import unittest -from unittest.mock import call, MagicMock, patch - -import aiohue -import pytest -import voluptuous as vol - -from homeassistant.components import configurator, hue -from homeassistant.const import CONF_FILENAME, CONF_HOST -from homeassistant.setup import setup_component, async_setup_component - -from tests.common import ( - assert_setup_component, get_test_home_assistant, get_test_config_dir, - MockDependency, MockConfigEntry, mock_coro -) - -_LOGGER = logging.getLogger(__name__) - - -class TestSetup(unittest.TestCase): - """Test the Hue component.""" - - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.skip_teardown_stop = False - - def tearDown(self): - """Stop everything that was started.""" - if not self.skip_teardown_stop: - self.hass.stop() - - @MockDependency('phue') - def test_setup_no_domain(self, mock_phue): - """If it's not in the config we won't even try.""" - with assert_setup_component(0): - self.assertTrue(setup_component( - self.hass, hue.DOMAIN, {})) - mock_phue.Bridge.assert_not_called() - self.assertEqual({}, self.hass.data[hue.DOMAIN]) - - @MockDependency('phue') - def test_setup_with_host(self, mock_phue): - """Host specified in the config file.""" - mock_bridge = mock_phue.Bridge - - with assert_setup_component(1): - with patch('homeassistant.helpers.discovery.load_platform') \ - as mock_load: - self.assertTrue(setup_component( - self.hass, hue.DOMAIN, - {hue.DOMAIN: {hue.CONF_BRIDGES: [ - {CONF_HOST: 'localhost'}]}})) - - mock_bridge.assert_called_once_with( - 'localhost', - config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) - mock_load.assert_called_once_with( - self.hass, 'light', hue.DOMAIN, - {'bridge_id': '127.0.0.1'}) - - self.assertTrue(hue.DOMAIN in self.hass.data) - self.assertEqual(1, len(self.hass.data[hue.DOMAIN])) - - @MockDependency('phue') - def test_setup_with_phue_conf(self, mock_phue): - """No host in the config file, but one is cached in phue.conf.""" - mock_bridge = mock_phue.Bridge - - with assert_setup_component(1): - with patch( - 'homeassistant.components.hue._find_host_from_config', - return_value='localhost'): - with patch('homeassistant.helpers.discovery.load_platform') \ - as mock_load: - self.assertTrue(setup_component( - self.hass, hue.DOMAIN, - {hue.DOMAIN: {hue.CONF_BRIDGES: [ - {CONF_FILENAME: 'phue.conf'}]}})) - - mock_bridge.assert_called_once_with( - 'localhost', - config_file_path=get_test_config_dir( - hue.PHUE_CONFIG_FILE)) - mock_load.assert_called_once_with( - self.hass, 'light', hue.DOMAIN, - {'bridge_id': '127.0.0.1'}) - - self.assertTrue(hue.DOMAIN in self.hass.data) - self.assertEqual(1, len(self.hass.data[hue.DOMAIN])) - - @MockDependency('phue') - def test_setup_with_multiple_hosts(self, mock_phue): - """Multiple hosts specified in the config file.""" - mock_bridge = mock_phue.Bridge - - with assert_setup_component(1): - with patch('homeassistant.helpers.discovery.load_platform') \ - as mock_load: - self.assertTrue(setup_component( - self.hass, hue.DOMAIN, - {hue.DOMAIN: {hue.CONF_BRIDGES: [ - {CONF_HOST: 'localhost'}, - {CONF_HOST: '192.168.0.1'}]}})) - - mock_bridge.assert_has_calls([ - call( - 'localhost', - config_file_path=get_test_config_dir( - hue.PHUE_CONFIG_FILE)), - call( - '192.168.0.1', - config_file_path=get_test_config_dir( - hue.PHUE_CONFIG_FILE))]) - mock_load.mock_bridge.assert_not_called() - mock_load.assert_has_calls([ - call( - self.hass, 'light', hue.DOMAIN, - {'bridge_id': '127.0.0.1'}), - call( - self.hass, 'light', hue.DOMAIN, - {'bridge_id': '192.168.0.1'}), - ], any_order=True) - - self.assertTrue(hue.DOMAIN in self.hass.data) - self.assertEqual(2, len(self.hass.data[hue.DOMAIN])) - - @MockDependency('phue') - def test_bridge_discovered(self, mock_phue): - """Bridge discovery.""" - mock_bridge = mock_phue.Bridge - mock_service = MagicMock() - discovery_info = {'host': '192.168.0.10', 'serial': 'foobar'} - - with patch('homeassistant.helpers.discovery.load_platform') \ - as mock_load: - self.assertTrue(setup_component( - self.hass, hue.DOMAIN, {})) - hue.bridge_discovered(self.hass, mock_service, discovery_info) - - mock_bridge.assert_called_once_with( - '192.168.0.10', - config_file_path=get_test_config_dir('phue-foobar.conf')) - mock_load.assert_called_once_with( - self.hass, 'light', hue.DOMAIN, - {'bridge_id': '192.168.0.10'}) - - self.assertTrue(hue.DOMAIN in self.hass.data) - self.assertEqual(1, len(self.hass.data[hue.DOMAIN])) - - @MockDependency('phue') - def test_bridge_configure_and_discovered(self, mock_phue): - """Bridge is in the config file, then we discover it.""" - mock_bridge = mock_phue.Bridge - mock_service = MagicMock() - discovery_info = {'host': '192.168.1.10', 'serial': 'foobar'} - - with assert_setup_component(1): - with patch('homeassistant.helpers.discovery.load_platform') \ - as mock_load: - # First we set up the component from config - self.assertTrue(setup_component( - self.hass, hue.DOMAIN, - {hue.DOMAIN: {hue.CONF_BRIDGES: [ - {CONF_HOST: '192.168.1.10'}]}})) - - mock_bridge.assert_called_once_with( - '192.168.1.10', - config_file_path=get_test_config_dir( - hue.PHUE_CONFIG_FILE)) - calls_to_mock_load = [ - call( - self.hass, 'light', hue.DOMAIN, - {'bridge_id': '192.168.1.10'}), - ] - mock_load.assert_has_calls(calls_to_mock_load) - - self.assertTrue(hue.DOMAIN in self.hass.data) - self.assertEqual(1, len(self.hass.data[hue.DOMAIN])) - - # Then we discover the same bridge - hue.bridge_discovered(self.hass, mock_service, discovery_info) - - # No additional calls - mock_bridge.assert_called_once_with( - '192.168.1.10', - config_file_path=get_test_config_dir( - hue.PHUE_CONFIG_FILE)) - mock_load.assert_has_calls(calls_to_mock_load) - - # Still only one - self.assertTrue(hue.DOMAIN in self.hass.data) - self.assertEqual(1, len(self.hass.data[hue.DOMAIN])) - - -class TestHueBridge(unittest.TestCase): - """Test the HueBridge class.""" - - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.data[hue.DOMAIN] = {} - self.skip_teardown_stop = False - - def tearDown(self): - """Stop everything that was started.""" - if not self.skip_teardown_stop: - self.hass.stop() - - @MockDependency('phue') - def test_setup_bridge_connection_refused(self, mock_phue): - """Test a registration failed with a connection refused exception.""" - mock_bridge = mock_phue.Bridge - mock_bridge.side_effect = ConnectionRefusedError() - - bridge = hue.HueBridge( - 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) - bridge.setup() - self.assertFalse(bridge.configured) - self.assertTrue(bridge.config_request_id is None) - - mock_bridge.assert_called_once_with( - 'localhost', - config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) - - @MockDependency('phue') - def test_setup_bridge_registration_exception(self, mock_phue): - """Test a registration failed with an exception.""" - mock_bridge = mock_phue.Bridge - mock_phue.PhueRegistrationException = Exception - mock_bridge.side_effect = mock_phue.PhueRegistrationException(1, 2) - - bridge = hue.HueBridge( - 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) - bridge.setup() - self.assertFalse(bridge.configured) - self.assertFalse(bridge.config_request_id is None) - self.assertTrue(isinstance(bridge.config_request_id, str)) - - mock_bridge.assert_called_once_with( - 'localhost', - config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) - - @MockDependency('phue') - def test_setup_bridge_registration_succeeds(self, mock_phue): - """Test a registration success sequence.""" - mock_bridge = mock_phue.Bridge - mock_phue.PhueRegistrationException = Exception - mock_bridge.side_effect = [ - # First call, raise because not registered - mock_phue.PhueRegistrationException(1, 2), - # Second call, registration is done - None, - ] - - bridge = hue.HueBridge( - 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) - bridge.setup() - self.assertFalse(bridge.configured) - self.assertFalse(bridge.config_request_id is None) - - # Simulate the user confirming the registration - self.hass.services.call( - configurator.DOMAIN, configurator.SERVICE_CONFIGURE, - {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) - - self.hass.block_till_done() - self.assertTrue(bridge.configured) - self.assertTrue(bridge.config_request_id is None) - - # We should see a total of two identical calls - args = call( - 'localhost', - config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) - mock_bridge.assert_has_calls([args, args]) - - # Make sure the request is done - self.assertEqual(1, len(self.hass.states.all())) - self.assertEqual('configured', self.hass.states.all()[0].state) - - @MockDependency('phue') - def test_setup_bridge_registration_fails(self, mock_phue): - """ - Test a registration failure sequence. - - This may happen when we start the registration process, the user - responds to the request but the bridge has become unreachable. - """ - mock_bridge = mock_phue.Bridge - mock_phue.PhueRegistrationException = Exception - mock_bridge.side_effect = [ - # First call, raise because not registered - mock_phue.PhueRegistrationException(1, 2), - # Second call, the bridge has gone away - ConnectionRefusedError(), - ] - - bridge = hue.HueBridge( - 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) - bridge.setup() - self.assertFalse(bridge.configured) - self.assertFalse(bridge.config_request_id is None) - - # Simulate the user confirming the registration - self.hass.services.call( - configurator.DOMAIN, configurator.SERVICE_CONFIGURE, - {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) - - self.hass.block_till_done() - self.assertFalse(bridge.configured) - self.assertFalse(bridge.config_request_id is None) - - # We should see a total of two identical calls - args = call( - 'localhost', - config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) - mock_bridge.assert_has_calls([args, args]) - - # The request should still be pending - self.assertEqual(1, len(self.hass.states.all())) - self.assertEqual('configure', self.hass.states.all()[0].state) - - @MockDependency('phue') - def test_setup_bridge_registration_retry(self, mock_phue): - """ - Test a registration retry sequence. - - This may happen when we start the registration process, the user - responds to the request but we fail to confirm it with the bridge. - """ - mock_bridge = mock_phue.Bridge - mock_phue.PhueRegistrationException = Exception - mock_bridge.side_effect = [ - # First call, raise because not registered - mock_phue.PhueRegistrationException(1, 2), - # Second call, for whatever reason authentication fails - mock_phue.PhueRegistrationException(1, 2), - ] - - bridge = hue.HueBridge( - 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) - bridge.setup() - self.assertFalse(bridge.configured) - self.assertFalse(bridge.config_request_id is None) - - # Simulate the user confirming the registration - self.hass.services.call( - configurator.DOMAIN, configurator.SERVICE_CONFIGURE, - {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) - - self.hass.block_till_done() - self.assertFalse(bridge.configured) - self.assertFalse(bridge.config_request_id is None) - - # We should see a total of two identical calls - args = call( - 'localhost', - config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) - mock_bridge.assert_has_calls([args, args]) - - # Make sure the request is done - self.assertEqual(1, len(self.hass.states.all())) - self.assertEqual('configure', self.hass.states.all()[0].state) - self.assertEqual( - 'Failed to register, please try again.', - self.hass.states.all()[0].attributes.get(configurator.ATTR_ERRORS)) - - @MockDependency('phue') - def test_hue_activate_scene(self, mock_phue): - """Test the hue_activate_scene service.""" - with patch('homeassistant.helpers.discovery.load_platform'): - bridge = hue.HueBridge('localhost', self.hass, - hue.PHUE_CONFIG_FILE, None) - bridge.setup() - - # No args - self.hass.services.call(hue.DOMAIN, hue.SERVICE_HUE_SCENE, - blocking=True) - bridge.bridge.run_scene.assert_not_called() - - # Only one arg - self.hass.services.call( - hue.DOMAIN, hue.SERVICE_HUE_SCENE, - {hue.ATTR_GROUP_NAME: 'group'}, - blocking=True) - bridge.bridge.run_scene.assert_not_called() - - self.hass.services.call( - hue.DOMAIN, hue.SERVICE_HUE_SCENE, - {hue.ATTR_SCENE_NAME: 'scene'}, - blocking=True) - bridge.bridge.run_scene.assert_not_called() - - # Both required args - self.hass.services.call( - hue.DOMAIN, hue.SERVICE_HUE_SCENE, - {hue.ATTR_GROUP_NAME: 'group', hue.ATTR_SCENE_NAME: 'scene'}, - blocking=True) - bridge.bridge.run_scene.assert_called_once_with('group', 'scene') - - -async def test_setup_no_host(hass, requests_mock): - """No host specified in any way.""" - requests_mock.get(hue.API_NUPNP, json=[]) - with MockDependency('phue') as mock_phue: - result = await async_setup_component( - hass, hue.DOMAIN, {hue.DOMAIN: {}}) - assert result - - mock_phue.Bridge.assert_not_called() - - assert hass.data[hue.DOMAIN] == {} - - -async def test_flow_works(hass, aioclient_mock): - """Test config flow .""" - aioclient_mock.get(hue.API_NUPNP, json=[ - {'internalipaddress': '1.2.3.4', 'id': 'bla'} - ]) - - flow = hue.HueFlowHandler() - flow.hass = hass - await flow.async_step_init() - - with patch('aiohue.Bridge') as mock_bridge: - def mock_constructor(host, websession): - mock_bridge.host = host - return mock_bridge - - 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() - - 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 - - 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' - } - - -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() - flow.hass = hass - - result = await flow.async_step_init() - assert result['type'] == 'abort' - - -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=[ - {'internalipaddress': '1.2.3.4', 'id': 'bla'} - ]) - MockConfigEntry(domain='hue', data={ - 'host': '1.2.3.4' - }).add_to_hass(hass) - flow = hue.HueFlowHandler() - flow.hass = hass - - result = await flow.async_step_init() - assert result['type'] == 'abort' - - -async def test_flow_one_bridge_discovered(hass, aioclient_mock): - """Test config flow discovers one bridge.""" - aioclient_mock.get(hue.API_NUPNP, json=[ - {'internalipaddress': '1.2.3.4', 'id': 'bla'} - ]) - flow = hue.HueFlowHandler() - flow.hass = hass - - result = await flow.async_step_init() - assert result['type'] == 'form' - assert result['step_id'] == 'link' - - -async def test_flow_two_bridges_discovered(hass, aioclient_mock): - """Test config flow discovers two bridges.""" - aioclient_mock.get(hue.API_NUPNP, json=[ - {'internalipaddress': '1.2.3.4', 'id': 'bla'}, - {'internalipaddress': '5.6.7.8', 'id': 'beer'} - ]) - flow = hue.HueFlowHandler() - flow.hass = hass - - result = await flow.async_step_init() - assert result['type'] == 'form' - assert result['step_id'] == 'init' - - with pytest.raises(vol.Invalid): - assert result['data_schema']({'host': '0.0.0.0'}) - - result['data_schema']({'host': '1.2.3.4'}) - result['data_schema']({'host': '5.6.7.8'}) - - -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=[ - {'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.hass = hass - - result = await flow.async_step_init() - assert result['type'] == 'form' - assert result['step_id'] == 'link' - assert flow.host == '5.6.7.8' - - -async def test_flow_timeout_discovery(hass): - """Test config flow .""" - flow = hue.HueFlowHandler() - flow.hass = hass - - with patch('aiohue.discovery.discover_nupnp', - side_effect=asyncio.TimeoutError): - result = await flow.async_step_init() - - assert result['type'] == 'abort' - - -async def test_flow_link_timeout(hass): - """Test config flow .""" - flow = hue.HueFlowHandler() - flow.hass = hass - - with patch('aiohue.Bridge.create_user', - side_effect=asyncio.TimeoutError): - result = await flow.async_step_link({}) - - assert result['type'] == 'form' - assert result['step_id'] == 'link' - assert result['errors'] == { - 'base': 'register_failed' - } - - -async def test_flow_link_button_not_pressed(hass): - """Test config flow .""" - flow = hue.HueFlowHandler() - flow.hass = hass - - with patch('aiohue.Bridge.create_user', - side_effect=aiohue.LinkButtonNotPressed): - result = await flow.async_step_link({}) - - assert result['type'] == 'form' - assert result['step_id'] == 'link' - assert result['errors'] == { - 'base': 'register_failed' - } - - -async def test_flow_link_unknown_host(hass): - """Test config flow .""" - flow = hue.HueFlowHandler() - flow.hass = hass - - with patch('aiohue.Bridge.create_user', - side_effect=aiohue.RequestError): - result = await flow.async_step_link({}) - - assert result['type'] == 'form' - assert result['step_id'] == 'link' - assert result['errors'] == { - 'base': 'register_failed' - } diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index cdbf91d09e5..30c9d3ba489 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -17,7 +17,7 @@ from homeassistant.setup import setup_component import pytest from tests.common import ( - get_test_home_assistant, async_fire_time_changed) + get_test_home_assistant, async_fire_time_changed, mock_coro) from tests.mock.zwave import MockNetwork, MockNode, MockValue, MockEntityValues @@ -468,6 +468,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): @patch.object(zwave, 'discovery') def test_entity_discovery(self, discovery, get_platform): """Test the creation of a new entity.""" + discovery.async_load_platform.return_value = mock_coro() mock_platform = MagicMock() get_platform.return_value = mock_platform mock_device = MagicMock() @@ -500,8 +501,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): key=lambda a: id(a))) assert discovery.async_load_platform.called - # Second call is to async yield from - assert len(discovery.async_load_platform.mock_calls) == 2 + assert len(discovery.async_load_platform.mock_calls) == 1 args = discovery.async_load_platform.mock_calls[0][1] assert args[0] == self.hass assert args[1] == 'mock_component' @@ -532,6 +532,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): @patch.object(zwave, 'discovery') def test_entity_existing_values(self, discovery, get_platform): """Test the loading of already discovered values.""" + discovery.async_load_platform.return_value = mock_coro() mock_platform = MagicMock() get_platform.return_value = mock_platform mock_device = MagicMock() @@ -563,8 +564,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): key=lambda a: id(a))) assert discovery.async_load_platform.called - # Second call is to async yield from - assert len(discovery.async_load_platform.mock_calls) == 2 + assert len(discovery.async_load_platform.mock_calls) == 1 args = discovery.async_load_platform.mock_calls[0][1] assert args[0] == self.hass assert args[1] == 'mock_component' @@ -599,6 +599,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): @patch.object(zwave, 'discovery') def test_entity_workaround_component(self, discovery, get_platform): """Test ignore workaround.""" + discovery.async_load_platform.return_value = mock_coro() mock_platform = MagicMock() get_platform.return_value = mock_platform mock_device = MagicMock() @@ -629,8 +630,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): self.hass.block_till_done() assert discovery.async_load_platform.called - # Second call is to async yield from - assert len(discovery.async_load_platform.mock_calls) == 2 + assert len(discovery.async_load_platform.mock_calls) == 1 args = discovery.async_load_platform.mock_calls[0][1] assert args[1] == 'binary_sensor' diff --git a/tests/helpers/test_discovery.py b/tests/helpers/test_discovery.py index 2087dc2adb5..b345400ba17 100644 --- a/tests/helpers/test_discovery.py +++ b/tests/helpers/test_discovery.py @@ -1,5 +1,4 @@ """Test discovery helpers.""" -import asyncio from unittest.mock import patch import pytest @@ -24,7 +23,8 @@ class TestHelpersDiscovery: """Stop everything that was started.""" self.hass.stop() - @patch('homeassistant.setup.async_setup_component') + @patch('homeassistant.setup.async_setup_component', + return_value=mock_coro()) def test_listen(self, mock_setup_component): """Test discovery listen/discover combo.""" helpers = self.hass.helpers @@ -199,15 +199,13 @@ class TestHelpersDiscovery: assert len(component_calls) == 1 -@asyncio.coroutine -def test_load_platform_forbids_config(): +async def test_load_platform_forbids_config(): """Test you cannot setup config component with load_platform.""" with pytest.raises(HomeAssistantError): - yield from discovery.async_load_platform(None, 'config', 'zwave') + await discovery.async_load_platform(None, 'config', 'zwave') -@asyncio.coroutine -def test_discover_forbids_config(): +async def test_discover_forbids_config(): """Test you cannot setup config component with load_platform.""" with pytest.raises(HomeAssistantError): - yield from discovery.async_discover(None, None, None, 'config') + await discovery.async_discover(None, None, None, 'config') diff --git a/tests/util/test_init.py b/tests/util/test_init.py index 5493843c246..60b0e68ca59 100644 --- a/tests/util/test_init.py +++ b/tests/util/test_init.py @@ -291,3 +291,11 @@ async def test_throttle_async(): assert (await test_method()) is True assert (await test_method()) is None + + @util.Throttle(timedelta(seconds=2), timedelta(seconds=0.1)) + async def test_method2(): + """Only first call should return a value.""" + return True + + assert (await test_method2()) is True + assert (await test_method2()) is None