diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py new file mode 100644 index 00000000000..d60750721ac --- /dev/null +++ b/homeassistant/components/hue/binary_sensor.py @@ -0,0 +1,27 @@ +"""Hue binary sensor entities.""" +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.hue.sensor_base import ( + GenericZLLSensor, async_setup_entry as shared_async_setup_entry) + + +PRESENCE_NAME_FORMAT = "{} presence" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer binary sensor setup to the shared sensor module.""" + await shared_async_setup_entry( + hass, config_entry, async_add_entities, binary=True) + + +class HuePresence(GenericZLLSensor, BinarySensorDevice): + """The presence sensor entity for a Hue motion sensor device.""" + + device_class = 'presence' + + async def _async_update_ha_state(self, *args, **kwargs): + await self.async_update_ha_state(self, *args, **kwargs) + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.sensor.presence diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 9e99d219316..25db031e6bf 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -69,6 +69,10 @@ class HueBridge: hass.async_create_task(hass.config_entries.async_forward_entry_setup( self.config_entry, 'light')) + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + self.config_entry, 'binary_sensor')) + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + self.config_entry, 'sensor')) hass.services.async_register( DOMAIN, SERVICE_HUE_SCENE, self.hue_activate_scene, @@ -94,8 +98,16 @@ class HueBridge: # If setup was successful, we set api variable, forwarded entry and # register service - return await self.hass.config_entries.async_forward_entry_unload( - self.config_entry, 'light') + results = await asyncio.gather( + self.hass.config_entries.async_forward_entry_unload( + self.config_entry, 'light'), + self.hass.config_entries.async_forward_entry_unload( + self.config_entry, 'binary_sensor'), + self.hass.config_entries.async_forward_entry_unload( + self.config_entry, 'sensor') + ) + # None and True are OK + return False not in results async def hue_activate_scene(self, call, updated=False): """Service to call directly into bridge to set scenes.""" diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py new file mode 100644 index 00000000000..555c16a0be7 --- /dev/null +++ b/homeassistant/components/hue/sensor.py @@ -0,0 +1,57 @@ +"""Hue sensor entities.""" +from homeassistant.const import ( + DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS) +from homeassistant.helpers.entity import Entity +from homeassistant.components.hue.sensor_base import ( + GenericZLLSensor, async_setup_entry as shared_async_setup_entry) + + +LIGHT_LEVEL_NAME_FORMAT = "{} light level" +TEMPERATURE_NAME_FORMAT = "{} temperature" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer sensor setup to the shared sensor module.""" + await shared_async_setup_entry( + hass, config_entry, async_add_entities, binary=False) + + +class GenericHueGaugeSensorEntity(GenericZLLSensor, Entity): + """Parent class for all 'gauge' Hue device sensors.""" + + async def _async_update_ha_state(self, *args, **kwargs): + await self.async_update_ha_state(self, *args, **kwargs) + + +class HueLightLevel(GenericHueGaugeSensorEntity): + """The light level sensor entity for a Hue motion sensor device.""" + + device_class = DEVICE_CLASS_ILLUMINANCE + unit_of_measurement = "Lux" + + @property + def state(self): + """Return the state of the device.""" + return self.sensor.lightlevel + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = super().device_state_attributes + attributes.update({ + "threshold_dark": self.sensor.tholddark, + "threshold_offset": self.sensor.tholdoffset, + }) + return attributes + + +class HueTemperature(GenericHueGaugeSensorEntity): + """The temperature sensor entity for a Hue motion sensor device.""" + + device_class = DEVICE_CLASS_TEMPERATURE + unit_of_measurement = TEMP_CELSIUS + + @property + def state(self): + """Return the state of the device.""" + return self.sensor.temperature / 100 diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py new file mode 100644 index 00000000000..1d6fa2d34b4 --- /dev/null +++ b/homeassistant/components/hue/sensor_base.py @@ -0,0 +1,283 @@ +"""Support for the Philips Hue sensors as a platform.""" +import asyncio +from datetime import timedelta +import logging +from time import monotonic + +import async_timeout + +from homeassistant.components import hue +from homeassistant.exceptions import NoEntitySpecifiedError +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util.dt import utcnow + + +CURRENT_SENSORS = 'current_sensors' +SENSOR_MANAGER = 'sensor_manager' + +_LOGGER = logging.getLogger(__name__) + + +def _device_id(aiohue_sensor): + # Work out the shared device ID, as described below + device_id = aiohue_sensor.uniqueid + if device_id and len(device_id) > 23: + device_id = device_id[:23] + return device_id + + +async def async_setup_entry(hass, config_entry, async_add_entities, + binary=False): + """Set up the Hue sensors from a config entry.""" + bridge = hass.data[hue.DOMAIN][config_entry.data['host']] + hass.data[hue.DOMAIN].setdefault(CURRENT_SENSORS, {}) + + manager = hass.data[hue.DOMAIN].get(SENSOR_MANAGER) + if manager is None: + manager = SensorManager(hass, bridge) + hass.data[hue.DOMAIN][SENSOR_MANAGER] = manager + + manager.register_component(binary, async_add_entities) + await manager.start() + + +class SensorManager: + """Class that handles registering and updating Hue sensor entities. + + Intended to be a singleton. + """ + + SCAN_INTERVAL = timedelta(seconds=5) + sensor_config_map = {} + + def __init__(self, hass, bridge): + """Initialize the sensor manager.""" + import aiohue + from .binary_sensor import HuePresence, PRESENCE_NAME_FORMAT + from .sensor import ( + HueLightLevel, HueTemperature, LIGHT_LEVEL_NAME_FORMAT, + TEMPERATURE_NAME_FORMAT) + + self.hass = hass + self.bridge = bridge + self._component_add_entities = {} + self._started = False + + self.sensor_config_map.update({ + aiohue.sensors.TYPE_ZLL_LIGHTLEVEL: { + "binary": False, + "name_format": LIGHT_LEVEL_NAME_FORMAT, + "class": HueLightLevel, + }, + aiohue.sensors.TYPE_ZLL_TEMPERATURE: { + "binary": False, + "name_format": TEMPERATURE_NAME_FORMAT, + "class": HueTemperature, + }, + aiohue.sensors.TYPE_ZLL_PRESENCE: { + "binary": True, + "name_format": PRESENCE_NAME_FORMAT, + "class": HuePresence, + }, + }) + + def register_component(self, binary, async_add_entities): + """Register async_add_entities methods for components.""" + self._component_add_entities[binary] = async_add_entities + + async def start(self): + """Start updating sensors from the bridge on a schedule.""" + # but only if it's not already started, and when we've got both + # async_add_entities methods + if self._started or len(self._component_add_entities) < 2: + return + + self._started = True + _LOGGER.info('Starting sensor polling loop with %s second interval', + self.SCAN_INTERVAL.total_seconds()) + + async def async_update_bridge(now): + """Will update sensors from the bridge.""" + await self.async_update_items() + + async_track_point_in_utc_time( + self.hass, async_update_bridge, utcnow() + self.SCAN_INTERVAL) + + await async_update_bridge(None) + + async def async_update_items(self): + """Update sensors from the bridge.""" + import aiohue + + api = self.bridge.api.sensors + + try: + start = monotonic() + with async_timeout.timeout(4): + await api.update() + except (asyncio.TimeoutError, aiohue.AiohueException) as err: + _LOGGER.debug('Failed to fetch sensor: %s', err) + + if not self.bridge.available: + return + + _LOGGER.error('Unable to reach bridge %s (%s)', self.bridge.host, + err) + self.bridge.available = False + + return + + finally: + _LOGGER.debug('Finished sensor request in %.3f seconds', + monotonic() - start) + + if not self.bridge.available: + _LOGGER.info('Reconnected to bridge %s', self.bridge.host) + self.bridge.available = True + + new_sensors = [] + new_binary_sensors = [] + primary_sensor_devices = {} + current = self.hass.data[hue.DOMAIN][CURRENT_SENSORS] + + # Physical Hue motion sensors present as three sensors in the API: a + # presence sensor, a temperature sensor, and a light level sensor. Of + # these, only the presence sensor is assigned the user-friendly name + # that the user has given to the device. Each of these sensors is + # linked by a common device_id, which is the first twenty-three + # characters of the unique id (then followed by a hyphen and an ID + # specific to the individual sensor). + # + # To set up neat values, and assign the sensor entities to the same + # device, we first, iterate over all the sensors and find the Hue + # presence sensors, then iterate over all the remaining sensors - + # finding the remaining ones that may or may not be related to the + # presence sensors. + for item_id in api: + if api[item_id].type != aiohue.sensors.TYPE_ZLL_PRESENCE: + continue + + primary_sensor_devices[_device_id(api[item_id])] = api[item_id] + + # Iterate again now we have all the presence sensors, and add the + # related sensors with nice names where appropriate. + for item_id in api: + existing = current.get(api[item_id].uniqueid) + if existing is not None: + self.hass.async_create_task( + existing.async_maybe_update_ha_state()) + continue + + primary_sensor = None + sensor_config = self.sensor_config_map.get(api[item_id].type) + if sensor_config is None: + continue + + base_name = api[item_id].name + primary_sensor = primary_sensor_devices.get( + _device_id(api[item_id])) + if primary_sensor is not None: + base_name = primary_sensor.name + name = sensor_config["name_format"].format(base_name) + + current[api[item_id].uniqueid] = sensor_config["class"]( + api[item_id], name, self.bridge, primary_sensor=primary_sensor) + if sensor_config['binary']: + new_binary_sensors.append(current[api[item_id].uniqueid]) + else: + new_sensors.append(current[api[item_id].uniqueid]) + + async_add_sensor_entities = self._component_add_entities.get(False) + async_add_binary_entities = self._component_add_entities.get(True) + if new_sensors and async_add_sensor_entities: + async_add_sensor_entities(new_sensors) + if new_binary_sensors and async_add_binary_entities: + async_add_binary_entities(new_binary_sensors) + + +class GenericHueSensor: + """Representation of a Hue sensor.""" + + should_poll = False + + def __init__(self, sensor, name, bridge, primary_sensor=None): + """Initialize the sensor.""" + self.sensor = sensor + self._name = name + self._primary_sensor = primary_sensor + self.bridge = bridge + + async def _async_update_ha_state(self, *args, **kwargs): + raise NotImplementedError + + @property + def primary_sensor(self): + """Return the primary sensor entity of the physical device.""" + return self._primary_sensor or self.sensor + + @property + def device_id(self): + """Return the ID of the physical device this sensor is part of.""" + return self.unique_id[:23] + + @property + def unique_id(self): + """Return the ID of this Hue sensor.""" + return self.sensor.uniqueid + + @property + def name(self): + """Return a friendly name for the sensor.""" + return self._name + + @property + def available(self): + """Return if sensor is available.""" + return self.bridge.available and (self.bridge.allow_unreachable or + self.sensor.config['reachable']) + + @property + def swupdatestate(self): + """Return detail of available software updates for this device.""" + return self.primary_sensor.raw.get('swupdate', {}).get('state') + + async def async_maybe_update_ha_state(self): + """Try to update Home Assistant with current state of entity. + + But if it's not been added to hass yet, then don't throw an error. + """ + try: + await self._async_update_ha_state() + except (RuntimeError, NoEntitySpecifiedError): + _LOGGER.debug( + "Hue sensor update requested before it has been added.") + + @property + def device_info(self): + """Return the device info. + + Links individual entities together in the hass device registry. + """ + return { + 'identifiers': { + (hue.DOMAIN, self.device_id) + }, + 'name': self.primary_sensor.name, + 'manufacturer': self.primary_sensor.manufacturername, + 'model': ( + self.primary_sensor.productname or + self.primary_sensor.modelid), + 'sw_version': self.primary_sensor.swversion, + 'via_hub': (hue.DOMAIN, self.bridge.api.config.bridgeid), + } + + +class GenericZLLSensor(GenericHueSensor): + """Representation of a Hue-brand, physical sensor.""" + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return { + "battery_level": self.sensor.battery + } diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index 855a12e2620..5b383afc53d 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -21,9 +21,13 @@ async def test_bridge_setup(): assert await hue_bridge.async_setup() is True assert hue_bridge.api is api - assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1 - assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \ - (entry, 'light') + forward_entries = set( + c[1][1] + for c in + hass.config_entries.async_forward_entry_setup.mock_calls + ) + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 3 + assert forward_entries == set(['light', 'binary_sensor', 'sensor']) async def test_bridge_setup_invalid_username(): @@ -84,11 +88,11 @@ async def test_reset_unloads_entry_if_setup(): assert await hue_bridge.async_setup() is True assert len(hass.services.async_register.mock_calls) == 1 - assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 3 hass.config_entries.async_forward_entry_unload.return_value = \ mock_coro(True) assert await hue_bridge.async_reset() - assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 1 + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 3 assert len(hass.services.async_remove.mock_calls) == 1 diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_base.py new file mode 100644 index 00000000000..99829c59666 --- /dev/null +++ b/tests/components/hue/test_sensor_base.py @@ -0,0 +1,485 @@ +"""Philips Hue sensors platform tests.""" +import asyncio +from collections import deque +import datetime +import logging +from unittest.mock import Mock + +import aiohue +from aiohue.sensors import Sensors +import pytest + +from homeassistant import config_entries +from homeassistant.components import hue +from homeassistant.components.hue import sensor_base as hue_sensor_base + +_LOGGER = logging.getLogger(__name__) + +PRESENCE_SENSOR_1_PRESENT = { + "state": { + "presence": True, + "lastupdated": "2019-01-01T01:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T00:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "ledindication": False, + "usertest": False, + "sensitivity": 2, + "sensitivitymax": 2, + "pending": [] + }, + "name": "Living room sensor", + "type": "ZLLPresence", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue motion sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:77-02-0406", + "capabilities": { + "certified": True + } +} +LIGHT_LEVEL_SENSOR_1 = { + "state": { + "lightlevel": 0, + "dark": True, + "daylight": True, + "lastupdated": "2019-01-01T01:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T00:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "tholddark": 12467, + "tholdoffset": 7000, + "ledindication": False, + "usertest": False, + "pending": [] + }, + "name": "Hue ambient light sensor 1", + "type": "ZLLLightLevel", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue ambient light sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:77-02-0400", + "capabilities": { + "certified": True + } +} +TEMPERATURE_SENSOR_1 = { + "state": { + "temperature": 1775, + "lastupdated": "2019-01-01T01:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T01:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "ledindication": False, + "usertest": False, + "pending": [] + }, + "name": "Hue temperature sensor 1", + "type": "ZLLTemperature", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue temperature sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:77-02-0402", + "capabilities": { + "certified": True + } +} +PRESENCE_SENSOR_2_NOT_PRESENT = { + "state": { + "presence": False, + "lastupdated": "2019-01-01T00:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T01:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "ledindication": False, + "usertest": False, + "sensitivity": 2, + "sensitivitymax": 2, + "pending": [] + }, + "name": "Kitchen sensor", + "type": "ZLLPresence", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue motion sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:88-02-0406", + "capabilities": { + "certified": True + } +} +LIGHT_LEVEL_SENSOR_2 = { + "state": { + "lightlevel": 100, + "dark": True, + "daylight": True, + "lastupdated": "2019-01-01T01:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T00:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "tholddark": 12467, + "tholdoffset": 7000, + "ledindication": False, + "usertest": False, + "pending": [] + }, + "name": "Hue ambient light sensor 2", + "type": "ZLLLightLevel", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue ambient light sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:88-02-0400", + "capabilities": { + "certified": True + } +} +TEMPERATURE_SENSOR_2 = { + "state": { + "temperature": 1875, + "lastupdated": "2019-01-01T01:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T01:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "ledindication": False, + "usertest": False, + "pending": [] + }, + "name": "Hue temperature sensor 2", + "type": "ZLLTemperature", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue temperature sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:88-02-0402", + "capabilities": { + "certified": True + } +} +PRESENCE_SENSOR_3_PRESENT = { + "state": { + "presence": True, + "lastupdated": "2019-01-01T01:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T00:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "ledindication": False, + "usertest": False, + "sensitivity": 2, + "sensitivitymax": 2, + "pending": [] + }, + "name": "Bedroom sensor", + "type": "ZLLPresence", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue motion sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:99-02-0406", + "capabilities": { + "certified": True + } +} +LIGHT_LEVEL_SENSOR_3 = { + "state": { + "lightlevel": 0, + "dark": True, + "daylight": True, + "lastupdated": "2019-01-01T01:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T00:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "tholddark": 12467, + "tholdoffset": 7000, + "ledindication": False, + "usertest": False, + "pending": [] + }, + "name": "Hue ambient light sensor 3", + "type": "ZLLLightLevel", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue ambient light sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:99-02-0400", + "capabilities": { + "certified": True + } +} +TEMPERATURE_SENSOR_3 = { + "state": { + "temperature": 1775, + "lastupdated": "2019-01-01T01:00:00" + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2019-01-01T01:00:00" + }, + "config": { + "on": True, + "battery": 100, + "reachable": True, + "alert": "none", + "ledindication": False, + "usertest": False, + "pending": [] + }, + "name": "Hue temperature sensor 3", + "type": "ZLLTemperature", + "modelid": "SML001", + "manufacturername": "Philips", + "productname": "Hue temperature sensor", + "swversion": "6.1.1.27575", + "uniqueid": "00:11:22:33:44:55:66:99-02-0402", + "capabilities": { + "certified": True + } +} +UNSUPPORTED_SENSOR = { + "state": { + "status": 0, + "lastupdated": "2019-01-01T01:00:00" + }, + "config": { + "on": True, + "reachable": True + }, + "name": "Unsupported sensor", + "type": "CLIPGenericStatus", + "modelid": "PHWA01", + "manufacturername": "Philips", + "swversion": "1.0", + "uniqueid": "arbitrary", + "recycle": True +} +SENSOR_RESPONSE = { + "1": PRESENCE_SENSOR_1_PRESENT, + "2": LIGHT_LEVEL_SENSOR_1, + "3": TEMPERATURE_SENSOR_1, + "4": PRESENCE_SENSOR_2_NOT_PRESENT, + "5": LIGHT_LEVEL_SENSOR_2, + "6": TEMPERATURE_SENSOR_2, +} + + +@pytest.fixture +def mock_bridge(hass): + """Mock a Hue bridge.""" + bridge = Mock( + available=True, + allow_unreachable=False, + allow_groups=False, + api=Mock(), + spec=hue.HueBridge + ) + 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_sensor_responses = deque() + + async def mock_request(method, path, **kwargs): + kwargs['method'] = method + kwargs['path'] = path + bridge.mock_requests.append(kwargs) + + if path == 'sensors': + return bridge.mock_sensor_responses.popleft() + return None + + bridge.api.config.apiversion = '9.9.9' + bridge.api.sensors = Sensors({}, mock_request) + + return bridge + + +@pytest.fixture +def increase_scan_interval(hass): + """Increase the SCAN_INTERVAL to prevent unexpected scans during tests.""" + hue_sensor_base.SensorManager.SCAN_INTERVAL = datetime.timedelta(days=365) + + +async def setup_bridge(hass, mock_bridge): + """Load the Hue platform with the provided bridge.""" + hass.config.components.add(hue.DOMAIN) + hass.data[hue.DOMAIN] = {'mock-host': mock_bridge} + config_entry = config_entries.ConfigEntry(1, hue.DOMAIN, 'Mock Title', { + 'host': 'mock-host' + }, 'test', config_entries.CONN_CLASS_LOCAL_POLL) + await hass.config_entries.async_forward_entry_setup( + config_entry, 'binary_sensor') + await hass.config_entries.async_forward_entry_setup( + config_entry, 'sensor') + # and make sure it completes before going further + await hass.async_block_till_done() + + +async def test_no_sensors(hass, mock_bridge): + """Test the update_items function when no sensors are found.""" + mock_bridge.allow_groups = True + mock_bridge.mock_sensor_responses.append({}) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + assert len(hass.states.async_all()) == 0 + + +async def test_sensors(hass, mock_bridge): + """Test the update_items function with some sensors.""" + mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + # 2 "physical" sensors with 3 virtual sensors each + assert len(hass.states.async_all()) == 6 + + presence_sensor_1 = hass.states.get( + 'binary_sensor.living_room_sensor_presence') + light_level_sensor_1 = hass.states.get( + 'sensor.living_room_sensor_light_level') + temperature_sensor_1 = hass.states.get( + 'sensor.living_room_sensor_temperature') + assert presence_sensor_1 is not None + assert presence_sensor_1.state == 'on' + assert light_level_sensor_1 is not None + assert light_level_sensor_1.state == '0' + assert light_level_sensor_1.name == 'Living room sensor light level' + assert temperature_sensor_1 is not None + assert temperature_sensor_1.state == '17.75' + assert temperature_sensor_1.name == 'Living room sensor temperature' + + presence_sensor_2 = hass.states.get( + 'binary_sensor.kitchen_sensor_presence') + light_level_sensor_2 = hass.states.get( + 'sensor.kitchen_sensor_light_level') + temperature_sensor_2 = hass.states.get( + 'sensor.kitchen_sensor_temperature') + assert presence_sensor_2 is not None + assert presence_sensor_2.state == 'off' + assert light_level_sensor_2 is not None + assert light_level_sensor_2.state == '100' + assert light_level_sensor_2.name == 'Kitchen sensor light level' + assert temperature_sensor_2 is not None + assert temperature_sensor_2.state == '18.75' + assert temperature_sensor_2.name == 'Kitchen sensor temperature' + + +async def test_unsupported_sensors(hass, mock_bridge): + """Test that unsupported sensors don't get added and don't fail.""" + response_with_unsupported = dict(SENSOR_RESPONSE) + response_with_unsupported['7'] = UNSUPPORTED_SENSOR + mock_bridge.mock_sensor_responses.append(response_with_unsupported) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + # 2 "physical" sensors with 3 virtual sensors each + assert len(hass.states.async_all()) == 6 + + +async def test_new_sensor_discovered(hass, mock_bridge): + """Test if 2nd update has a new sensor.""" + mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE) + + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + assert len(hass.states.async_all()) == 6 + + new_sensor_response = dict(SENSOR_RESPONSE) + new_sensor_response.update({ + "7": PRESENCE_SENSOR_3_PRESENT, + "8": LIGHT_LEVEL_SENSOR_3, + "9": TEMPERATURE_SENSOR_3, + }) + + mock_bridge.mock_sensor_responses.append(new_sensor_response) + + # Force updates to run again + sm = hass.data[hue.DOMAIN][hue_sensor_base.SENSOR_MANAGER] + await sm.async_update_items() + + # To flush out the service call to update the group + await hass.async_block_till_done() + + assert len(mock_bridge.mock_requests) == 2 + assert len(hass.states.async_all()) == 9 + + presence = hass.states.get('binary_sensor.bedroom_sensor_presence') + assert presence is not None + assert presence.state == 'on' + temperature = hass.states.get('sensor.bedroom_sensor_temperature') + assert temperature is not None + assert temperature.state == '17.75' + + +async def test_update_timeout(hass, mock_bridge): + """Test bridge marked as not available if timeout error during update.""" + mock_bridge.api.sensors.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.sensors.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