diff --git a/homeassistant/components/hue/hue_event.py b/homeassistant/components/hue/hue_event.py new file mode 100644 index 00000000000..838d5ead6da --- /dev/null +++ b/homeassistant/components/hue/hue_event.py @@ -0,0 +1,93 @@ +"""Representation of a Hue remote firing events for button presses.""" +import logging + +from aiohue.sensors import TYPE_ZGP_SWITCH, TYPE_ZLL_ROTARY, TYPE_ZLL_SWITCH + +from homeassistant.const import CONF_EVENT, CONF_ID +from homeassistant.core import callback +from homeassistant.util import slugify + +from .sensor_device import GenericHueDevice + +_LOGGER = logging.getLogger(__name__) + +CONF_HUE_EVENT = "hue_event" +CONF_LAST_UPDATED = "last_updated" +CONF_UNIQUE_ID = "unique_id" + +EVENT_NAME_FORMAT = "{}" + + +class HueEvent(GenericHueDevice): + """When you want signals instead of entities. + + Stateless sensors such as remotes are expected to generate an event + instead of a sensor entity in hass. + """ + + def __init__(self, sensor, name, bridge, primary_sensor=None): + """Register callback that will be used for signals.""" + super().__init__(sensor, name, bridge, primary_sensor) + + self.event_id = slugify(self.sensor.name) + # Use the 'lastupdated' string to detect new remote presses + self._last_updated = self.sensor.lastupdated + + # Register callback in coordinator and add job to remove it on bridge reset. + self.bridge.sensor_manager.coordinator.async_add_listener( + self.async_update_callback + ) + self.bridge.reset_jobs.append(self.async_will_remove_from_hass) + _LOGGER.debug("Hue event created: %s", self.event_id) + + @callback + def async_will_remove_from_hass(self): + """Remove listener on bridge reset.""" + self.bridge.sensor_manager.coordinator.async_remove_listener( + self.async_update_callback + ) + + @callback + def async_update_callback(self): + """Fire the event if reason is that state is updated.""" + if self.sensor.lastupdated == self._last_updated: + return + + # Extract the press code as state + if hasattr(self.sensor, "rotaryevent"): + state = self.sensor.rotaryevent + else: + state = self.sensor.buttonevent + + self._last_updated = self.sensor.lastupdated + + # Fire event + data = { + CONF_ID: self.event_id, + CONF_UNIQUE_ID: self.unique_id, + CONF_EVENT: state, + CONF_LAST_UPDATED: self.sensor.lastupdated, + } + self.bridge.hass.bus.async_fire(CONF_HUE_EVENT, data) + + async def async_update_device_registry(self): + """Update device registry.""" + device_registry = ( + await self.bridge.hass.helpers.device_registry.async_get_registry() + ) + + entry = device_registry.async_get_or_create( + config_entry_id=self.bridge.config_entry.entry_id, **self.device_info + ) + _LOGGER.debug( + "Event registry with entry_id: %s and device_id: %s", + entry.id, + self.device_id, + ) + + +EVENT_CONFIG_MAP = { + TYPE_ZGP_SWITCH: {"name_format": EVENT_NAME_FORMAT, "class": HueEvent}, + TYPE_ZLL_SWITCH: {"name_format": EVENT_NAME_FORMAT, "class": HueEvent}, + TYPE_ZLL_ROTARY: {"name_format": EVENT_NAME_FORMAT, "class": HueEvent}, +} diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py index 61acd097b01..0da8e77eeee 100644 --- a/homeassistant/components/hue/sensor.py +++ b/homeassistant/components/hue/sensor.py @@ -1,17 +1,25 @@ """Hue sensor entities.""" -from aiohue.sensors import TYPE_ZLL_LIGHTLEVEL, TYPE_ZLL_TEMPERATURE +from aiohue.sensors import ( + TYPE_ZLL_LIGHTLEVEL, + TYPE_ZLL_ROTARY, + TYPE_ZLL_SWITCH, + TYPE_ZLL_TEMPERATURE, +) from homeassistant.const import ( + DEVICE_CLASS_BATTERY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.helpers.entity import Entity from .const import DOMAIN as HUE_DOMAIN -from .sensor_base import SENSOR_CONFIG_MAP, GenericZLLSensor +from .sensor_base import SENSOR_CONFIG_MAP, GenericHueSensor, GenericZLLSensor LIGHT_LEVEL_NAME_FORMAT = "{} light level" +REMOTE_NAME_FORMAT = "{} battery level" TEMPERATURE_NAME_FORMAT = "{} temperature" @@ -79,6 +87,30 @@ class HueTemperature(GenericHueGaugeSensorEntity): return self.sensor.temperature / 100 +class HueBattery(GenericHueSensor): + """Battery class for when a batt-powered device is only represented as an event.""" + + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return f"{self.sensor.uniqueid}-battery" + + @property + def state(self): + """Return the state of the battery.""" + return self.sensor.battery + + @property + def device_class(self): + """Return the class of the sensor.""" + return DEVICE_CLASS_BATTERY + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return UNIT_PERCENTAGE + + SENSOR_CONFIG_MAP.update( { TYPE_ZLL_LIGHTLEVEL: { @@ -91,5 +123,15 @@ SENSOR_CONFIG_MAP.update( "name_format": TEMPERATURE_NAME_FORMAT, "class": HueTemperature, }, + TYPE_ZLL_SWITCH: { + "platform": "sensor", + "name_format": REMOTE_NAME_FORMAT, + "class": HueBattery, + }, + TYPE_ZLL_ROTARY: { + "platform": "sensor", + "name_format": REMOTE_NAME_FORMAT, + "class": HueBattery, + }, } ) diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py index 9596d7457aa..113957d140e 100644 --- a/homeassistant/components/hue/sensor_base.py +++ b/homeassistant/components/hue/sensor_base.py @@ -10,8 +10,10 @@ from homeassistant.core import callback from homeassistant.helpers import debounce, entity from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN as HUE_DOMAIN, REQUEST_REFRESH_DELAY +from .const import REQUEST_REFRESH_DELAY from .helpers import remove_devices +from .hue_event import EVENT_CONFIG_MAP +from .sensor_device import GenericHueDevice SENSOR_CONFIG_MAP = {} _LOGGER = logging.getLogger(__name__) @@ -38,6 +40,9 @@ class SensorManager: self.bridge = bridge self._component_add_entities = {} self.current = {} + self.current_events = {} + + self._enabled_platforms = ("binary_sensor", "sensor") self.coordinator = DataUpdateCoordinator( bridge.hass, _LOGGER, @@ -66,7 +71,8 @@ class SensorManager: """Register async_add_entities methods for components.""" self._component_add_entities[platform] = async_add_entities - if len(self._component_add_entities) < 2: + if len(self._component_add_entities) < len(self._enabled_platforms): + _LOGGER.debug("Aborting start with %s, waiting for the rest", platform) return # We have all components available, start the updating. @@ -81,7 +87,7 @@ class SensorManager: """Update sensors from the bridge.""" api = self.bridge.api.sensors - if len(self._component_add_entities) < 2: + if len(self._component_add_entities) < len(self._enabled_platforms): return to_add = {} @@ -110,12 +116,24 @@ class SensorManager: # 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: + uniqueid = api[item_id].uniqueid + if current.get(uniqueid, self.current_events.get(uniqueid)) is not None: continue - primary_sensor = None - sensor_config = SENSOR_CONFIG_MAP.get(api[item_id].type) + sensor_type = api[item_id].type + + # Check for event generator devices + event_config = EVENT_CONFIG_MAP.get(sensor_type) + if event_config is not None: + base_name = api[item_id].name + name = event_config["name_format"].format(base_name) + new_event = event_config["class"](api[item_id], name, self.bridge) + self.bridge.hass.async_create_task( + new_event.async_update_device_registry() + ) + self.current_events[uniqueid] = new_event + + sensor_config = SENSOR_CONFIG_MAP.get(sensor_type) if sensor_config is None: continue @@ -125,13 +143,11 @@ class SensorManager: base_name = primary_sensor.name name = sensor_config["name_format"].format(base_name) - current[api[item_id].uniqueid] = sensor_config["class"]( + current[uniqueid] = sensor_config["class"]( api[item_id], name, self.bridge, primary_sensor=primary_sensor ) - to_add.setdefault(sensor_config["platform"], []).append( - current[api[item_id].uniqueid] - ) + to_add.setdefault(sensor_config["platform"], []).append(current[uniqueid]) self.bridge.hass.async_create_task( remove_devices( @@ -143,53 +159,23 @@ class SensorManager: self._component_add_entities[platform](to_add[platform]) -class GenericHueSensor(entity.Entity): +class GenericHueSensor(GenericHueDevice, entity.Entity): """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.sensor_manager.coordinator.last_update_success and ( - self.bridge.allow_unreachable or self.sensor.config["reachable"] + self.bridge.allow_unreachable + # remotes like Hue Tap (ZGPSwitchSensor) have no _reachability_ + or self.sensor.config.get("reachable", True) ) - @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_added_to_hass(self): """When entity is added to hass.""" self.bridge.sensor_manager.coordinator.async_add_listener( @@ -209,21 +195,6 @@ class GenericHueSensor(entity.Entity): """ await self.bridge.sensor_manager.coordinator.async_request_refresh() - @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_device": (HUE_DOMAIN, self.bridge.api.config.bridgeid), - } - class GenericZLLSensor(GenericHueSensor): """Representation of a Hue-brand, physical sensor.""" diff --git a/homeassistant/components/hue/sensor_device.py b/homeassistant/components/hue/sensor_device.py new file mode 100644 index 00000000000..91719debeb5 --- /dev/null +++ b/homeassistant/components/hue/sensor_device.py @@ -0,0 +1,53 @@ +"""Support for the Philips Hue sensor devices.""" +from .const import DOMAIN as HUE_DOMAIN + + +class GenericHueDevice: + """Representation of a Hue device.""" + + 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 + + @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 swupdatestate(self): + """Return detail of available software updates for this device.""" + return self.primary_sensor.raw.get("swupdate", {}).get("state") + + @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_device": (HUE_DOMAIN, self.bridge.api.config.bridgeid), + } diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_base.py index ca83da725fa..cf1a4ab7983 100644 --- a/tests/components/hue/test_sensor_base.py +++ b/tests/components/hue/test_sensor_base.py @@ -11,6 +11,7 @@ import pytest from homeassistant import config_entries from homeassistant.components import hue from homeassistant.components.hue import sensor_base as hue_sensor_base +from homeassistant.components.hue.hue_event import CONF_HUE_EVENT _LOGGER = logging.getLogger(__name__) @@ -241,6 +242,33 @@ UNSUPPORTED_SENSOR = { "uniqueid": "arbitrary", "recycle": True, } +HUE_TAP_REMOTE_1 = { + "state": {"buttonevent": 17, "lastupdated": "2019-06-22T14:43:50"}, + "swupdate": {"state": "notupdatable", "lastinstall": None}, + "config": {"on": True}, + "name": "Hue Tap", + "type": "ZGPSwitch", + "modelid": "ZGPSWITCH", + "manufacturername": "Philips", + "productname": "Hue tap switch", + "diversityid": "d8cde5d5-0eef-4b95-b0f0-71ddd2952af4", + "uniqueid": "00:00:00:00:00:44:23:08-f2", + "capabilities": {"certified": True, "primary": True, "inputs": []}, +} +HUE_DIMMER_REMOTE_1 = { + "state": {"buttonevent": 4002, "lastupdated": "2019-12-28T21:58:02"}, + "swupdate": {"state": "noupdates", "lastinstall": "2019-10-13T13:16:15"}, + "config": {"on": True, "battery": 100, "reachable": True, "pending": []}, + "name": "Hue dimmer switch 1", + "type": "ZLLSwitch", + "modelid": "RWL021", + "manufacturername": "Philips", + "productname": "Hue dimmer switch", + "diversityid": "73bbabea-3420-499a-9856-46bf437e119b", + "swversion": "6.1.1.28573", + "uniqueid": "00:17:88:01:10:3e:3a:dc-02-fc00", + "capabilities": {"certified": True, "primary": True, "inputs": []}, +} SENSOR_RESPONSE = { "1": PRESENCE_SENSOR_1_PRESENT, "2": LIGHT_LEVEL_SENSOR_1, @@ -248,6 +276,8 @@ SENSOR_RESPONSE = { "4": PRESENCE_SENSOR_2_NOT_PRESENT, "5": LIGHT_LEVEL_SENSOR_2, "6": TEMPERATURE_SENSOR_2, + "7": HUE_TAP_REMOTE_1, + "8": HUE_DIMMER_REMOTE_1, } @@ -341,8 +371,8 @@ async def test_sensors_with_multiple_bridges(hass, mock_bridge): assert len(mock_bridge.mock_requests) == 1 assert len(mock_bridge_2.mock_requests) == 1 - # 3 "physical" sensors with 3 virtual sensors each - assert len(hass.states.async_all()) == 9 + # 3 "physical" sensors with 3 virtual sensors each + 1 battery sensor + assert len(hass.states.async_all()) == 10 async def test_sensors(hass, mock_bridge): @@ -351,7 +381,7 @@ async def test_sensors(hass, mock_bridge): 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 + assert len(hass.states.async_all()) == 7 presence_sensor_1 = hass.states.get("binary_sensor.living_room_sensor_motion") light_level_sensor_1 = hass.states.get("sensor.living_room_sensor_light_level") @@ -377,6 +407,11 @@ async def test_sensors(hass, mock_bridge): assert temperature_sensor_2.state == "18.75" assert temperature_sensor_2.name == "Kitchen sensor temperature" + battery_remote_1 = hass.states.get("sensor.hue_dimmer_switch_1_battery_level") + assert battery_remote_1 is not None + assert battery_remote_1.state == "100" + assert battery_remote_1.name == "Hue dimmer switch 1 battery level" + async def test_unsupported_sensors(hass, mock_bridge): """Test that unsupported sensors don't get added and don't fail.""" @@ -385,8 +420,8 @@ async def test_unsupported_sensors(hass, mock_bridge): 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 + # 2 "physical" sensors with 3 virtual sensors each + 1 battery sensor + assert len(hass.states.async_all()) == 7 async def test_new_sensor_discovered(hass, mock_bridge): @@ -395,14 +430,14 @@ async def test_new_sensor_discovered(hass, mock_bridge): await setup_bridge(hass, mock_bridge) assert len(mock_bridge.mock_requests) == 1 - assert len(hass.states.async_all()) == 6 + assert len(hass.states.async_all()) == 7 new_sensor_response = dict(SENSOR_RESPONSE) new_sensor_response.update( { - "7": PRESENCE_SENSOR_3_PRESENT, - "8": LIGHT_LEVEL_SENSOR_3, - "9": TEMPERATURE_SENSOR_3, + "9": PRESENCE_SENSOR_3_PRESENT, + "10": LIGHT_LEVEL_SENSOR_3, + "11": TEMPERATURE_SENSOR_3, } ) @@ -413,7 +448,7 @@ async def test_new_sensor_discovered(hass, mock_bridge): await hass.async_block_till_done() assert len(mock_bridge.mock_requests) == 2 - assert len(hass.states.async_all()) == 9 + assert len(hass.states.async_all()) == 10 presence = hass.states.get("binary_sensor.bedroom_sensor_motion") assert presence is not None @@ -429,7 +464,7 @@ async def test_sensor_removed(hass, mock_bridge): await setup_bridge(hass, mock_bridge) assert len(mock_bridge.mock_requests) == 1 - assert len(hass.states.async_all()) == 6 + assert len(hass.states.async_all()) == 7 mock_bridge.mock_sensor_responses.clear() keys = ("1", "2", "3") @@ -466,3 +501,121 @@ async def test_update_unauthorized(hass, mock_bridge): assert len(mock_bridge.mock_requests) == 0 assert len(hass.states.async_all()) == 0 assert len(mock_bridge.handle_unauthorized_error.mock_calls) == 1 + + +async def test_hue_events(hass, mock_bridge): + """Test that hue remotes fire events when pressed.""" + mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE) + + mock_listener = Mock() + unsub = hass.bus.async_listen(CONF_HUE_EVENT, mock_listener) + + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + assert len(hass.states.async_all()) == 7 + assert len(mock_listener.mock_calls) == 0 + + new_sensor_response = dict(SENSOR_RESPONSE) + new_sensor_response["7"]["state"] = { + "buttonevent": 18, + "lastupdated": "2019-12-28T22:58:02", + } + mock_bridge.mock_sensor_responses.append(new_sensor_response) + + # Force updates to run again + await mock_bridge.sensor_manager.coordinator.async_refresh() + await hass.async_block_till_done() + + assert len(mock_bridge.mock_requests) == 2 + assert len(hass.states.async_all()) == 7 + assert len(mock_listener.mock_calls) == 1 + assert mock_listener.mock_calls[0][1][0].data == { + "id": "hue_tap", + "unique_id": "00:00:00:00:00:44:23:08-f2", + "event": 18, + "last_updated": "2019-12-28T22:58:02", + } + + new_sensor_response = dict(new_sensor_response) + new_sensor_response["8"]["state"] = { + "buttonevent": 3002, + "lastupdated": "2019-12-28T22:58:01", + } + mock_bridge.mock_sensor_responses.append(new_sensor_response) + + # Force updates to run again + await mock_bridge.sensor_manager.coordinator.async_refresh() + await hass.async_block_till_done() + + assert len(mock_bridge.mock_requests) == 3 + assert len(hass.states.async_all()) == 7 + assert len(mock_listener.mock_calls) == 2 + assert mock_listener.mock_calls[1][1][0].data == { + "id": "hue_dimmer_switch_1", + "unique_id": "00:17:88:01:10:3e:3a:dc-02-fc00", + "event": 3002, + "last_updated": "2019-12-28T22:58:01", + } + + # Add a new remote. In discovery the new event is registered **but not fired** + new_sensor_response = dict(new_sensor_response) + new_sensor_response["21"] = { + "state": { + "rotaryevent": 2, + "expectedrotation": 208, + "expectedeventduration": 400, + "lastupdated": "2020-01-31T15:56:19", + }, + "swupdate": {"state": "noupdates", "lastinstall": "2019-11-26T03:35:21"}, + "config": {"on": True, "battery": 100, "reachable": True, "pending": []}, + "name": "Lutron Aurora 1", + "type": "ZLLRelativeRotary", + "modelid": "Z3-1BRL", + "manufacturername": "Lutron", + "productname": "Lutron Aurora", + "diversityid": "2c3a75ff-55c4-4e4d-8c44-82d330b8eb9b", + "swversion": "3.4", + "uniqueid": "ff:ff:00:0f:e7:fd:bc:b7-01-fc00-0014", + "capabilities": { + "certified": True, + "primary": True, + "inputs": [ + { + "repeatintervals": [400], + "events": [ + {"rotaryevent": 1, "eventtype": "start"}, + {"rotaryevent": 2, "eventtype": "repeat"}, + ], + } + ], + }, + } + mock_bridge.mock_sensor_responses.append(new_sensor_response) + + # Force updates to run again + await mock_bridge.sensor_manager.coordinator.async_refresh() + await hass.async_block_till_done() + + assert len(mock_bridge.mock_requests) == 4 + assert len(hass.states.async_all()) == 8 + assert len(mock_listener.mock_calls) == 2 + + # A new press fires the event + new_sensor_response["21"]["state"]["lastupdated"] = "2020-01-31T15:57:19" + mock_bridge.mock_sensor_responses.append(new_sensor_response) + + # Force updates to run again + await mock_bridge.sensor_manager.coordinator.async_refresh() + await hass.async_block_till_done() + + assert len(mock_bridge.mock_requests) == 5 + assert len(hass.states.async_all()) == 8 + assert len(mock_listener.mock_calls) == 3 + assert mock_listener.mock_calls[2][1][0].data == { + "id": "lutron_aurora_1", + "unique_id": "ff:ff:00:0f:e7:fd:bc:b7-01-fc00-0014", + "event": 2, + "last_updated": "2020-01-31T15:57:19", + } + + unsub()