diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index 844cbb4ca98..e380711303d 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -63,6 +63,14 @@ "temperature": { "name": "Current temperature" } + }, + "switch": { + "child_lock": { + "name": "Child lock" + }, + "multi_child_lock": { + "name": "Child lock {cover_id}" + } } }, "services": { diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index ba0a99b4089..7d3d71a0615 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -4,14 +4,15 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any +from typing import Any, cast -from aioswitcher.api import Command -from aioswitcher.device import DeviceCategory, DeviceState +from aioswitcher.api import Command, ShutterChildLock +from aioswitcher.device import DeviceCategory, DeviceState, SwitcherShutter import voluptuous as vol from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -32,6 +33,7 @@ _LOGGER = logging.getLogger(__name__) API_CONTROL_DEVICE = "control_device" API_SET_AUTO_SHUTDOWN = "set_auto_shutdown" +API_SET_CHILD_LOCK = "set_shutter_child_lock" SERVICE_SET_AUTO_OFF_SCHEMA: VolDictType = { vol.Required(CONF_AUTO_OFF): cv.time_period_str, @@ -67,10 +69,28 @@ async def async_setup_entry( @callback def async_add_switch(coordinator: SwitcherDataUpdateCoordinator) -> None: """Add switch from Switcher device.""" + entities: list[SwitchEntity] = [] + if coordinator.data.device_type.category == DeviceCategory.POWER_PLUG: - async_add_entities([SwitcherPowerPlugSwitchEntity(coordinator)]) + entities.append(SwitcherPowerPlugSwitchEntity(coordinator)) elif coordinator.data.device_type.category == DeviceCategory.WATER_HEATER: - async_add_entities([SwitcherWaterHeaterSwitchEntity(coordinator)]) + entities.append(SwitcherWaterHeaterSwitchEntity(coordinator)) + elif coordinator.data.device_type.category in ( + DeviceCategory.SHUTTER, + DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT, + DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT, + ): + number_of_covers = len(cast(SwitcherShutter, coordinator.data).position) + if number_of_covers == 1: + entities.append( + SwitchereShutterChildLockSingleSwitchEntity(coordinator, 0) + ) + else: + entities.extend( + SwitchereShutterChildLockMultiSwitchEntity(coordinator, i) + for i in range(number_of_covers) + ) + async_add_entities(entities) config_entry.async_on_unload( async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_switch) @@ -154,3 +174,91 @@ class SwitcherWaterHeaterSwitchEntity(SwitcherBaseSwitchEntity): await self._async_call_api(API_CONTROL_DEVICE, Command.ON, timer_minutes) self.control_result = True self.async_write_ha_state() + + +class SwitchereShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity): + """Representation of a Switcher shutter base switch entity.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + _attr_entity_category = EntityCategory.CONFIG + _attr_icon = "mdi:lock-open" + _cover_id: int + + def __init__(self, coordinator: SwitcherDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.control_result: bool | None = None + + @callback + def _handle_coordinator_update(self) -> None: + """When device updates, clear control result that overrides state.""" + self.control_result = None + super()._handle_coordinator_update() + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + if self.control_result is not None: + return self.control_result + + data = cast(SwitcherShutter, self.coordinator.data) + return bool(data.child_lock[self._cover_id] == ShutterChildLock.ON) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self._async_call_api( + API_SET_CHILD_LOCK, ShutterChildLock.ON, self._cover_id + ) + self.control_result = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._async_call_api( + API_SET_CHILD_LOCK, ShutterChildLock.OFF, self._cover_id + ) + self.control_result = False + self.async_write_ha_state() + + +class SwitchereShutterChildLockSingleSwitchEntity( + SwitchereShutterChildLockBaseSwitchEntity +): + """Representation of a Switcher runner child lock single switch entity.""" + + _attr_translation_key = "child_lock" + + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + cover_id: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._cover_id = cover_id + + self._attr_unique_id = ( + f"{coordinator.device_id}-{coordinator.mac_address}-child_lock" + ) + + +class SwitchereShutterChildLockMultiSwitchEntity( + SwitchereShutterChildLockBaseSwitchEntity +): + """Representation of a Switcher runner child lock multiple switch entity.""" + + _attr_translation_key = "multi_child_lock" + + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + cover_id: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._cover_id = cover_id + + self._attr_translation_placeholders = {"cover_id": str(cover_id + 1)} + self._attr_unique_id = ( + f"{coordinator.device_id}-{coordinator.mac_address}-{cover_id}-child_lock" + ) diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index defe970c674..57454e38062 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -91,8 +91,8 @@ DUMMY_POSITION = [54] DUMMY_POSITION_2 = [54, 54] DUMMY_DIRECTION = [ShutterDirection.SHUTTER_STOP] DUMMY_DIRECTION_2 = [ShutterDirection.SHUTTER_STOP, ShutterDirection.SHUTTER_STOP] -DUMMY_CHILD_LOCK = [ShutterChildLock.OFF] -DUMMY_CHILD_LOCK_2 = [ShutterChildLock.OFF, ShutterChildLock.OFF] +DUMMY_CHILD_LOCK = [ShutterChildLock.ON] +DUMMY_CHILD_LOCK_2 = [ShutterChildLock.ON, ShutterChildLock.ON] DUMMY_USERNAME = "email" DUMMY_TOKEN = "zvVvd7JxtN7CgvkD1Psujw==" DUMMY_LIGHT = [DeviceState.ON] diff --git a/tests/components/switcher_kis/test_switch.py b/tests/components/switcher_kis/test_switch.py index 9bfe11fe202..c20149de074 100644 --- a/tests/components/switcher_kis/test_switch.py +++ b/tests/components/switcher_kis/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from aioswitcher.api import Command, SwitcherBaseResponse +from aioswitcher.api import Command, ShutterChildLock, SwitcherBaseResponse from aioswitcher.device import DeviceState import pytest @@ -20,7 +20,20 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util import slugify from . import init_integration -from .consts import DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE +from .consts import ( + DUMMY_DUAL_SHUTTER_SINGLE_LIGHT_DEVICE as DEVICE3, + DUMMY_PLUG_DEVICE, + DUMMY_SHUTTER_DEVICE as DEVICE, + DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE as DEVICE2, + DUMMY_TOKEN as TOKEN, + DUMMY_USERNAME as USERNAME, + DUMMY_WATER_HEATER_DEVICE, +) + +ENTITY_ID = f"{SWITCH_DOMAIN}.{slugify(DEVICE.name)}_child_lock" +ENTITY_ID2 = f"{SWITCH_DOMAIN}.{slugify(DEVICE2.name)}_child_lock" +ENTITY_ID3 = f"{SWITCH_DOMAIN}.{slugify(DEVICE3.name)}_child_lock_1" +ENTITY_ID3_2 = f"{SWITCH_DOMAIN}.{slugify(DEVICE3.name)}_child_lock_2" @pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) @@ -137,3 +150,192 @@ async def test_switch_control_fail( mock_control_device.assert_called_once_with(Command.ON) state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ( + "device", + "entity_id", + "cover_id", + "child_lock_state", + ), + [ + ( + DEVICE, + ENTITY_ID, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE2, + ENTITY_ID2, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE3, + ENTITY_ID3, + 0, + [ShutterChildLock.OFF, ShutterChildLock.ON], + ), + ( + DEVICE3, + ENTITY_ID3_2, + 1, + [ShutterChildLock.ON, ShutterChildLock.OFF], + ), + ], +) +@pytest.mark.parametrize("mock_bridge", [[DEVICE, DEVICE2, DEVICE3]], indirect=True) +async def test_child_lock_switch( + hass: HomeAssistant, + mock_bridge, + mock_api, + monkeypatch: pytest.MonkeyPatch, + device, + entity_id: str, + cover_id: int, + child_lock_state: list[ShutterChildLock], +) -> None: + """Test the switch.""" + await init_integration(hass, USERNAME, TOKEN) + assert mock_bridge + + # Test initial state - on + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test state change on --> off + monkeypatch.setattr(device, "child_lock", child_lock_state) + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test turning on child lock + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test turning off + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock" + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ShutterChildLock.OFF, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ( + "device", + "entity_id", + "cover_id", + "child_lock_state", + ), + [ + ( + DEVICE, + ENTITY_ID, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE2, + ENTITY_ID2, + 0, + [ShutterChildLock.OFF], + ), + ( + DEVICE3, + ENTITY_ID3, + 0, + [ShutterChildLock.OFF, ShutterChildLock.ON], + ), + ( + DEVICE3, + ENTITY_ID3_2, + 1, + [ShutterChildLock.ON, ShutterChildLock.OFF], + ), + ], +) +@pytest.mark.parametrize("mock_bridge", [[DEVICE, DEVICE2, DEVICE3]], indirect=True) +async def test_child_lock_control_fail( + hass: HomeAssistant, + mock_bridge, + mock_api, + monkeypatch: pytest.MonkeyPatch, + device, + entity_id: str, + cover_id: int, + child_lock_state: list[ShutterChildLock], +) -> None: + """Test switch control fail.""" + await init_integration(hass, USERNAME, TOKEN) + assert mock_bridge + + # Test initial state - off + monkeypatch.setattr(device, "child_lock", child_lock_state) + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test exception during turn on + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + side_effect=RuntimeError("fake error"), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + # Make device available again + mock_bridge.mock_callbacks([device]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test error response during turn on + with patch( + "homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock", + return_value=SwitcherBaseResponse(None), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ShutterChildLock.ON, cover_id) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE