From bf7c2753d59c6999d38cb248221f64146099dd45 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 18 Sep 2021 21:59:04 +0200 Subject: [PATCH] deCONZ use siren platform (#56397) * Add siren.py * Working siren platform with 100% test coverage * Also add test file... * Add test to verify that switch platform cleans up legacy entities now that sirens are their own platform * Update homeassistant/components/deconz/siren.py Co-authored-by: jjlawren --- homeassistant/components/deconz/const.py | 7 +- homeassistant/components/deconz/light.py | 11 +- homeassistant/components/deconz/siren.py | 78 +++++++++++++ homeassistant/components/deconz/switch.py | 37 ++---- tests/components/deconz/test_gateway.py | 4 +- tests/components/deconz/test_siren.py | 132 ++++++++++++++++++++++ tests/components/deconz/test_switch.py | 73 ------------ 7 files changed, 237 insertions(+), 105 deletions(-) create mode 100644 homeassistant/components/deconz/siren.py create mode 100644 tests/components/deconz/test_siren.py diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index d2d7025771e..e961a62c7a0 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -12,6 +12,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN LOGGER = logging.getLogger(__package__) @@ -41,6 +42,7 @@ PLATFORMS = [ LOCK_DOMAIN, SCENE_DOMAIN, SENSOR_DOMAIN, + SIREN_DOMAIN, SWITCH_DOMAIN, ] @@ -69,10 +71,11 @@ FANS = ["Fan"] # Locks LOCK_TYPES = ["Door Lock", "ZHADoorLock"] +# Sirens +SIRENS = ["Warning device"] + # Switches POWER_PLUGS = ["On/Off light", "On/Off plug-in unit", "Smart plug"] -SIRENS = ["Warning device"] -SWITCH_TYPES = POWER_PLUGS + SIRENS CONF_ANGLE = "angle" CONF_GESTURE = "gesture" diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 59eb1bb426e..3c48fbc5177 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -34,7 +34,8 @@ from .const import ( LOCK_TYPES, NEW_GROUP, NEW_LIGHT, - SWITCH_TYPES, + POWER_PLUGS, + SIRENS, ) from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry @@ -42,14 +43,16 @@ from .gateway import get_gateway_from_config_entry CONTROLLER = ["Configuration tool"] DECONZ_GROUP = "is_deconz_group" +OTHER_LIGHT_RESOURCE_TYPES = ( + CONTROLLER + COVER_TYPES + LOCK_TYPES + POWER_PLUGS + SIRENS +) + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the deCONZ lights and groups from a config entry.""" gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() - other_light_resource_types = CONTROLLER + COVER_TYPES + LOCK_TYPES + SWITCH_TYPES - @callback def async_add_light(lights=gateway.api.lights.values()): """Add light from deCONZ.""" @@ -57,7 +60,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for light in lights: if ( - light.type not in other_light_resource_types + light.type not in OTHER_LIGHT_RESOURCE_TYPES and light.unique_id not in gateway.entities[DOMAIN] ): entities.append(DeconzLight(light, gateway)) diff --git a/homeassistant/components/deconz/siren.py b/homeassistant/components/deconz/siren.py new file mode 100644 index 00000000000..9138bb3ac14 --- /dev/null +++ b/homeassistant/components/deconz/siren.py @@ -0,0 +1,78 @@ +"""Support for deCONZ siren.""" + +from pydeconz.light import Siren + +from homeassistant.components.siren import ( + ATTR_DURATION, + DOMAIN, + SUPPORT_DURATION, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SirenEntity, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import NEW_LIGHT +from .deconz_device import DeconzDevice +from .gateway import get_gateway_from_config_entry + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up sirens for deCONZ component.""" + gateway = get_gateway_from_config_entry(hass, config_entry) + gateway.entities[DOMAIN] = set() + + @callback + def async_add_siren(lights=gateway.api.lights.values()): + """Add siren from deCONZ.""" + entities = [] + + for light in lights: + + if ( + isinstance(light, Siren) + and light.unique_id not in gateway.entities[DOMAIN] + ): + entities.append(DeconzSiren(light, gateway)) + + if entities: + async_add_entities(entities) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_siren + ) + ) + + async_add_siren() + + +class DeconzSiren(DeconzDevice, SirenEntity): + """Representation of a deCONZ siren.""" + + TYPE = DOMAIN + + def __init__(self, device, gateway) -> None: + """Set up siren.""" + super().__init__(device, gateway) + + self._attr_supported_features = ( + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_DURATION + ) + + @property + def is_on(self): + """Return true if siren is on.""" + return self._device.is_on + + async def async_turn_on(self, **kwargs): + """Turn on siren.""" + data = {} + if (duration := kwargs.get(ATTR_DURATION)) is not None: + data["duration"] = duration * 10 + await self._device.turn_on(**data) + + async def async_turn_off(self, **kwargs): + """Turn off siren.""" + await self._device.turn_off() diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index 5fee752a71f..8f31af3a6cf 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -3,7 +3,7 @@ from homeassistant.components.switch import DOMAIN, SwitchEntity from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import NEW_LIGHT, POWER_PLUGS, SIRENS +from .const import DOMAIN as DECONZ_DOMAIN, NEW_LIGHT, POWER_PLUGS, SIRENS from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry @@ -16,6 +16,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway = get_gateway_from_config_entry(hass, config_entry) gateway.entities[DOMAIN] = set() + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + # Siren platform replacing sirens in switch platform added in 2021.10 + for light in gateway.api.lights.values(): + if light.type not in SIRENS: + continue + if entity_id := entity_registry.async_get_entity_id( + DOMAIN, DECONZ_DOMAIN, light.unique_id + ): + entity_registry.async_remove(entity_id) + @callback def async_add_switch(lights=gateway.api.lights.values()): """Add switch from deCONZ.""" @@ -29,11 +40,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ): entities.append(DeconzPowerPlug(light, gateway)) - elif ( - light.type in SIRENS and light.unique_id not in gateway.entities[DOMAIN] - ): - entities.append(DeconzSiren(light, gateway)) - if entities: async_add_entities(entities) @@ -63,22 +69,3 @@ class DeconzPowerPlug(DeconzDevice, SwitchEntity): async def async_turn_off(self, **kwargs): """Turn off switch.""" await self._device.set_state(on=False) - - -class DeconzSiren(DeconzDevice, SwitchEntity): - """Representation of a deCONZ siren.""" - - TYPE = DOMAIN - - @property - def is_on(self): - """Return true if switch is on.""" - return self._device.is_on - - async def async_turn_on(self, **kwargs): - """Turn on switch.""" - await self._device.turn_on() - - async def async_turn_off(self, **kwargs): - """Turn off switch.""" - await self._device.turn_off() diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index 120ceaa9327..4ee071f10d3 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -25,6 +25,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN from homeassistant.components.ssdp import ( ATTR_SSDP_LOCATION, ATTR_UPNP_MANUFACTURER_URL, @@ -163,7 +164,8 @@ async def test_gateway_setup(hass, aioclient_mock): assert forward_entry_setup.mock_calls[6][1] == (config_entry, LOCK_DOMAIN) assert forward_entry_setup.mock_calls[7][1] == (config_entry, SCENE_DOMAIN) assert forward_entry_setup.mock_calls[8][1] == (config_entry, SENSOR_DOMAIN) - assert forward_entry_setup.mock_calls[9][1] == (config_entry, SWITCH_DOMAIN) + assert forward_entry_setup.mock_calls[9][1] == (config_entry, SIREN_DOMAIN) + assert forward_entry_setup.mock_calls[10][1] == (config_entry, SWITCH_DOMAIN) async def test_gateway_retry(hass): diff --git a/tests/components/deconz/test_siren.py b/tests/components/deconz/test_siren.py new file mode 100644 index 00000000000..c24e2087768 --- /dev/null +++ b/tests/components/deconz/test_siren.py @@ -0,0 +1,132 @@ +"""deCONZ switch platform tests.""" + +from unittest.mock import patch + +from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN +from homeassistant.components.siren import ATTR_DURATION, DOMAIN as SIREN_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.helpers import entity_registry as er + +from .test_gateway import ( + DECONZ_WEB_REQUEST, + mock_deconz_put_request, + setup_deconz_integration, +) + + +async def test_sirens(hass, aioclient_mock, mock_deconz_websocket): + """Test that siren entities are created.""" + data = { + "lights": { + "1": { + "name": "Warning device", + "type": "Warning device", + "state": {"alert": "lselect", "reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:00-00", + }, + "2": { + "name": "Unsupported siren", + "type": "Not a siren", + "state": {"reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:01-00", + }, + } + } + with patch.dict(DECONZ_WEB_REQUEST, data): + config_entry = await setup_deconz_integration(hass, aioclient_mock) + + assert len(hass.states.async_all()) == 2 + assert hass.states.get("siren.warning_device").state == STATE_ON + assert not hass.states.get("siren.unsupported_siren") + + event_changed_light = { + "t": "event", + "e": "changed", + "r": "lights", + "id": "1", + "state": {"alert": None}, + } + await mock_deconz_websocket(data=event_changed_light) + await hass.async_block_till_done() + + assert hass.states.get("siren.warning_device").state == STATE_OFF + + # Verify service calls + + mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1/state") + + # Service turn on siren + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "siren.warning_device"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[1][2] == {"alert": "lselect"} + + # Service turn off siren + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "siren.warning_device"}, + blocking=True, + ) + assert aioclient_mock.mock_calls[2][2] == {"alert": "none"} + + # Service turn on siren with duration + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "siren.warning_device", ATTR_DURATION: 10}, + blocking=True, + ) + assert aioclient_mock.mock_calls[3][2] == {"alert": "lselect", "ontime": 100} + + await hass.config_entries.async_unload(config_entry.entry_id) + + states = hass.states.async_all() + assert len(states) == 2 + 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_remove_legacy_siren_switch(hass, aioclient_mock): + """Test that switch platform cleans up legacy siren entities.""" + unique_id = "00:00:00:00:00:00:00:00-00" + + registry = er.async_get(hass) + switch_siren_entity = registry.async_get_or_create( + SWITCH_DOMAIN, DECONZ_DOMAIN, unique_id + ) + + assert switch_siren_entity + + data = { + "lights": { + "1": { + "name": "Warning device", + "type": "Warning device", + "state": {"alert": "lselect", "reachable": True}, + "uniqueid": unique_id, + }, + } + } + with patch.dict(DECONZ_WEB_REQUEST, data): + await setup_deconz_integration(hass, aioclient_mock) + + assert not registry.async_get(switch_siren_entity.entity_id) diff --git a/tests/components/deconz/test_switch.py b/tests/components/deconz/test_switch.py index cffdf07ae2b..99dcbe1089a 100644 --- a/tests/components/deconz/test_switch.py +++ b/tests/components/deconz/test_switch.py @@ -107,76 +107,3 @@ async def test_power_plugs(hass, aioclient_mock, mock_deconz_websocket): 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_sirens(hass, aioclient_mock, mock_deconz_websocket): - """Test that siren entities are created.""" - data = { - "lights": { - "1": { - "name": "Warning device", - "type": "Warning device", - "state": {"alert": "lselect", "reachable": True}, - "uniqueid": "00:00:00:00:00:00:00:00-00", - }, - "2": { - "name": "Unsupported switch", - "type": "Not a switch", - "state": {"reachable": True}, - "uniqueid": "00:00:00:00:00:00:00:01-00", - }, - } - } - with patch.dict(DECONZ_WEB_REQUEST, data): - config_entry = await setup_deconz_integration(hass, aioclient_mock) - - assert len(hass.states.async_all()) == 2 - assert hass.states.get("switch.warning_device").state == STATE_ON - assert not hass.states.get("switch.unsupported_switch") - - event_changed_light = { - "t": "event", - "e": "changed", - "r": "lights", - "id": "1", - "state": {"alert": None}, - } - await mock_deconz_websocket(data=event_changed_light) - await hass.async_block_till_done() - - assert hass.states.get("switch.warning_device").state == STATE_OFF - - # Verify service calls - - mock_deconz_put_request(aioclient_mock, config_entry.data, "/lights/1/state") - - # Service turn on siren - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.warning_device"}, - blocking=True, - ) - assert aioclient_mock.mock_calls[1][2] == {"alert": "lselect"} - - # Service turn off siren - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.warning_device"}, - blocking=True, - ) - assert aioclient_mock.mock_calls[2][2] == {"alert": "none"} - - await hass.config_entries.async_unload(config_entry.entry_id) - - states = hass.states.async_all() - assert len(states) == 2 - 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