From 144371d8437b7288288c4c421804f1fc52e500fd Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Thu, 20 Jan 2022 14:10:06 +0100 Subject: [PATCH] Add siren platform to devolo Home Control (#53400) * Rework mocking * Add siren platform * Rebase with dev * React on change of default tone * Fix linting error --- .coveragerc | 1 - .../components/devolo_home_control/const.py | 1 + .../components/devolo_home_control/siren.py | 86 +++++++++++ tests/components/devolo_home_control/mocks.py | 41 +++++ .../devolo_home_control/test_siren.py | 141 ++++++++++++++++++ 5 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/devolo_home_control/siren.py create mode 100644 tests/components/devolo_home_control/test_siren.py diff --git a/.coveragerc b/.coveragerc index 2d2bede3b25..f23f055d7a6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -205,7 +205,6 @@ omit = homeassistant/components/devolo_home_control/climate.py homeassistant/components/devolo_home_control/const.py homeassistant/components/devolo_home_control/cover.py - homeassistant/components/devolo_home_control/devolo_multi_level_switch.py homeassistant/components/devolo_home_control/light.py homeassistant/components/devolo_home_control/sensor.py homeassistant/components/devolo_home_control/subscriber.py diff --git a/homeassistant/components/devolo_home_control/const.py b/homeassistant/components/devolo_home_control/const.py index e2ac3a42416..6ba934f6591 100644 --- a/homeassistant/components/devolo_home_control/const.py +++ b/homeassistant/components/devolo_home_control/const.py @@ -11,6 +11,7 @@ PLATFORMS = [ Platform.COVER, Platform.LIGHT, Platform.SENSOR, + Platform.SIREN, Platform.SWITCH, ] CONF_MYDEVOLO = "mydevolo_url" diff --git a/homeassistant/components/devolo_home_control/siren.py b/homeassistant/components/devolo_home_control/siren.py new file mode 100644 index 00000000000..91f8d86c030 --- /dev/null +++ b/homeassistant/components/devolo_home_control/siren.py @@ -0,0 +1,86 @@ +"""Platform for siren integration.""" +from typing import Any + +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl + +from homeassistant.components.siren import ( + ATTR_TONE, + SUPPORT_TONES, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SirenEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Get all binary sensor and multi level sensor devices and setup them via config entry.""" + entities = [] + + for gateway in hass.data[DOMAIN][entry.entry_id]["gateways"]: + for device in gateway.multi_level_switch_devices: + for multi_level_switch in device.multi_level_switch_property: + if multi_level_switch.startswith("devolo.SirenMultiLevelSwitch"): + entities.append( + DevoloSirenDeviceEntity( + homecontrol=gateway, + device_instance=device, + element_uid=multi_level_switch, + ) + ) + + async_add_entities(entities, False) + + +class DevoloSirenDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, SirenEntity): + """Representation of a cover device within devolo Home Control.""" + + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: + """Initialize a devolo multi level switch.""" + super().__init__( + homecontrol=homecontrol, + device_instance=device_instance, + element_uid=element_uid, + ) + self._attr_available_tones = [ + *range( + self._multi_level_switch_property.min, + self._multi_level_switch_property.max + 1, + ) + ] + self._attr_supported_features = ( + SUPPORT_TURN_OFF | SUPPORT_TURN_ON | SUPPORT_TONES + ) + self._default_tone = device_instance.settings_property["tone"].tone + + @property + def is_on(self) -> bool: + """Whether the device is on or off.""" + return self._value != 0 + + def turn_on(self, **kwargs: Any) -> None: + """Turn the device off.""" + tone = kwargs.get(ATTR_TONE) or self._default_tone + self._multi_level_switch_property.set(tone) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + self._multi_level_switch_property.set(0) + + def _generic_message(self, message: tuple) -> None: + """Handle generic messages.""" + if message[0].startswith("mss"): + # The default tone was changed + self._default_tone = message[1] + else: + super()._generic_message(message=message) diff --git a/tests/components/devolo_home_control/mocks.py b/tests/components/devolo_home_control/mocks.py index 693b4e7351d..79bf94b8fc3 100644 --- a/tests/components/devolo_home_control/mocks.py +++ b/tests/components/devolo_home_control/mocks.py @@ -8,6 +8,9 @@ from devolo_home_control_api.homecontrol import HomeControl from devolo_home_control_api.properties.binary_sensor_property import ( BinarySensorProperty, ) +from devolo_home_control_api.properties.multi_level_switch_property import ( + MultiLevelSwitchProperty, +) from devolo_home_control_api.properties.settings_property import SettingsProperty from devolo_home_control_api.publisher.publisher import Publisher @@ -25,6 +28,19 @@ class BinarySensorPropertyMock(BinarySensorProperty): self.state = False +class SirenPropertyMock(MultiLevelSwitchProperty): + """devolo Home Control siren mock.""" + + def __init__(self, **kwargs: Any) -> None: + """Initialize the mock.""" + self.element_uid = "Test" + self.max = 0 + self.min = 0 + self.switch_type = "tone" + self._value = 0 + self._logger = MagicMock() + + class SettingsMock(SettingsProperty): """devolo Home Control settings mock.""" @@ -33,6 +49,7 @@ class SettingsMock(SettingsProperty): self._logger = MagicMock() self.name = "Test" self.zone = "Test" + self.tone = 1 class DeviceMock(Zwave): @@ -87,6 +104,19 @@ class DisabledBinarySensorMock(DeviceMock): } +class SirenMock(DeviceMock): + """devolo Home Control siren device mock.""" + + def __init__(self) -> None: + """Initialize the mock.""" + super().__init__() + self.device_model_uid = "devolo.model.Siren" + self.multi_level_switch_property = { + "devolo.SirenMultiLevelSwitch:Test": SirenPropertyMock() + } + self.settings_property["tone"] = SettingsMock() + + class HomeControlMock(HomeControl): """devolo Home Control gateway mock.""" @@ -131,3 +161,14 @@ class HomeControlMockDisabledBinarySensor(HomeControlMock): """Initialize the mock.""" super().__init__() self.devices = {"Test": DisabledBinarySensorMock()} + + +class HomeControlMockSiren(HomeControlMock): + """devolo Home Control gateway mock with siren device.""" + + def __init__(self, **kwargs: Any) -> None: + """Initialize the mock.""" + super().__init__() + self.devices = {"Test": SirenMock()} + self.publisher = Publisher(self.devices.keys()) + self.publisher.unregister = MagicMock() diff --git a/tests/components/devolo_home_control/test_siren.py b/tests/components/devolo_home_control/test_siren.py new file mode 100644 index 00000000000..97e044738a5 --- /dev/null +++ b/tests/components/devolo_home_control/test_siren.py @@ -0,0 +1,141 @@ +"""Tests for the devolo Home Control binary sensors.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.siren import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import configure_integration +from .mocks import HomeControlMock, HomeControlMockSiren + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_siren(hass: HomeAssistant): + """Test setup and state change of a siren device.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockSiren() + test_gateway.devices["Test"].status = 0 + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + assert state.state == STATE_OFF + + # Emulate websocket message: sensor turned on + test_gateway.publisher.dispatch("Test", ("devolo.SirenMultiLevelSwitch:Test", 1)) + await hass.async_block_till_done() + assert hass.states.get(f"{DOMAIN}.test").state == STATE_ON + + # Emulate websocket message: device went offline + test_gateway.devices["Test"].status = 1 + test_gateway.publisher.dispatch("Test", ("Status", False, "status")) + await hass.async_block_till_done() + assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_siren_switching(hass: HomeAssistant): + """Test setup and state change via switching of a siren device.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockSiren() + test_gateway.devices["Test"].status = 0 + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + assert state.state == STATE_OFF + + with patch( + "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" + ) as set: + await hass.services.async_call( + "siren", + "turn_on", + {"entity_id": f"{DOMAIN}.test"}, + blocking=True, + ) + # The real device state is changed by a websocket message + test_gateway.publisher.dispatch( + "Test", ("devolo.SirenMultiLevelSwitch:Test", 1) + ) + await hass.async_block_till_done() + set.assert_called_once_with(1) + + with patch( + "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" + ) as set: + await hass.services.async_call( + "siren", + "turn_off", + {"entity_id": f"{DOMAIN}.test"}, + blocking=True, + ) + # The real device state is changed by a websocket message + test_gateway.publisher.dispatch( + "Test", ("devolo.SirenMultiLevelSwitch:Test", 0) + ) + await hass.async_block_till_done() + assert hass.states.get(f"{DOMAIN}.test").state == STATE_OFF + set.assert_called_once_with(0) + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_siren_change_default_tone(hass: HomeAssistant): + """Test changing the default tone on message.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockSiren() + test_gateway.devices["Test"].status = 0 + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + + with patch( + "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" + ) as set: + test_gateway.publisher.dispatch("Test", ("mss:Test", 2)) + await hass.services.async_call( + "siren", + "turn_on", + {"entity_id": f"{DOMAIN}.test"}, + blocking=True, + ) + set.assert_called_once_with(2) + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_remove_from_hass(hass: HomeAssistant): + """Test removing entity.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockSiren() + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + test_gateway.publisher.unregister.assert_called_once()