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
This commit is contained in:
Robert Svensson 2022-02-16 17:55:30 +01:00 committed by GitHub
parent a9390908ea
commit dd9b14d5c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 366 additions and 69 deletions

View File

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

View File

@ -25,6 +25,7 @@ CONF_MASTER_GATEWAY = "master"
PLATFORMS = [ PLATFORMS = [
Platform.ALARM_CONTROL_PANEL, Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE, Platform.CLIMATE,
Platform.COVER, Platform.COVER,
Platform.FAN, Platform.FAN,

View File

@ -1,6 +1,8 @@
"""Base class for deCONZ devices.""" """Base class for deCONZ devices."""
from __future__ import annotations from __future__ import annotations
from pydeconz.group import Scene as PydeconzScene
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -96,3 +98,39 @@ class DeconzDevice(DeconzBase, Entity):
def available(self): def available(self):
"""Return True if device is available.""" """Return True if device is available."""
return self.gateway.available and self._device.reachable 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)})

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import ValuesView from collections.abc import ValuesView
from typing import Any from typing import Any
from pydeconz.group import DeconzGroup as Group from pydeconz.group import Group
from pydeconz.light import ( from pydeconz.light import (
ALERT_LONG, ALERT_LONG,
ALERT_SHORT, ALERT_SHORT,

View File

@ -4,7 +4,7 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/deconz", "documentation": "https://www.home-assistant.io/integrations/deconz",
"requirements": [ "requirements": [
"pydeconz==86" "pydeconz==87"
], ],
"ssdp": [ "ssdp": [
{ {
@ -16,5 +16,7 @@
], ],
"quality_scale": "platinum", "quality_scale": "platinum",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["pydeconz"] "loggers": [
"pydeconz"
]
} }

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import ValuesView from collections.abc import ValuesView
from typing import Any 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.components.scene import DOMAIN, Scene
from homeassistant.config_entries import ConfigEntry 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.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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( async def async_setup_entry(
@ -31,11 +32,14 @@ async def async_setup_entry(
| ValuesView[PydeconzScene] = gateway.api.scenes.values(), | ValuesView[PydeconzScene] = gateway.api.scenes.values(),
) -> None: ) -> None:
"""Add scene from deCONZ.""" """Add scene from deCONZ."""
entities = [ entities = []
DeconzScene(scene, gateway)
for scene in scenes for scene in scenes:
if scene.deconz_id not in gateway.entities[DOMAIN]
] 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: if entities:
async_add_entities(entities) async_add_entities(entities)
@ -51,27 +55,11 @@ async def async_setup_entry(
async_add_scene() async_add_scene()
class DeconzScene(Scene): class DeconzScene(DeconzSceneMixin, Scene):
"""Representation of a deCONZ scene.""" """Representation of a deCONZ scene."""
def __init__(self, scene: PydeconzScene, gateway: DeconzGateway) -> None: TYPE = DOMAIN
"""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
async def async_activate(self, **kwargs: Any) -> None: async def async_activate(self, **kwargs: Any) -> None:
"""Activate the scene.""" """Activate the scene."""
await self._scene.recall() await self._device.recall()

View File

@ -1467,7 +1467,7 @@ pydaikin==2.7.0
pydanfossair==0.1.0 pydanfossair==0.1.0
# homeassistant.components.deconz # homeassistant.components.deconz
pydeconz==86 pydeconz==87
# homeassistant.components.delijn # homeassistant.components.delijn
pydelijn==1.0.0 pydelijn==1.0.0

View File

@ -917,7 +917,7 @@ pycoolmasternet-async==0.1.2
pydaikin==2.7.0 pydaikin==2.7.0
# homeassistant.components.deconz # homeassistant.components.deconz
pydeconz==86 pydeconz==87
# homeassistant.components.dexcom # homeassistant.components.dexcom
pydexcom==0.2.2 pydexcom==0.2.2

View File

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

View File

@ -49,6 +49,7 @@ async def test_entry_diagnostics(
"entities": { "entities": {
str(Platform.ALARM_CONTROL_PANEL): [], str(Platform.ALARM_CONTROL_PANEL): [],
str(Platform.BINARY_SENSOR): [], str(Platform.BINARY_SENSOR): [],
str(Platform.BUTTON): [],
str(Platform.CLIMATE): [], str(Platform.CLIMATE): [],
str(Platform.COVER): [], str(Platform.COVER): [],
str(Platform.FAN): [], str(Platform.FAN): [],

View File

@ -12,6 +12,7 @@ from homeassistant.components.alarm_control_panel import (
DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, DOMAIN as ALARM_CONTROL_PANEL_DOMAIN,
) )
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_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.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
from homeassistant.components.deconz.config_flow import DECONZ_MANUFACTURERURL from homeassistant.components.deconz.config_flow import DECONZ_MANUFACTURERURL
@ -159,16 +160,17 @@ async def test_gateway_setup(hass, aioclient_mock):
config_entry, config_entry,
BINARY_SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN,
) )
assert forward_entry_setup.mock_calls[2][1] == (config_entry, CLIMATE_DOMAIN) assert forward_entry_setup.mock_calls[2][1] == (config_entry, BUTTON_DOMAIN)
assert forward_entry_setup.mock_calls[3][1] == (config_entry, COVER_DOMAIN) assert forward_entry_setup.mock_calls[3][1] == (config_entry, CLIMATE_DOMAIN)
assert forward_entry_setup.mock_calls[4][1] == (config_entry, FAN_DOMAIN) assert forward_entry_setup.mock_calls[4][1] == (config_entry, COVER_DOMAIN)
assert forward_entry_setup.mock_calls[5][1] == (config_entry, LIGHT_DOMAIN) assert forward_entry_setup.mock_calls[5][1] == (config_entry, FAN_DOMAIN)
assert forward_entry_setup.mock_calls[6][1] == (config_entry, LOCK_DOMAIN) assert forward_entry_setup.mock_calls[6][1] == (config_entry, LIGHT_DOMAIN)
assert forward_entry_setup.mock_calls[7][1] == (config_entry, NUMBER_DOMAIN) assert forward_entry_setup.mock_calls[7][1] == (config_entry, LOCK_DOMAIN)
assert forward_entry_setup.mock_calls[8][1] == (config_entry, SCENE_DOMAIN) assert forward_entry_setup.mock_calls[8][1] == (config_entry, NUMBER_DOMAIN)
assert forward_entry_setup.mock_calls[9][1] == (config_entry, SENSOR_DOMAIN) assert forward_entry_setup.mock_calls[9][1] == (config_entry, SCENE_DOMAIN)
assert forward_entry_setup.mock_calls[10][1] == (config_entry, SIREN_DOMAIN) assert forward_entry_setup.mock_calls[10][1] == (config_entry, SENSOR_DOMAIN)
assert forward_entry_setup.mock_calls[11][1] == (config_entry, SWITCH_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) device_registry = dr.async_get(hass)
gateway_entry = device_registry.async_get_device( gateway_entry = device_registry.async_get_device(

View File

@ -2,9 +2,12 @@
from unittest.mock import patch from unittest.mock import patch
import pytest
from homeassistant.components.deconz.gateway import get_gateway_from_config_entry 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.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 homeassistant.helpers.dispatcher import async_dispatcher_send
from .test_gateway import ( from .test_gateway import (
@ -20,9 +23,9 @@ async def test_no_scenes(hass, aioclient_mock):
assert len(hass.states.async_all()) == 0 assert len(hass.states.async_all()) == 0
async def test_scenes(hass, aioclient_mock): TEST_DATA = [
"""Test that scenes works.""" ( # Scene
data = { {
"groups": { "groups": {
"1": { "1": {
"id": "Light group id", "id": "Light group id",
@ -34,31 +37,72 @@ async def test_scenes(hass, aioclient_mock):
"lights": [], "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) config_entry = await setup_deconz_integration(hass, aioclient_mock)
assert len(hass.states.async_all()) == 1 assert len(hass.states.async_all()) == expected["entity_count"]
assert hass.states.get("scene.light_group_scene")
# Verify service calls # Verify state data
mock_deconz_put_request( scene = hass.states.get(expected["entity_id"])
aioclient_mock, config_entry.data, "/groups/1/scenes/1/recall" 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( await hass.services.async_call(
SCENE_DOMAIN, SCENE_DOMAIN,
SERVICE_TURN_ON, SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "scene.light_group_scene"}, {ATTR_ENTITY_ID: expected["entity_id"]},
blocking=True, blocking=True,
) )
assert aioclient_mock.mock_calls[1][2] == {} 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 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): with patch.dict(DECONZ_WEB_REQUEST, data):
config_entry = await setup_deconz_integration(hass, aioclient_mock) 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) gateway = get_gateway_from_config_entry(hass, config_entry)
async_dispatcher_send(hass, gateway.signal_new_scene) async_dispatcher_send(hass, gateway.signal_new_scene)
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1 assert len(hass.states.async_all()) == 2

View File

@ -254,7 +254,7 @@ async def test_service_refresh_devices(hass, aioclient_mock):
) )
await hass.async_block_till_done() 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): 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() 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 assert len(captured_events) == 0