From 71aaf2d809c2165db166aeded0a240ac16f97b67 Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Wed, 1 Apr 2020 20:42:22 +0200 Subject: [PATCH] Add device triggers for Hue remotes (#33476) * Store device_registry entry id in HueEvent so it can be retrieved with that key when using device triggers * Add device_trigger for hue_event from hue remotes * supporting Hue dimmer switch & Hue Tap * State mapping and strings are copied from deCONZ * refactor mock_bridge for hue tests and also share `setup_bridge_for_sensors` for test_sensor_base and test_device_trigger. * Add tests for device triggers with hue remotes * Remove some triggers --- .../components/hue/.translations/en.json | 17 ++ .../components/hue/device_trigger.py | 149 +++++++++++++++ homeassistant/components/hue/hue_event.py | 4 +- homeassistant/components/hue/strings.json | 19 +- tests/components/hue/conftest.py | 86 ++++++++- tests/components/hue/test_device_trigger.py | 169 ++++++++++++++++++ tests/components/hue/test_light.py | 46 ----- tests/components/hue/test_sensor_base.py | 73 +------- 8 files changed, 443 insertions(+), 120 deletions(-) create mode 100644 homeassistant/components/hue/device_trigger.py create mode 100644 tests/components/hue/test_device_trigger.py diff --git a/homeassistant/components/hue/.translations/en.json b/homeassistant/components/hue/.translations/en.json index 350360285af..b16213bfbf8 100644 --- a/homeassistant/components/hue/.translations/en.json +++ b/homeassistant/components/hue/.translations/en.json @@ -27,5 +27,22 @@ } }, "title": "Philips Hue" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "First button", + "button_2": "Second button", + "button_3": "Third button", + "button_4": "Fourth button", + "dim_down": "Dim down", + "dim_up": "Dim up", + "turn_off": "Turn off", + "turn_on": "Turn on" + }, + "trigger_type": { + "remote_button_long_release": "\"{subtype}\" button released after long press", + "remote_button_short_press": "\"{subtype}\" button pressed", + "remote_button_short_release": "\"{subtype}\" button released" + } } } \ No newline at end of file diff --git a/homeassistant/components/hue/device_trigger.py b/homeassistant/components/hue/device_trigger.py new file mode 100644 index 00000000000..81877654746 --- /dev/null +++ b/homeassistant/components/hue/device_trigger.py @@ -0,0 +1,149 @@ +"""Provides device automations for Philips Hue events.""" +import logging + +import voluptuous as vol + +import homeassistant.components.automation.event as event +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_EVENT, + CONF_PLATFORM, + CONF_TYPE, +) + +from . import DOMAIN +from .hue_event import CONF_HUE_EVENT, CONF_UNIQUE_ID + +_LOGGER = logging.getLogger(__file__) + +CONF_SUBTYPE = "subtype" + +CONF_SHORT_PRESS = "remote_button_short_press" +CONF_SHORT_RELEASE = "remote_button_short_release" +CONF_LONG_RELEASE = "remote_button_long_release" + +CONF_TURN_ON = "turn_on" +CONF_TURN_OFF = "turn_off" +CONF_DIM_UP = "dim_up" +CONF_DIM_DOWN = "dim_down" +CONF_BUTTON_1 = "button_1" +CONF_BUTTON_2 = "button_2" +CONF_BUTTON_3 = "button_3" +CONF_BUTTON_4 = "button_4" + + +HUE_DIMMER_REMOTE_MODEL = "Hue dimmer switch" # RWL020/021 +HUE_DIMMER_REMOTE = { + (CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, + (CONF_SHORT_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2002}, + (CONF_LONG_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2003}, + (CONF_SHORT_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3002}, + (CONF_LONG_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3003}, + (CONF_SHORT_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 4002}, + (CONF_LONG_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 4003}, +} + +HUE_TAP_REMOTE_MODEL = "Hue tap switch" # ZGPSWITCH +HUE_TAP_REMOTE = { + (CONF_SHORT_PRESS, CONF_BUTTON_1): {CONF_EVENT: 34}, + (CONF_SHORT_PRESS, CONF_BUTTON_2): {CONF_EVENT: 16}, + (CONF_SHORT_PRESS, CONF_BUTTON_3): {CONF_EVENT: 17}, + (CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 18}, +} + +REMOTES = { + HUE_DIMMER_REMOTE_MODEL: HUE_DIMMER_REMOTE, + HUE_TAP_REMOTE_MODEL: HUE_TAP_REMOTE, +} + +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str} +) + + +def _get_hue_event_from_device_id(hass, device_id): + """Resolve hue event from device id.""" + for bridge in hass.data.get(DOMAIN, {}).values(): + for hue_event in bridge.sensor_manager.current_events.values(): + if device_id == hue_event.device_registry_id: + return hue_event + + return None + + +async def async_validate_trigger_config(hass, config): + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get(config[CONF_DEVICE_ID]) + + trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) + + if ( + not device + or device.model not in REMOTES + or trigger not in REMOTES[device.model] + ): + raise InvalidDeviceAutomationConfig + + return config + + +async def async_attach_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get(config[CONF_DEVICE_ID]) + + hue_event = _get_hue_event_from_device_id(hass, device.id) + if hue_event is None: + raise InvalidDeviceAutomationConfig + + trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) + + trigger = REMOTES[device.model][trigger] + + event_config = { + event.CONF_PLATFORM: "event", + event.CONF_EVENT_TYPE: CONF_HUE_EVENT, + event.CONF_EVENT_DATA: {CONF_UNIQUE_ID: hue_event.unique_id, **trigger}, + } + + event_config = event.TRIGGER_SCHEMA(event_config) + return await event.async_attach_trigger( + hass, event_config, action, automation_info, platform_type="device" + ) + + +async def async_get_triggers(hass, device_id): + """List device triggers. + + Make sure device is a supported remote model. + Retrieve the hue event object matching device entry. + Generate device trigger list. + """ + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get(device_id) + + if device.model not in REMOTES: + return + + triggers = [] + for trigger, subtype in REMOTES[device.model].keys(): + triggers.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_PLATFORM: "device", + CONF_TYPE: trigger, + CONF_SUBTYPE: subtype, + } + ) + + return triggers diff --git a/homeassistant/components/hue/hue_event.py b/homeassistant/components/hue/hue_event.py index 838d5ead6da..ed1bc1c8f7d 100644 --- a/homeassistant/components/hue/hue_event.py +++ b/homeassistant/components/hue/hue_event.py @@ -28,6 +28,7 @@ class HueEvent(GenericHueDevice): 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.device_registry_id = None self.event_id = slugify(self.sensor.name) # Use the 'lastupdated' string to detect new remote presses @@ -79,9 +80,10 @@ class HueEvent(GenericHueDevice): entry = device_registry.async_get_or_create( config_entry_id=self.bridge.config_entry.entry_id, **self.device_info ) + self.device_registry_id = entry.id _LOGGER.debug( "Event registry with entry_id: %s and device_id: %s", - entry.id, + self.device_registry_id, self.device_id, ) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 78b990d5f42..0f70c49ff2e 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -27,5 +27,22 @@ "already_in_progress": "Config flow for bridge is already in progress.", "not_hue_bridge": "Not a Hue bridge" } + }, + "device_automation": { + "trigger_subtype": { + "button_1": "First button", + "button_2": "Second button", + "button_3": "Third button", + "button_4": "Fourth button", + "dim_down": "Dim down", + "dim_up": "Dim up", + "turn_off": "Turn off", + "turn_on": "Turn on" + }, + "trigger_type": { + "remote_button_long_release": "\"{subtype}\" button released after long press", + "remote_button_short_press": "\"{subtype}\" button pressed", + "remote_button_short_release": "\"{subtype}\" button released" + } } -} +} \ No newline at end of file diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py index 49cd953a697..fa7c4ac473d 100644 --- a/tests/components/hue/conftest.py +++ b/tests/components/hue/conftest.py @@ -1,11 +1,95 @@ """Test helpers for Hue.""" -from unittest.mock import patch +from collections import deque +from unittest.mock import Mock, patch +from aiohue.groups import Groups +from aiohue.lights import Lights +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 + @pytest.fixture(autouse=True) def no_request_delay(): """Make the request refresh delay 0 for instant tests.""" with patch("homeassistant.components.hue.light.REQUEST_REFRESH_DELAY", 0): yield + + +def create_mock_bridge(hass): + """Create a mock Hue bridge.""" + bridge = Mock( + hass=hass, + available=True, + authorized=True, + allow_unreachable=False, + allow_groups=False, + api=Mock(), + reset_jobs=[], + spec=hue.HueBridge, + ) + bridge.sensor_manager = hue_sensor_base.SensorManager(bridge) + 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_light_responses = deque() + bridge.mock_group_responses = deque() + 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 == "lights": + return bridge.mock_light_responses.popleft() + if path == "groups": + return bridge.mock_group_responses.popleft() + if path == "sensors": + return bridge.mock_sensor_responses.popleft() + return None + + async def async_request_call(task): + await task() + + bridge.async_request_call = async_request_call + bridge.api.config.apiversion = "9.9.9" + bridge.api.lights = Lights({}, mock_request) + bridge.api.groups = Groups({}, mock_request) + bridge.api.sensors = Sensors({}, mock_request) + return bridge + + +@pytest.fixture +def mock_bridge(hass): + """Mock a Hue bridge.""" + return create_mock_bridge(hass) + + +async def setup_bridge_for_sensors(hass, mock_bridge, hostname=None): + """Load the Hue platform with the provided bridge for sensor-related platforms.""" + if hostname is None: + hostname = "mock-host" + hass.config.components.add(hue.DOMAIN) + config_entry = config_entries.ConfigEntry( + 1, + hue.DOMAIN, + "Mock Title", + {"host": hostname}, + "test", + config_entries.CONN_CLASS_LOCAL_POLL, + system_options={}, + ) + mock_bridge.config_entry = config_entry + hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge} + await hass.config_entries.async_forward_entry_setup(config_entry, "binary_sensor") + await hass.config_entries.async_forward_entry_setup(config_entry, "sensor") + # simulate a full setup by manually adding the bridge config entry + hass.config_entries._entries.append(config_entry) + + # and make sure it completes before going further + await hass.async_block_till_done() diff --git a/tests/components/hue/test_device_trigger.py b/tests/components/hue/test_device_trigger.py new file mode 100644 index 00000000000..b6d3f4f2f50 --- /dev/null +++ b/tests/components/hue/test_device_trigger.py @@ -0,0 +1,169 @@ +"""The tests for Philips Hue device triggers.""" +import pytest + +from homeassistant.components import hue +import homeassistant.components.automation as automation +from homeassistant.components.hue import device_trigger +from homeassistant.setup import async_setup_component + +from .conftest import setup_bridge_for_sensors as setup_bridge +from .test_sensor_base import HUE_DIMMER_REMOTE_1, HUE_TAP_REMOTE_1 + +from tests.common import ( + assert_lists_same, + async_get_device_automations, + async_mock_service, + mock_device_registry, +) + +REMOTES_RESPONSE = {"7": HUE_TAP_REMOTE_1, "8": HUE_DIMMER_REMOTE_1} + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_get_triggers(hass, mock_bridge, device_reg): + """Test we get the expected triggers from a hue remote.""" + mock_bridge.mock_sensor_responses.append(REMOTES_RESPONSE) + await setup_bridge(hass, mock_bridge) + + assert len(mock_bridge.mock_requests) == 1 + # 2 remotes, just 1 battery sensor + assert len(hass.states.async_all()) == 1 + + # Get triggers for specific tap switch + hue_tap_device = device_reg.async_get_device( + {(hue.DOMAIN, "00:00:00:00:00:44:23:08")}, connections={} + ) + triggers = await async_get_device_automations(hass, "trigger", hue_tap_device.id) + + expected_triggers = [ + { + "platform": "device", + "domain": hue.DOMAIN, + "device_id": hue_tap_device.id, + "type": t_type, + "subtype": t_subtype, + } + for t_type, t_subtype in device_trigger.HUE_TAP_REMOTE.keys() + ] + assert_lists_same(triggers, expected_triggers) + + # Get triggers for specific dimmer switch + hue_dimmer_device = device_reg.async_get_device( + {(hue.DOMAIN, "00:17:88:01:10:3e:3a:dc")}, connections={} + ) + triggers = await async_get_device_automations(hass, "trigger", hue_dimmer_device.id) + + trigger_batt = { + "platform": "device", + "domain": "sensor", + "device_id": hue_dimmer_device.id, + "type": "battery_level", + "entity_id": "sensor.hue_dimmer_switch_1_battery_level", + } + expected_triggers = [ + trigger_batt, + *[ + { + "platform": "device", + "domain": hue.DOMAIN, + "device_id": hue_dimmer_device.id, + "type": t_type, + "subtype": t_subtype, + } + for t_type, t_subtype in device_trigger.HUE_DIMMER_REMOTE.keys() + ], + ] + assert_lists_same(triggers, expected_triggers) + + +async def test_if_fires_on_state_change(hass, mock_bridge, device_reg, calls): + """Test for button press trigger firing.""" + mock_bridge.mock_sensor_responses.append(REMOTES_RESPONSE) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + assert len(hass.states.async_all()) == 1 + + # Set an automation with a specific tap switch trigger + hue_tap_device = device_reg.async_get_device( + {(hue.DOMAIN, "00:00:00:00:00:44:23:08")}, connections={} + ) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": hue.DOMAIN, + "device_id": hue_tap_device.id, + "type": "remote_button_short_press", + "subtype": "button_4", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "B4 - {{ trigger.event.data.event }}" + }, + }, + }, + { + "trigger": { + "platform": "device", + "domain": hue.DOMAIN, + "device_id": "mock-device-id", + "type": "remote_button_short_press", + "subtype": "button_1", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "B1 - {{ trigger.event.data.event }}" + }, + }, + }, + ] + }, + ) + + # Fake that the remote is being pressed. + new_sensor_response = dict(REMOTES_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(calls) == 1 + assert calls[0].data["some"] == "B4 - 18" + + # Fake another button press. + new_sensor_response = dict(REMOTES_RESPONSE) + new_sensor_response["7"]["state"] = { + "buttonevent": 34, + "lastupdated": "2019-12-28T22:58:05", + } + 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(calls) == 1 diff --git a/tests/components/hue/test_light.py b/tests/components/hue/test_light.py index a99b947e48e..998e3cdea50 100644 --- a/tests/components/hue/test_light.py +++ b/tests/components/hue/test_light.py @@ -1,13 +1,9 @@ """Philips Hue lights platform tests.""" import asyncio -from collections import deque import logging from unittest.mock import Mock import aiohue -from aiohue.groups import Groups -from aiohue.lights import Lights -import pytest from homeassistant import config_entries from homeassistant.components import hue @@ -175,48 +171,6 @@ LIGHT_GAMUT = color.GamutType( LIGHT_GAMUT_TYPE = "A" -@pytest.fixture -def mock_bridge(hass): - """Mock a Hue bridge.""" - bridge = Mock( - hass=hass, - available=True, - authorized=True, - allow_unreachable=False, - allow_groups=False, - api=Mock(), - reset_jobs=[], - 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_light_responses = deque() - bridge.mock_group_responses = deque() - - async def mock_request(method, path, **kwargs): - kwargs["method"] = method - kwargs["path"] = path - bridge.mock_requests.append(kwargs) - - if path == "lights": - return bridge.mock_light_responses.popleft() - if path == "groups": - return bridge.mock_group_responses.popleft() - return None - - async def async_request_call(task): - await task() - - bridge.async_request_call = async_request_call - bridge.api.config.apiversion = "9.9.9" - bridge.api.lights = Lights({}, mock_request) - bridge.api.groups = Groups({}, mock_request) - - return bridge - - async def setup_bridge(hass, mock_bridge): """Load the Hue light platform with the provided bridge.""" hass.config.components.add(hue.DOMAIN) diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_base.py index cf1a4ab7983..576bc365d50 100644 --- a/tests/components/hue/test_sensor_base.py +++ b/tests/components/hue/test_sensor_base.py @@ -1,18 +1,14 @@ """Philips Hue sensors platform tests.""" import asyncio -from collections import deque 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 from homeassistant.components.hue.hue_event import CONF_HUE_EVENT +from .conftest import create_mock_bridge, setup_bridge_for_sensors as setup_bridge + _LOGGER = logging.getLogger(__name__) PRESENCE_SENSOR_1_PRESENT = { @@ -281,71 +277,6 @@ SENSOR_RESPONSE = { } -def create_mock_bridge(hass): - """Create a mock Hue bridge.""" - bridge = Mock( - hass=hass, - available=True, - authorized=True, - allow_unreachable=False, - allow_groups=False, - api=Mock(), - reset_jobs=[], - spec=hue.HueBridge, - ) - bridge.sensor_manager = hue_sensor_base.SensorManager(bridge) - 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 - - async def async_request_call(task): - await task() - - bridge.async_request_call = async_request_call - bridge.api.config.apiversion = "9.9.9" - bridge.api.sensors = Sensors({}, mock_request) - return bridge - - -@pytest.fixture -def mock_bridge(hass): - """Mock a Hue bridge.""" - return create_mock_bridge(hass) - - -async def setup_bridge(hass, mock_bridge, hostname=None): - """Load the Hue platform with the provided bridge.""" - if hostname is None: - hostname = "mock-host" - hass.config.components.add(hue.DOMAIN) - config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": hostname}, - "test", - config_entries.CONN_CLASS_LOCAL_POLL, - system_options={}, - ) - mock_bridge.config_entry = config_entry - hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge} - 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