From dd9b14d5c9f114108bf0e7304e24b1ef0e515d33 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 16 Feb 2022 17:55:30 +0100 Subject: [PATCH] Add Button platform to deCONZ integration (#65700) * Improve scene platform * Add button platform, tests and fix tests affected by new entities existing * Remove unnused property * Bump dependency to v87 --- homeassistant/components/deconz/button.py | 115 ++++++++++++++++++ homeassistant/components/deconz/const.py | 1 + .../components/deconz/deconz_device.py | 38 ++++++ homeassistant/components/deconz/light.py | 2 +- homeassistant/components/deconz/manifest.json | 6 +- homeassistant/components/deconz/scene.py | 40 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/deconz/test_button.py | 106 ++++++++++++++++ tests/components/deconz/test_diagnostics.py | 1 + tests/components/deconz/test_gateway.py | 22 ++-- tests/components/deconz/test_scene.py | 96 +++++++++++---- tests/components/deconz/test_services.py | 4 +- 13 files changed, 366 insertions(+), 69 deletions(-) create mode 100644 homeassistant/components/deconz/button.py create mode 100644 tests/components/deconz/test_button.py diff --git a/homeassistant/components/deconz/button.py b/homeassistant/components/deconz/button.py new file mode 100644 index 00000000000..2ad53d8ad63 --- /dev/null +++ b/homeassistant/components/deconz/button.py @@ -0,0 +1,115 @@ +"""Support for deCONZ buttons.""" + +from __future__ import annotations + +from collections.abc import ValuesView +from dataclasses import dataclass + +from pydeconz.group import Scene as PydeconzScene + +from homeassistant.components.button import ( + DOMAIN, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .deconz_device import DeconzSceneMixin +from .gateway import DeconzGateway, get_gateway_from_config_entry + + +@dataclass +class DeconzButtonDescriptionMixin: + """Required values when describing deCONZ button entities.""" + + suffix: str + button_fn: str + + +@dataclass +class DeconzButtonDescription(ButtonEntityDescription, DeconzButtonDescriptionMixin): + """Class describing deCONZ button entities.""" + + +ENTITY_DESCRIPTIONS = { + PydeconzScene: [ + DeconzButtonDescription( + key="store", + button_fn="store", + suffix="Store Current Scene", + icon="mdi:inbox-arrow-down", + entity_category=EntityCategory.CONFIG, + ) + ] +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the deCONZ button entity.""" + gateway = get_gateway_from_config_entry(hass, config_entry) + gateway.entities[DOMAIN] = set() + + @callback + def async_add_scene( + scenes: list[PydeconzScene] + | ValuesView[PydeconzScene] = gateway.api.scenes.values(), + ) -> None: + """Add scene button from deCONZ.""" + entities = [] + + for scene in scenes: + + known_entities = set(gateway.entities[DOMAIN]) + for description in ENTITY_DESCRIPTIONS.get(PydeconzScene, []): + + new_entity = DeconzButton(scene, gateway, description) + if new_entity.unique_id not in known_entities: + entities.append(new_entity) + + if entities: + async_add_entities(entities) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + gateway.signal_new_scene, + async_add_scene, + ) + ) + + async_add_scene() + + +class DeconzButton(DeconzSceneMixin, ButtonEntity): + """Representation of a deCONZ button entity.""" + + TYPE = DOMAIN + + def __init__( + self, + device: PydeconzScene, + gateway: DeconzGateway, + description: DeconzButtonDescription, + ) -> None: + """Initialize deCONZ number entity.""" + self.entity_description: DeconzButtonDescription = description + super().__init__(device, gateway) + + self._attr_name = f"{self._attr_name} {description.suffix}" + + async def async_press(self) -> None: + """Store light states into scene.""" + async_button_fn = getattr(self._device, self.entity_description.button_fn) + await async_button_fn() + + def get_device_identifier(self) -> str: + """Return a unique identifier for this scene.""" + return f"{super().get_device_identifier()}-{self.entity_description.key}" diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index 5f6d77a69fd..ca2e791f9e9 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -25,6 +25,7 @@ CONF_MASTER_GATEWAY = "master" PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.COVER, Platform.FAN, diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index bbd4051c177..45f57729a6f 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -1,6 +1,8 @@ """Base class for deCONZ devices.""" from __future__ import annotations +from pydeconz.group import Scene as PydeconzScene + from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -96,3 +98,39 @@ class DeconzDevice(DeconzBase, Entity): def available(self): """Return True if device is available.""" return self.gateway.available and self._device.reachable + + +class DeconzSceneMixin(DeconzDevice): + """Representation of a deCONZ scene.""" + + _device: PydeconzScene + + def __init__(self, device, gateway) -> None: + """Set up a scene.""" + super().__init__(device, gateway) + + self._attr_name = device.full_name + self._group_identifier = self.get_parent_identifier() + + def get_device_identifier(self) -> str: + """Describe a unique identifier for this scene.""" + return f"{self.gateway.bridgeid}{self._device.deconz_id}" + + def get_parent_identifier(self) -> str: + """Describe a unique identifier for group this scene belongs to.""" + return f"{self.gateway.bridgeid}-{self._device.group_deconz_id}" + + @property + def available(self) -> bool: + """Return True if scene is available.""" + return self.gateway.available + + @property + def unique_id(self) -> str: + """Return a unique identifier for this scene.""" + return self.get_device_identifier() + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + return DeviceInfo(identifiers={(DECONZ_DOMAIN, self._group_identifier)}) diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 5330fdb3226..e3cf6442079 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import ValuesView from typing import Any -from pydeconz.group import DeconzGroup as Group +from pydeconz.group import Group from pydeconz.light import ( ALERT_LONG, ALERT_SHORT, diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 6fb6bbce87a..bbbafffed7a 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/deconz", "requirements": [ - "pydeconz==86" + "pydeconz==87" ], "ssdp": [ { @@ -16,5 +16,7 @@ ], "quality_scale": "platinum", "iot_class": "local_push", - "loggers": ["pydeconz"] + "loggers": [ + "pydeconz" + ] } \ No newline at end of file diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py index 9fcccc52386..c188d7faffa 100644 --- a/homeassistant/components/deconz/scene.py +++ b/homeassistant/components/deconz/scene.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import ValuesView from typing import Any -from pydeconz.group import DeconzScene as PydeconzScene +from pydeconz.group import Scene as PydeconzScene from homeassistant.components.scene import DOMAIN, Scene from homeassistant.config_entries import ConfigEntry @@ -13,7 +13,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .gateway import DeconzGateway, get_gateway_from_config_entry +from .deconz_device import DeconzSceneMixin +from .gateway import get_gateway_from_config_entry async def async_setup_entry( @@ -31,11 +32,14 @@ async def async_setup_entry( | ValuesView[PydeconzScene] = gateway.api.scenes.values(), ) -> None: """Add scene from deCONZ.""" - entities = [ - DeconzScene(scene, gateway) - for scene in scenes - if scene.deconz_id not in gateway.entities[DOMAIN] - ] + entities = [] + + for scene in scenes: + + known_entities = set(gateway.entities[DOMAIN]) + new_entity = DeconzScene(scene, gateway) + if new_entity.unique_id not in known_entities: + entities.append(new_entity) if entities: async_add_entities(entities) @@ -51,27 +55,11 @@ async def async_setup_entry( async_add_scene() -class DeconzScene(Scene): +class DeconzScene(DeconzSceneMixin, Scene): """Representation of a deCONZ scene.""" - def __init__(self, scene: PydeconzScene, gateway: DeconzGateway) -> None: - """Set up a scene.""" - self._scene = scene - self.gateway = gateway - - self._attr_name = scene.full_name - - async def async_added_to_hass(self) -> None: - """Subscribe to sensors events.""" - self.gateway.deconz_ids[self.entity_id] = self._scene.deconz_id - self.gateway.entities[DOMAIN].add(self._scene.deconz_id) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect scene object when removed.""" - del self.gateway.deconz_ids[self.entity_id] - self.gateway.entities[DOMAIN].remove(self._scene.deconz_id) - self._scene = None + TYPE = DOMAIN async def async_activate(self, **kwargs: Any) -> None: """Activate the scene.""" - await self._scene.recall() + await self._device.recall() diff --git a/requirements_all.txt b/requirements_all.txt index e49ada0b8ce..34598e82c69 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1467,7 +1467,7 @@ pydaikin==2.7.0 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==86 +pydeconz==87 # homeassistant.components.delijn pydelijn==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b9a5db5f7b..aa830cb97c9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -917,7 +917,7 @@ pycoolmasternet-async==0.1.2 pydaikin==2.7.0 # homeassistant.components.deconz -pydeconz==86 +pydeconz==87 # homeassistant.components.dexcom pydexcom==0.2.2 diff --git a/tests/components/deconz/test_button.py b/tests/components/deconz/test_button.py new file mode 100644 index 00000000000..804a93d5ea4 --- /dev/null +++ b/tests/components/deconz/test_button.py @@ -0,0 +1,106 @@ +"""deCONZ button platform tests.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory + +from .test_gateway import ( + DECONZ_WEB_REQUEST, + mock_deconz_put_request, + setup_deconz_integration, +) + + +async def test_no_binary_sensors(hass, aioclient_mock): + """Test that no sensors in deconz results in no sensor entities.""" + await setup_deconz_integration(hass, aioclient_mock) + assert len(hass.states.async_all()) == 0 + + +TEST_DATA = [ + ( # Store scene button + { + "groups": { + "1": { + "id": "Light group id", + "name": "Light group", + "type": "LightGroup", + "state": {"all_on": False, "any_on": True}, + "action": {}, + "scenes": [{"id": "1", "name": "Scene"}], + "lights": [], + } + } + }, + { + "entity_count": 2, + "device_count": 3, + "entity_id": "button.light_group_scene_store_current_scene", + "unique_id": "01234E56789A/groups/1/scenes/1-store", + "entity_category": EntityCategory.CONFIG, + "attributes": { + "icon": "mdi:inbox-arrow-down", + "friendly_name": "Light group Scene Store Current Scene", + }, + "request": "/groups/1/scenes/1/store", + }, + ), +] + + +@pytest.mark.parametrize("raw_data, expected", TEST_DATA) +async def test_button(hass, aioclient_mock, raw_data, expected): + """Test successful creation of button entities.""" + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + + with patch.dict(DECONZ_WEB_REQUEST, raw_data): + config_entry = await setup_deconz_integration(hass, aioclient_mock) + + assert len(hass.states.async_all()) == expected["entity_count"] + + # Verify state data + + button = hass.states.get(expected["entity_id"]) + assert button.attributes == expected["attributes"] + + # Verify entity registry data + + ent_reg_entry = ent_reg.async_get(expected["entity_id"]) + assert ent_reg_entry.entity_category is expected["entity_category"] + assert ent_reg_entry.unique_id == expected["unique_id"] + + # Verify device registry data + + assert ( + len(dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)) + == expected["device_count"] + ) + + # Verify button press + + mock_deconz_put_request(aioclient_mock, config_entry.data, expected["request"]) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: expected["entity_id"]}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {} + + # Unload entry + + await hass.config_entries.async_unload(config_entry.entry_id) + assert hass.states.get(expected["entity_id"]).state == STATE_UNAVAILABLE + + # Remove entry + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 diff --git a/tests/components/deconz/test_diagnostics.py b/tests/components/deconz/test_diagnostics.py index 17da9f1141a..d0905f5ba5f 100644 --- a/tests/components/deconz/test_diagnostics.py +++ b/tests/components/deconz/test_diagnostics.py @@ -49,6 +49,7 @@ async def test_entry_diagnostics( "entities": { str(Platform.ALARM_CONTROL_PANEL): [], str(Platform.BINARY_SENSOR): [], + str(Platform.BUTTON): [], str(Platform.CLIMATE): [], str(Platform.COVER): [], str(Platform.FAN): [], diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index 30473814f26..8a449456fde 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -12,6 +12,7 @@ from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, ) from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.components.deconz.config_flow import DECONZ_MANUFACTURERURL @@ -159,16 +160,17 @@ async def test_gateway_setup(hass, aioclient_mock): config_entry, BINARY_SENSOR_DOMAIN, ) - assert forward_entry_setup.mock_calls[2][1] == (config_entry, CLIMATE_DOMAIN) - assert forward_entry_setup.mock_calls[3][1] == (config_entry, COVER_DOMAIN) - assert forward_entry_setup.mock_calls[4][1] == (config_entry, FAN_DOMAIN) - assert forward_entry_setup.mock_calls[5][1] == (config_entry, LIGHT_DOMAIN) - assert forward_entry_setup.mock_calls[6][1] == (config_entry, LOCK_DOMAIN) - assert forward_entry_setup.mock_calls[7][1] == (config_entry, NUMBER_DOMAIN) - assert forward_entry_setup.mock_calls[8][1] == (config_entry, SCENE_DOMAIN) - assert forward_entry_setup.mock_calls[9][1] == (config_entry, SENSOR_DOMAIN) - assert forward_entry_setup.mock_calls[10][1] == (config_entry, SIREN_DOMAIN) - assert forward_entry_setup.mock_calls[11][1] == (config_entry, SWITCH_DOMAIN) + assert forward_entry_setup.mock_calls[2][1] == (config_entry, BUTTON_DOMAIN) + assert forward_entry_setup.mock_calls[3][1] == (config_entry, CLIMATE_DOMAIN) + assert forward_entry_setup.mock_calls[4][1] == (config_entry, COVER_DOMAIN) + assert forward_entry_setup.mock_calls[5][1] == (config_entry, FAN_DOMAIN) + assert forward_entry_setup.mock_calls[6][1] == (config_entry, LIGHT_DOMAIN) + assert forward_entry_setup.mock_calls[7][1] == (config_entry, LOCK_DOMAIN) + assert forward_entry_setup.mock_calls[8][1] == (config_entry, NUMBER_DOMAIN) + assert forward_entry_setup.mock_calls[9][1] == (config_entry, SCENE_DOMAIN) + assert forward_entry_setup.mock_calls[10][1] == (config_entry, SENSOR_DOMAIN) + assert forward_entry_setup.mock_calls[11][1] == (config_entry, SIREN_DOMAIN) + assert forward_entry_setup.mock_calls[12][1] == (config_entry, SWITCH_DOMAIN) device_registry = dr.async_get(hass) gateway_entry = device_registry.async_get_device( diff --git a/tests/components/deconz/test_scene.py b/tests/components/deconz/test_scene.py index e6f74cd0529..f28e83d3f39 100644 --- a/tests/components/deconz/test_scene.py +++ b/tests/components/deconz/test_scene.py @@ -2,9 +2,12 @@ from unittest.mock import patch +import pytest + from homeassistant.components.deconz.gateway import get_gateway_from_config_entry from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from .test_gateway import ( @@ -20,45 +23,86 @@ async def test_no_scenes(hass, aioclient_mock): assert len(hass.states.async_all()) == 0 -async def test_scenes(hass, aioclient_mock): - """Test that scenes works.""" - data = { - "groups": { - "1": { - "id": "Light group id", - "name": "Light group", - "type": "LightGroup", - "state": {"all_on": False, "any_on": True}, - "action": {}, - "scenes": [{"id": "1", "name": "Scene"}], - "lights": [], +TEST_DATA = [ + ( # Scene + { + "groups": { + "1": { + "id": "Light group id", + "name": "Light group", + "type": "LightGroup", + "state": {"all_on": False, "any_on": True}, + "action": {}, + "scenes": [{"id": "1", "name": "Scene"}], + "lights": [], + } } - } - } - with patch.dict(DECONZ_WEB_REQUEST, data): + }, + { + "entity_count": 2, + "device_count": 3, + "entity_id": "scene.light_group_scene", + "unique_id": "01234E56789A/groups/1/scenes/1", + "entity_category": None, + "attributes": { + "friendly_name": "Light group Scene", + }, + "request": "/groups/1/scenes/1/recall", + }, + ), +] + + +@pytest.mark.parametrize("raw_data, expected", TEST_DATA) +async def test_scenes(hass, aioclient_mock, raw_data, expected): + """Test successful creation of scene entities.""" + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + + with patch.dict(DECONZ_WEB_REQUEST, raw_data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - assert len(hass.states.async_all()) == 1 - assert hass.states.get("scene.light_group_scene") + assert len(hass.states.async_all()) == expected["entity_count"] - # Verify service calls + # Verify state data - mock_deconz_put_request( - aioclient_mock, config_entry.data, "/groups/1/scenes/1/recall" + scene = hass.states.get(expected["entity_id"]) + assert scene.attributes == expected["attributes"] + + # Verify entity registry data + + ent_reg_entry = ent_reg.async_get(expected["entity_id"]) + assert ent_reg_entry.entity_category is expected["entity_category"] + assert ent_reg_entry.unique_id == expected["unique_id"] + + # Verify device registry data + + assert ( + len(dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)) + == expected["device_count"] ) - # Service turn on scene + # Verify button press + + mock_deconz_put_request(aioclient_mock, config_entry.data, expected["request"]) await hass.services.async_call( SCENE_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "scene.light_group_scene"}, + {ATTR_ENTITY_ID: expected["entity_id"]}, blocking=True, ) assert aioclient_mock.mock_calls[1][2] == {} - await hass.config_entries.async_unload(config_entry.entry_id) + # Unload entry + await hass.config_entries.async_unload(config_entry.entry_id) + assert hass.states.get(expected["entity_id"]).state == STATE_UNAVAILABLE + + # Remove entry + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 @@ -80,10 +124,10 @@ async def test_only_new_scenes_are_created(hass, aioclient_mock): with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 2 gateway = get_gateway_from_config_entry(hass, config_entry) async_dispatcher_send(hass, gateway.signal_new_scene) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 2 diff --git a/tests/components/deconz/test_services.py b/tests/components/deconz/test_services.py index 5cdd36440ea..086da5e24c4 100644 --- a/tests/components/deconz/test_services.py +++ b/tests/components/deconz/test_services.py @@ -254,7 +254,7 @@ async def test_service_refresh_devices(hass, aioclient_mock): ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 4 + assert len(hass.states.async_all()) == 5 async def test_service_refresh_devices_trigger_no_state_update(hass, aioclient_mock): @@ -317,7 +317,7 @@ async def test_service_refresh_devices_trigger_no_state_update(hass, aioclient_m ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 4 + assert len(hass.states.async_all()) == 5 assert len(captured_events) == 0