Switcher runner child lock support (#133270)

* Switcher runner child lock support

* fix based on requested changes

* Update homeassistant/components/switcher_kis/switch.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Fix

---------

Co-authored-by: Shay Levy <levyshay1@gmail.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
YogevBokobza 2025-01-11 21:01:10 +02:00 committed by GitHub
parent 6dc9c6819f
commit c442935fdd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 327 additions and 9 deletions

View File

@ -63,6 +63,14 @@
"temperature": {
"name": "Current temperature"
}
},
"switch": {
"child_lock": {
"name": "Child lock"
},
"multi_child_lock": {
"name": "Child lock {cover_id}"
}
}
},
"services": {

View File

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

View File

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

View File

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