diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index 88baa9aed91..840b62252f1 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -19,6 +19,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.CLIMATE, Platform.COVER, + Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/switcher_kis/light.py b/homeassistant/components/switcher_kis/light.py new file mode 100644 index 00000000000..d3e8d52bc00 --- /dev/null +++ b/homeassistant/components/switcher_kis/light.py @@ -0,0 +1,129 @@ +"""Switcher integration Light platform.""" + +from __future__ import annotations + +import logging +from typing import Any, cast + +from aioswitcher.api import SwitcherBaseResponse, SwitcherType2Api +from aioswitcher.device import ( + DeviceCategory, + DeviceState, + SwitcherSingleShutterDualLight, +) + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import SIGNAL_DEVICE_ADD +from .coordinator import SwitcherDataUpdateCoordinator +from .entity import SwitcherEntity + +_LOGGER = logging.getLogger(__name__) + +API_SET_LIGHT = "set_light" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Switcher light from a config entry.""" + + @callback + def async_add_light(coordinator: SwitcherDataUpdateCoordinator) -> None: + """Add light from Switcher device.""" + if ( + coordinator.data.device_type.category + == DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT + ): + async_add_entities( + [ + SwitcherLightEntity(coordinator, 0), + SwitcherLightEntity(coordinator, 1), + ] + ) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_light) + ) + + +class SwitcherLightEntity(SwitcherEntity, LightEntity): + """Representation of a Switcher light entity.""" + + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_translation_key = "light" + + def __init__( + self, coordinator: SwitcherDataUpdateCoordinator, light_id: int + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._light_id = light_id + self.control_result: bool | None = None + + # Entity class attributes + self._attr_translation_placeholders = {"light_id": str(light_id + 1)} + self._attr_unique_id = ( + f"{coordinator.device_id}-{coordinator.mac_address}-{light_id}" + ) + + @callback + def _handle_coordinator_update(self) -> None: + """When device updates, clear control result that overrides state.""" + self.control_result = None + self.async_write_ha_state() + + @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(SwitcherSingleShutterDualLight, self.coordinator.data) + return bool(data.lights[self._light_id] == DeviceState.ON) + + async def _async_call_api(self, api: str, *args: Any) -> None: + """Call Switcher API.""" + _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args) + response: SwitcherBaseResponse | None = None + error = None + + try: + async with SwitcherType2Api( + self.coordinator.data.device_type, + self.coordinator.data.ip_address, + self.coordinator.data.device_id, + self.coordinator.data.device_key, + self.coordinator.token, + ) as swapi: + response = await getattr(swapi, api)(*args) + except (TimeoutError, OSError, RuntimeError) as err: + error = repr(err) + + if error or not response or not response.successful: + self.coordinator.last_update_success = False + self.async_write_ha_state() + raise HomeAssistantError( + f"Call api for {self.name} failed, api: '{api}', " + f"args: {args}, response/error: {response or error}" + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + await self._async_call_api(API_SET_LIGHT, DeviceState.ON, self._light_id) + self.control_result = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self._async_call_api(API_SET_LIGHT, DeviceState.OFF, self._light_id) + self.control_result = False + self.async_write_ha_state() diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index a3b3739eb2e..68f9f9d590c 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -43,6 +43,11 @@ "name": "Vertical swing off" } }, + "light": { + "light": { + "name": "Light {light_id}" + } + }, "sensor": { "remaining_time": { "name": "Remaining time" diff --git a/tests/components/switcher_kis/test_light.py b/tests/components/switcher_kis/test_light.py new file mode 100644 index 00000000000..0fb036967e7 --- /dev/null +++ b/tests/components/switcher_kis/test_light.py @@ -0,0 +1,154 @@ +"""Test the Switcher light platform.""" + +from unittest.mock import patch + +from aioswitcher.api import SwitcherBaseResponse +from aioswitcher.device import DeviceState +import pytest + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import slugify + +from . import init_integration +from .consts import ( + DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE as DEVICE, + DUMMY_TOKEN as TOKEN, + DUMMY_USERNAME as USERNAME, +) + +ENTITY_ID = f"{LIGHT_DOMAIN}.{slugify(DEVICE.name)}_light_1" +ENTITY_ID2 = f"{LIGHT_DOMAIN}.{slugify(DEVICE.name)}_light_2" + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +@pytest.mark.parametrize( + ("entity_id", "light_id", "device_state"), + [ + (ENTITY_ID, 0, [DeviceState.OFF, DeviceState.ON]), + (ENTITY_ID2, 1, [DeviceState.ON, DeviceState.OFF]), + ], +) +async def test_light( + hass: HomeAssistant, + mock_bridge, + mock_api, + monkeypatch: pytest.MonkeyPatch, + entity_id: str, + light_id: int, + device_state: list[DeviceState], +) -> None: + """Test the light.""" + await init_integration(hass, USERNAME, TOKEN) + assert mock_bridge + + # Test initial state - light on + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test state change on --> off for light + monkeypatch.setattr(DEVICE, "lights", device_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 light + with patch( + "homeassistant.components.switcher_kis.light.SwitcherType2Api.set_light", + ) as mock_set_light: + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 2 + mock_set_light.assert_called_once_with(DeviceState.ON, light_id) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test turning off light + with patch( + "homeassistant.components.switcher_kis.light.SwitcherType2Api.set_light" + ) as mock_set_light: + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 4 + mock_set_light.assert_called_once_with(DeviceState.OFF, light_id) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_light_control_fail( + hass: HomeAssistant, + mock_bridge, + mock_api, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test light control fail.""" + await init_integration(hass, USERNAME, TOKEN) + assert mock_bridge + + # Test initial state - light off + monkeypatch.setattr(DEVICE, "lights", [DeviceState.OFF, DeviceState.ON]) + 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.cover.SwitcherType2Api.set_light", + side_effect=RuntimeError("fake error"), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(DeviceState.ON, 0) + 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.cover.SwitcherType2Api.set_light", + return_value=SwitcherBaseResponse(None), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(DeviceState.ON, 0) + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_UNAVAILABLE