Add Event platform/entity to Hue integration (#97256)

Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
Marcel van der Veldt 2023-07-26 16:42:01 +02:00 committed by GitHub
parent d233438e1a
commit 2ae059d4fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 330 additions and 29 deletions

View File

@ -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,

View File

@ -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,),
}

View File

@ -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)

View File

@ -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": {

View File

@ -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,

View File

@ -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",
}

View File

@ -475,6 +475,10 @@
{
"rid": "db50a5d9-8cc7-486f-be06-c0b8f0d26c69",
"rtype": "zigbee_connectivity"
},
{
"rid": "2f029c7b-868b-49e9-aa01-a0bbc595990d",
"rtype": "relative_rotary"
}
],
"type": "device"

View File

@ -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:

View File

@ -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,

View File

@ -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