diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index c39fbed180c..0e1688221b3 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -29,6 +29,7 @@ HUB_BUSY_SLEEP = 0.5 PLATFORMS_v1 = [Platform.BINARY_SENSOR, Platform.LIGHT, Platform.SENSOR] PLATFORMS_v2 = [ Platform.BINARY_SENSOR, + Platform.EVENT, Platform.LIGHT, Platform.SCENE, Platform.SENSOR, diff --git a/homeassistant/components/hue/const.py b/homeassistant/components/hue/const.py index 798148b92c0..d7d254b64a8 100644 --- a/homeassistant/components/hue/const.py +++ b/homeassistant/components/hue/const.py @@ -1,4 +1,9 @@ """Constants for the Hue component.""" +from aiohue.v2.models.button import ButtonEvent +from aiohue.v2.models.relative_rotary import ( + RelativeRotaryAction, + RelativeRotaryDirection, +) DOMAIN = "hue" @@ -33,3 +38,26 @@ DEFAULT_ALLOW_UNREACHABLE = False # How long to wait to actually do the refresh after requesting it. # We wait some time so if we control multiple lights, we batch requests. REQUEST_REFRESH_DELAY = 0.3 + + +# V2 API SPECIFIC CONSTANTS ################## + +DEFAULT_BUTTON_EVENT_TYPES = ( + # I have never ever seen the `DOUBLE_SHORT_RELEASE` + # or `DOUBLE_SHORT_RELEASE` events so leave them out here + ButtonEvent.INITIAL_PRESS, + ButtonEvent.REPEAT, + ButtonEvent.SHORT_RELEASE, + ButtonEvent.LONG_RELEASE, +) + +DEFAULT_ROTARY_EVENT_TYPES = (RelativeRotaryAction.START, RelativeRotaryAction.REPEAT) +DEFAULT_ROTARY_EVENT_SUBTYPES = ( + RelativeRotaryDirection.CLOCK_WISE, + RelativeRotaryDirection.COUNTER_CLOCK_WISE, +) + +DEVICE_SPECIFIC_EVENT_TYPES = { + # device specific overrides of specific supported button events + "Hue tap switch": (ButtonEvent.INITIAL_PRESS,), +} diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py new file mode 100644 index 00000000000..8e34f7a22bf --- /dev/null +++ b/homeassistant/components/hue/event.py @@ -0,0 +1,133 @@ +"""Hue event entities from Button resources.""" +from __future__ import annotations + +from typing import Any + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.models.button import Button +from aiohue.v2.models.relative_rotary import ( + RelativeRotary, + RelativeRotaryDirection, +) + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .bridge import HueBridge +from .const import DEFAULT_BUTTON_EVENT_TYPES, DEVICE_SPECIFIC_EVENT_TYPES, DOMAIN +from .v2.entity import HueBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up event platform from Hue button resources.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + api: HueBridgeV2 = bridge.api + + if bridge.api_version == 1: + # should not happen, but just in case + raise NotImplementedError("Event support is only available for V2 bridges") + + # add entities for all button and relative rotary resources + @callback + def async_add_entity( + event_type: EventType, + resource: Button | RelativeRotary, + ) -> None: + """Add entity from Hue resource.""" + if isinstance(resource, RelativeRotary): + async_add_entities( + [HueRotaryEventEntity(bridge, api.sensors.relative_rotary, resource)] + ) + else: + async_add_entities( + [HueButtonEventEntity(bridge, api.sensors.button, resource)] + ) + + for controller in (api.sensors.button, api.sensors.relative_rotary): + # add all current items in controller + for item in controller: + async_add_entity(EventType.RESOURCE_ADDED, item) + + # register listener for new items only + config_entry.async_on_unload( + controller.subscribe( + async_add_entity, event_filter=EventType.RESOURCE_ADDED + ) + ) + + +class HueButtonEventEntity(HueBaseEntity, EventEntity): + """Representation of a Hue Event entity from a button resource.""" + + entity_description = EventEntityDescription( + key="button", + device_class=EventDeviceClass.BUTTON, + translation_key="button", + ) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the entity.""" + super().__init__(*args, **kwargs) + # fill the event types based on the features the switch supports + hue_dev_id = self.controller.get_device(self.resource.id).id + model_id = self.bridge.api.devices[hue_dev_id].product_data.product_name + event_types: list[str] = [] + for event_type in DEVICE_SPECIFIC_EVENT_TYPES.get( + model_id, DEFAULT_BUTTON_EVENT_TYPES + ): + event_types.append(event_type.value) + self._attr_event_types = event_types + + @property + def name(self) -> str: + """Return name for the entity.""" + return f"{super().name} {self.resource.metadata.control_id}" + + @callback + def _handle_event(self, event_type: EventType, resource: Button) -> None: + """Handle status event for this resource (or it's parent).""" + if event_type == EventType.RESOURCE_UPDATED and resource.id == self.resource.id: + self._trigger_event(resource.button.last_event.value) + self.async_write_ha_state() + return + super()._handle_event(event_type, resource) + + +class HueRotaryEventEntity(HueBaseEntity, EventEntity): + """Representation of a Hue Event entity from a RelativeRotary resource.""" + + entity_description = EventEntityDescription( + key="rotary", + device_class=EventDeviceClass.BUTTON, + translation_key="rotary", + event_types=[ + RelativeRotaryDirection.CLOCK_WISE.value, + RelativeRotaryDirection.COUNTER_CLOCK_WISE.value, + ], + ) + + @callback + def _handle_event(self, event_type: EventType, resource: RelativeRotary) -> None: + """Handle status event for this resource (or it's parent).""" + if event_type == EventType.RESOURCE_UPDATED and resource.id == self.resource.id: + event_key = resource.relative_rotary.last_event.rotation.direction.value + event_data = { + "duration": resource.relative_rotary.last_event.rotation.duration, + "steps": resource.relative_rotary.last_event.rotation.steps, + "action": resource.relative_rotary.last_event.action.value, + } + self._trigger_event(event_key, event_data) + self.async_write_ha_state() + return + super()._handle_event(event_type, resource) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index aef5dba1986..a6920293ac1 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -67,6 +67,34 @@ "start": "[%key:component::hue::device_automation::trigger_type::initial_press%]" } }, + "entity": { + "event": { + "button": { + "state_attributes": { + "event_type": { + "state": { + "initial_press": "Initial press", + "repeat": "Repeat", + "short_release": "Short press", + "long_release": "Long press", + "double_short_release": "Double press" + } + } + } + }, + "rotary": { + "name": "Rotary", + "state_attributes": { + "event_type": { + "state": { + "clock_wise": "Clockwise", + "counter_clock_wise": "Counter clockwise" + } + } + } + } + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/hue/v2/device_trigger.py b/homeassistant/components/hue/v2/device_trigger.py index 466b593b56a..a3027736661 100644 --- a/homeassistant/components/hue/v2/device_trigger.py +++ b/homeassistant/components/hue/v2/device_trigger.py @@ -3,11 +3,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -from aiohue.v2.models.button import ButtonEvent -from aiohue.v2.models.relative_rotary import ( - RelativeRotaryAction, - RelativeRotaryDirection, -) from aiohue.v2.models.resource import ResourceTypes import voluptuous as vol @@ -24,7 +19,15 @@ from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.typing import ConfigType -from ..const import ATTR_HUE_EVENT, CONF_SUBTYPE, DOMAIN +from ..const import ( + ATTR_HUE_EVENT, + CONF_SUBTYPE, + DEFAULT_BUTTON_EVENT_TYPES, + DEFAULT_ROTARY_EVENT_SUBTYPES, + DEFAULT_ROTARY_EVENT_TYPES, + DEVICE_SPECIFIC_EVENT_TYPES, + DOMAIN, +) if TYPE_CHECKING: from aiohue.v2 import HueBridgeV2 @@ -41,26 +44,6 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( } ) -DEFAULT_BUTTON_EVENT_TYPES = ( - # all except `DOUBLE_SHORT_RELEASE` - ButtonEvent.INITIAL_PRESS, - ButtonEvent.REPEAT, - ButtonEvent.SHORT_RELEASE, - ButtonEvent.LONG_PRESS, - ButtonEvent.LONG_RELEASE, -) - -DEFAULT_ROTARY_EVENT_TYPES = (RelativeRotaryAction.START, RelativeRotaryAction.REPEAT) -DEFAULT_ROTARY_EVENT_SUBTYPES = ( - RelativeRotaryDirection.CLOCK_WISE, - RelativeRotaryDirection.COUNTER_CLOCK_WISE, -) - -DEVICE_SPECIFIC_EVENT_TYPES = { - # device specific overrides of specific supported button events - "Hue tap switch": (ButtonEvent.INITIAL_PRESS,), -} - async def async_validate_trigger_config( bridge: HueBridge, diff --git a/tests/components/hue/const.py b/tests/components/hue/const.py index 01b9c7f84b8..415fe1324b7 100644 --- a/tests/components/hue/const.py +++ b/tests/components/hue/const.py @@ -18,6 +18,7 @@ FAKE_DEVICE = { {"rid": "fake_zigbee_connectivity_id_1", "rtype": "zigbee_connectivity"}, {"rid": "fake_temperature_sensor_id_1", "rtype": "temperature"}, {"rid": "fake_motion_sensor_id_1", "rtype": "motion"}, + {"rid": "fake_relative_rotary", "rtype": "relative_rotary"}, ], "type": "device", } @@ -95,3 +96,20 @@ FAKE_SCENE = { "auto_dynamic": False, "type": "scene", } + +FAKE_ROTARY = { + "id": "fake_relative_rotary", + "id_v1": "/sensors/1", + "owner": {"rid": "fake_device_id_1", "rtype": "device"}, + "relative_rotary": { + "last_event": { + "action": "start", + "rotation": { + "direction": "clock_wise", + "steps": 0, + "duration": 0, + }, + } + }, + "type": "relative_rotary", +} diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index a7ad7ec1a00..371975e12a5 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -475,6 +475,10 @@ { "rid": "db50a5d9-8cc7-486f-be06-c0b8f0d26c69", "rtype": "zigbee_connectivity" + }, + { + "rid": "2f029c7b-868b-49e9-aa01-a0bbc595990d", + "rtype": "relative_rotary" } ], "type": "device" diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index ef309849faa..28aa8626c42 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -51,9 +51,16 @@ async def test_bridge_setup_v2(hass: HomeAssistant, mock_api_v2) -> None: assert hue_bridge.api is mock_api_v2 assert isinstance(hue_bridge.api, HueBridgeV2) assert hue_bridge.api_version == 2 - assert len(mock_forward.mock_calls) == 5 + assert len(mock_forward.mock_calls) == 6 forward_entries = {c[1][1] for c in mock_forward.mock_calls} - assert forward_entries == {"light", "binary_sensor", "sensor", "switch", "scene"} + assert forward_entries == { + "light", + "binary_sensor", + "event", + "sensor", + "switch", + "scene", + } async def test_bridge_setup_invalid_api_key(hass: HomeAssistant) -> None: diff --git a/tests/components/hue/test_device_trigger_v2.py b/tests/components/hue/test_device_trigger_v2.py index ab400c53ee4..bfc0b612c1f 100644 --- a/tests/components/hue/test_device_trigger_v2.py +++ b/tests/components/hue/test_device_trigger_v2.py @@ -92,7 +92,6 @@ async def test_get_triggers( } for event_type in ( ButtonEvent.INITIAL_PRESS, - ButtonEvent.LONG_PRESS, ButtonEvent.LONG_RELEASE, ButtonEvent.REPEAT, ButtonEvent.SHORT_RELEASE, diff --git a/tests/components/hue/test_event.py b/tests/components/hue/test_event.py new file mode 100644 index 00000000000..e3f50318f61 --- /dev/null +++ b/tests/components/hue/test_event.py @@ -0,0 +1,100 @@ +"""Philips Hue Event platform tests for V2 bridge/api.""" +from homeassistant.components.event import ( + ATTR_EVENT_TYPE, + ATTR_EVENT_TYPES, +) +from homeassistant.core import HomeAssistant + +from .conftest import setup_platform +from .const import FAKE_DEVICE, FAKE_ROTARY, FAKE_ZIGBEE_CONNECTIVITY + + +async def test_event( + hass: HomeAssistant, mock_bridge_v2, v2_resources_test_data +) -> None: + """Test event entity for Hue integration.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, "event") + # 7 entities should be created from test data + assert len(hass.states.async_all()) == 7 + + # pick one of the remote buttons + state = hass.states.get("event.hue_dimmer_switch_with_4_controls_button_1") + assert state + assert state.state == "unknown" + assert state.name == "Hue Dimmer switch with 4 controls Button 1" + # check event_types + assert state.attributes[ATTR_EVENT_TYPES] == [ + "initial_press", + "repeat", + "short_release", + "long_release", + ] + # trigger firing 'initial_press' event from the device + btn_event = { + "button": {"last_event": "initial_press"}, + "id": "f92aa267-1387-4f02-9950-210fb7ca1f5a", + "metadata": {"control_id": 1}, + "type": "button", + } + mock_bridge_v2.api.emit_event("update", btn_event) + await hass.async_block_till_done() + state = hass.states.get("event.hue_dimmer_switch_with_4_controls_button_1") + assert state.attributes[ATTR_EVENT_TYPE] == "initial_press" + # trigger firing 'long_release' event from the device + btn_event = { + "button": {"last_event": "long_release"}, + "id": "f92aa267-1387-4f02-9950-210fb7ca1f5a", + "metadata": {"control_id": 1}, + "type": "button", + } + mock_bridge_v2.api.emit_event("update", btn_event) + await hass.async_block_till_done() + state = hass.states.get("event.hue_dimmer_switch_with_4_controls_button_1") + assert state.attributes[ATTR_EVENT_TYPE] == "long_release" + + +async def test_sensor_add_update(hass: HomeAssistant, mock_bridge_v2) -> None: + """Test Event entity for newly added Relative Rotary resource.""" + await mock_bridge_v2.api.load_test_data([FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY]) + await setup_platform(hass, mock_bridge_v2, "event") + + test_entity_id = "event.hue_mocked_device_relative_rotary" + + # verify entity does not exist before we start + assert hass.states.get(test_entity_id) is None + + # Add new fake relative_rotary entity by emitting event + mock_bridge_v2.api.emit_event("add", FAKE_ROTARY) + await hass.async_block_till_done() + + # the entity should now be available + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == "unknown" + assert state.name == "Hue mocked device Relative Rotary" + # check event_types + assert state.attributes[ATTR_EVENT_TYPES] == ["clock_wise", "counter_clock_wise"] + + # test update of entity works on incoming event + btn_event = { + "id": "fake_relative_rotary", + "relative_rotary": { + "last_event": { + "action": "repeat", + "rotation": { + "direction": "counter_clock_wise", + "steps": 60, + "duration": 400, + }, + } + }, + "type": "relative_rotary", + } + mock_bridge_v2.api.emit_event("update", btn_event) + await hass.async_block_till_done() + state = hass.states.get(test_entity_id) + assert state.attributes[ATTR_EVENT_TYPE] == "counter_clock_wise" + assert state.attributes["action"] == "repeat" + assert state.attributes["steps"] == 60 + assert state.attributes["duration"] == 400