From 3853182ccf371fafeded60e81fe92d2897333af2 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sun, 27 Nov 2022 21:01:58 +0100 Subject: [PATCH] Add deconz_relative_rotary event for Hue Tap Dial (#82727) --- homeassistant/components/deconz/const.py | 3 + .../components/deconz/deconz_event.py | 45 +++++++- .../components/deconz/device_trigger.py | 3 +- homeassistant/components/deconz/gateway.py | 14 ++- tests/components/deconz/test_deconz_event.py | 103 ++++++++++++++++++ 5 files changed, 162 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index 6070f83871f..ca38edf0625 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -57,3 +57,6 @@ POWER_PLUGS = [ CONF_ANGLE = "angle" CONF_GESTURE = "gesture" + +ATTR_DURATION = "duration" +ATTR_ROTATION = "rotation" diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py index 35e1ba79948..9bde87e5e17 100644 --- a/homeassistant/components/deconz/deconz_event.py +++ b/homeassistant/components/deconz/deconz_event.py @@ -10,6 +10,7 @@ from pydeconz.models.sensor.ancillary_control import ( AncillaryControlAction, ) from pydeconz.models.sensor.presence import Presence, PresenceStatePresenceEvent +from pydeconz.models.sensor.relative_rotary import RelativeRotary, RelativeRotaryEvent from pydeconz.models.sensor.switch import Switch from homeassistant.const import ( @@ -23,13 +24,14 @@ from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.util import slugify -from .const import CONF_ANGLE, CONF_GESTURE, LOGGER +from .const import ATTR_DURATION, ATTR_ROTATION, CONF_ANGLE, CONF_GESTURE, LOGGER from .deconz_device import DeconzBase from .gateway import DeconzGateway CONF_DECONZ_EVENT = "deconz_event" CONF_DECONZ_ALARM_EVENT = "deconz_alarm_event" CONF_DECONZ_PRESENCE_EVENT = "deconz_presence_event" +CONF_DECONZ_RELATIVE_ROTARY_EVENT = "deconz_relative_rotary_event" SUPPORTED_DECONZ_ALARM_EVENTS = { AncillaryControlAction.EMERGENCY, @@ -47,6 +49,10 @@ SUPPORTED_DECONZ_PRESENCE_EVENTS = { PresenceStatePresenceEvent.APPROACHING, PresenceStatePresenceEvent.ABSENTING, } +RELATIVE_ROTARY_DECONZ_TO_EVENT = { + RelativeRotaryEvent.NEW: "new", + RelativeRotaryEvent.REPEAT: "repeat", +} async def async_setup_events(gateway: DeconzGateway) -> None: @@ -55,7 +61,7 @@ async def async_setup_events(gateway: DeconzGateway) -> None: @callback def async_add_sensor(_: EventType, sensor_id: str) -> None: """Create DeconzEvent.""" - new_event: DeconzAlarmEvent | DeconzEvent | DeconzPresenceEvent + new_event: DeconzAlarmEvent | DeconzEvent | DeconzPresenceEvent | DeconzRelativeRotaryEvent sensor = gateway.api.sensors[sensor_id] if isinstance(sensor, Switch): @@ -69,6 +75,9 @@ async def async_setup_events(gateway: DeconzGateway) -> None: return new_event = DeconzPresenceEvent(sensor, gateway) + elif isinstance(sensor, RelativeRotary): + new_event = DeconzRelativeRotaryEvent(sensor, gateway) + gateway.hass.async_create_task(new_event.async_update_device_registry()) gateway.events.append(new_event) @@ -84,6 +93,10 @@ async def async_setup_events(gateway: DeconzGateway) -> None: async_add_sensor, gateway.api.sensors.presence, ) + gateway.register_platform_add_device_callback( + async_add_sensor, + gateway.api.sensors.relative_rotary, + ) @callback @@ -104,7 +117,7 @@ class DeconzEventBase(DeconzBase): def __init__( self, - device: AncillaryControl | Presence | Switch, + device: AncillaryControl | Presence | RelativeRotary | Switch, gateway: DeconzGateway, ) -> None: """Register callback that will be used for signals.""" @@ -227,3 +240,29 @@ class DeconzPresenceEvent(DeconzEventBase): } self.gateway.hass.bus.async_fire(CONF_DECONZ_PRESENCE_EVENT, data) + + +class DeconzRelativeRotaryEvent(DeconzEventBase): + """Relative rotary event.""" + + _device: RelativeRotary + + @callback + def async_update_callback(self) -> None: + """Fire the event if reason is new action is updated.""" + if ( + self.gateway.ignore_state_updates + or "rotaryevent" not in self._device.changed_keys + ): + return + + data = { + CONF_ID: self.event_id, + CONF_UNIQUE_ID: self.serial, + CONF_DEVICE_ID: self.device_id, + CONF_EVENT: RELATIVE_ROTARY_DECONZ_TO_EVENT[self._device.rotary_event], + ATTR_ROTATION: self._device.expected_rotation, + ATTR_DURATION: self._device.expected_event_duration, + } + + self.gateway.hass.bus.async_fire(CONF_DECONZ_RELATIVE_ROTARY_EVENT, data) diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index 8c63a47f59c..76844b026ce 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -28,6 +28,7 @@ from .deconz_event import ( DeconzAlarmEvent, DeconzEvent, DeconzPresenceEvent, + DeconzRelativeRotaryEvent, ) from .gateway import DeconzGateway @@ -635,7 +636,7 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( def _get_deconz_event_from_device( hass: HomeAssistant, device: dr.DeviceEntry, -) -> DeconzAlarmEvent | DeconzEvent | DeconzPresenceEvent: +) -> DeconzAlarmEvent | DeconzEvent | DeconzPresenceEvent | DeconzRelativeRotaryEvent: """Resolve deconz event from device.""" gateways: dict[str, DeconzGateway] = hass.data.get(DOMAIN, {}) for gateway in gateways.values(): diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 1c381bc194a..c1b8139e6f7 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -41,7 +41,12 @@ from .const import ( from .errors import AuthenticationRequired, CannotConnect if TYPE_CHECKING: - from .deconz_event import DeconzAlarmEvent, DeconzEvent, DeconzPresenceEvent + from .deconz_event import ( + DeconzAlarmEvent, + DeconzEvent, + DeconzPresenceEvent, + DeconzRelativeRotaryEvent, + ) SENSORS = ( sensors.SensorResourceManager, @@ -93,7 +98,12 @@ class DeconzGateway: self.deconz_ids: dict[str, str] = {} self.entities: dict[str, set[str]] = {} - self.events: list[DeconzAlarmEvent | DeconzEvent | DeconzPresenceEvent] = [] + self.events: list[ + DeconzAlarmEvent + | DeconzEvent + | DeconzPresenceEvent + | DeconzRelativeRotaryEvent + ] = [] self.clip_sensors: set[tuple[Callable[[EventType, str], None], str]] = set() self.deconz_groups: set[tuple[Callable[[EventType, str], None], str]] = set() self.ignored_devices: set[tuple[Callable[[EventType, str], None], str]] = set() diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py index 0d99d33e571..597d9282136 100644 --- a/tests/components/deconz/test_deconz_event.py +++ b/tests/components/deconz/test_deconz_event.py @@ -10,9 +10,13 @@ from pydeconz.models.sensor.presence import PresenceStatePresenceEvent from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN from homeassistant.components.deconz.deconz_event import ( + ATTR_DURATION, + ATTR_ROTATION, CONF_DECONZ_ALARM_EVENT, CONF_DECONZ_EVENT, CONF_DECONZ_PRESENCE_EVENT, + CONF_DECONZ_RELATIVE_ROTARY_EVENT, + RELATIVE_ROTARY_DECONZ_TO_EVENT, ) from homeassistant.const import ( CONF_DEVICE_ID, @@ -515,6 +519,105 @@ async def test_deconz_presence_events(hass, aioclient_mock, mock_deconz_websocke assert len(hass.states.async_all()) == 0 +async def test_deconz_relative_rotary_events( + hass, aioclient_mock, mock_deconz_websocket +): + """Test successful creation of deconz relative rotary events.""" + data = { + "sensors": { + "1": { + "config": { + "battery": 100, + "on": True, + "reachable": True, + }, + "etag": "463728970bdb7d04048fc4373654f45a", + "lastannounced": "2022-07-03T13:57:59Z", + "lastseen": "2022-07-03T14:02Z", + "manufacturername": "Signify Netherlands B.V.", + "modelid": "RDM002", + "name": "RDM002 44", + "state": { + "expectedeventduration": 400, + "expectedrotation": 75, + "lastupdated": "2022-07-03T11:37:49.586", + "rotaryevent": 2, + }, + "swversion": "2.59.19", + "type": "ZHARelativeRotary", + "uniqueid": "xx:xx:xx:xx:xx:xx:xx:xx-14-fc00", + } + } + } + with patch.dict(DECONZ_WEB_REQUEST, data): + config_entry = await setup_deconz_integration(hass, aioclient_mock) + + device_registry = dr.async_get(hass) + + assert len(hass.states.async_all()) == 1 + assert ( + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) + == 3 + ) + + device = device_registry.async_get_device( + identifiers={(DECONZ_DOMAIN, "xx:xx:xx:xx:xx:xx:xx:xx")} + ) + + captured_events = async_capture_events(hass, CONF_DECONZ_RELATIVE_ROTARY_EVENT) + + for rotary_event, duration, rotation in ((1, 100, 50), (2, 200, -50)): + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "1", + "state": { + "rotaryevent": rotary_event, + "expectedeventduration": duration, + "expectedrotation": rotation, + }, + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert len(captured_events) == 1 + assert captured_events[0].data == { + CONF_ID: "rdm002_44", + CONF_UNIQUE_ID: "xx:xx:xx:xx:xx:xx:xx:xx", + CONF_DEVICE_ID: device.id, + CONF_EVENT: RELATIVE_ROTARY_DECONZ_TO_EVENT[rotary_event], + ATTR_DURATION: duration, + ATTR_ROTATION: rotation, + } + captured_events.clear() + + # Unsupported relative rotary event + + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "1", + "name": "123", + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert len(captured_events) == 0 + + await hass.config_entries.async_unload(config_entry.entry_id) + + states = hass.states.async_all() + assert len(hass.states.async_all()) == 1 + for state in states: + assert state.state == STATE_UNAVAILABLE + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + async def test_deconz_events_bad_unique_id(hass, aioclient_mock): """Verify no devices are created if unique id is bad or missing.""" data = {