From f97ac9fdcdad161ee521ffb23f5abcff0a7516f9 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 28 Nov 2022 10:06:14 +0200 Subject: [PATCH] Add Switcher button platform (#81245) --- .../components/switcher_kis/__init__.py | 8 +- .../components/switcher_kis/button.py | 159 ++++++++++++++++++ .../components/switcher_kis/climate.py | 6 +- .../components/switcher_kis/manifest.json | 2 +- .../components/switcher_kis/utils.py | 8 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/switcher_kis/test_button.py | 150 +++++++++++++++++ 8 files changed, 330 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/switcher_kis/button.py create mode 100644 tests/components/switcher_kis/test_button.py diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index be8f140711a..39710be4857 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -29,7 +29,13 @@ from .const import ( ) from .utils import async_start_bridge, async_stop_bridge -PLATFORMS = [Platform.CLIMATE, Platform.COVER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BUTTON, + Platform.CLIMATE, + Platform.COVER, + Platform.SENSOR, + Platform.SWITCH, +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py new file mode 100644 index 00000000000..3668f11f037 --- /dev/null +++ b/homeassistant/components/switcher_kis/button.py @@ -0,0 +1,159 @@ +"""Switcher integration Button platform.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass + +from aioswitcher.api import ( + DeviceState, + SwitcherBaseResponse, + SwitcherType2Api, + ThermostatSwing, +) +from aioswitcher.api.remotes import SwitcherBreezeRemote +from aioswitcher.device import DeviceCategory + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import SwitcherDataUpdateCoordinator +from .const import SIGNAL_DEVICE_ADD +from .utils import get_breeze_remote_manager + + +@dataclass +class SwitcherThermostatButtonDescriptionMixin: + """Mixin to describe a Switcher Thermostat Button entity.""" + + press_fn: Callable[[SwitcherType2Api, SwitcherBreezeRemote], SwitcherBaseResponse] + supported: Callable[[SwitcherBreezeRemote], bool] + + +@dataclass +class SwitcherThermostatButtonEntityDescription( + ButtonEntityDescription, SwitcherThermostatButtonDescriptionMixin +): + """Class to describe a Switcher Thermostat Button entity.""" + + +THERMOSTAT_BUTTONS = [ + SwitcherThermostatButtonEntityDescription( + key="assume_on", + name="Assume on", + icon="mdi:fan", + entity_category=EntityCategory.CONFIG, + press_fn=lambda api, remote: api.control_breeze_device( + remote, state=DeviceState.ON, update_state=True + ), + supported=lambda remote: bool(remote.on_off_type), + ), + SwitcherThermostatButtonEntityDescription( + key="assume_off", + name="Assume off", + icon="mdi:fan-off", + entity_category=EntityCategory.CONFIG, + press_fn=lambda api, remote: api.control_breeze_device( + remote, state=DeviceState.OFF, update_state=True + ), + supported=lambda remote: bool(remote.on_off_type), + ), + SwitcherThermostatButtonEntityDescription( + key="vertical_swing_on", + name="Vertical swing on", + icon="mdi:autorenew", + press_fn=lambda api, remote: api.control_breeze_device( + remote, swing=ThermostatSwing.ON + ), + supported=lambda remote: bool(remote.separated_swing_command), + ), + SwitcherThermostatButtonEntityDescription( + key="vertical_swing_off", + name="Vertical swing off", + icon="mdi:autorenew-off", + press_fn=lambda api, remote: api.control_breeze_device( + remote, swing=ThermostatSwing.OFF + ), + supported=lambda remote: bool(remote.separated_swing_command), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Switcher button from config entry.""" + + @callback + async def async_add_buttons(coordinator: SwitcherDataUpdateCoordinator) -> None: + """Get remote and add button from Switcher device.""" + if coordinator.data.device_type.category == DeviceCategory.THERMOSTAT: + remote: SwitcherBreezeRemote = await hass.async_add_executor_job( + get_breeze_remote_manager(hass).get_remote, coordinator.data.remote_id + ) + async_add_entities( + SwitcherThermostatButtonEntity(coordinator, description, remote) + for description in THERMOSTAT_BUTTONS + if description.supported(remote) + ) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_buttons) + ) + + +class SwitcherThermostatButtonEntity( + CoordinatorEntity[SwitcherDataUpdateCoordinator], ButtonEntity +): + """Representation of a Switcher climate entity.""" + + entity_description: SwitcherThermostatButtonEntityDescription + + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + description: SwitcherThermostatButtonEntityDescription, + remote: SwitcherBreezeRemote, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = description + self._remote = remote + + self._attr_name = f"{coordinator.name} {description.name}" + self._attr_unique_id = f"{coordinator.mac_address}-{description.key}" + self._attr_device_info = DeviceInfo( + connections={ + (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) + } + ) + + async def async_press(self) -> None: + """Press the button.""" + response: SwitcherBaseResponse = None + error = None + + try: + async with SwitcherType2Api( + self.coordinator.data.ip_address, self.coordinator.data.device_id + ) as swapi: + response = await self.entity_description.press_fn(swapi, self._remote) + except (asyncio.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, " + f"response/error: {response or error}" + ) diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index 8462c8f02f8..01f4c80da31 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -5,7 +5,7 @@ import asyncio from typing import Any, cast from aioswitcher.api import SwitcherBaseResponse, SwitcherType2Api -from aioswitcher.api.remotes import SwitcherBreezeRemote, SwitcherBreezeRemoteManager +from aioswitcher.api.remotes import SwitcherBreezeRemote from aioswitcher.device import ( DeviceCategory, DeviceState, @@ -37,6 +37,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SwitcherDataUpdateCoordinator from .const import SIGNAL_DEVICE_ADD +from .utils import get_breeze_remote_manager DEVICE_MODE_TO_HA = { ThermostatMode.COOL: HVACMode.COOL, @@ -64,13 +65,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Switcher climate from config entry.""" - remote_manager = SwitcherBreezeRemoteManager() async def async_add_climate(coordinator: SwitcherDataUpdateCoordinator) -> None: """Get remote and add climate from Switcher device.""" if coordinator.data.device_type.category == DeviceCategory.THERMOSTAT: remote: SwitcherBreezeRemote = await hass.async_add_executor_job( - remote_manager.get_remote, coordinator.data.remote_id + get_breeze_remote_manager(hass).get_remote, coordinator.data.remote_id ) async_add_entities([SwitcherClimateEntity(coordinator, remote)]) diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 14f324d8cac..0dafb840dfa 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -3,7 +3,7 @@ "name": "Switcher", "documentation": "https://www.home-assistant.io/integrations/switcher_kis/", "codeowners": ["@tomerfi", "@thecode"], - "requirements": ["aioswitcher==3.1.0"], + "requirements": ["aioswitcher==3.2.0"], "quality_scale": "platinum", "iot_class": "local_push", "config_flow": true, diff --git a/homeassistant/components/switcher_kis/utils.py b/homeassistant/components/switcher_kis/utils.py index 5a35be8aa95..ad0414ae806 100644 --- a/homeassistant/components/switcher_kis/utils.py +++ b/homeassistant/components/switcher_kis/utils.py @@ -6,9 +6,11 @@ from collections.abc import Callable import logging from typing import Any +from aioswitcher.api.remotes import SwitcherBreezeRemoteManager from aioswitcher.bridge import SwitcherBase, SwitcherBridge from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import singleton from .const import DATA_BRIDGE, DISCOVERY_TIME_SEC, DOMAIN @@ -53,3 +55,9 @@ async def async_discover_devices() -> dict[str, SwitcherBase]: _LOGGER.debug("Finished discovery, discovered devices: %s", len(discovered_devices)) return discovered_devices + + +@singleton.singleton("switcher_breeze_remote_manager") +def get_breeze_remote_manager(hass: HomeAssistant) -> SwitcherBreezeRemoteManager: + """Get Switcher Breeze remote manager.""" + return SwitcherBreezeRemoteManager() diff --git a/requirements_all.txt b/requirements_all.txt index 47cf17e93d3..623c8db02db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -273,7 +273,7 @@ aioslimproto==2.1.1 aiosteamist==0.3.2 # homeassistant.components.switcher_kis -aioswitcher==3.1.0 +aioswitcher==3.2.0 # homeassistant.components.syncthing aiosyncthing==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 12462c52496..67cadb6d083 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -248,7 +248,7 @@ aioslimproto==2.1.1 aiosteamist==0.3.2 # homeassistant.components.switcher_kis -aioswitcher==3.1.0 +aioswitcher==3.2.0 # homeassistant.components.syncthing aiosyncthing==0.5.1 diff --git a/tests/components/switcher_kis/test_button.py b/tests/components/switcher_kis/test_button.py new file mode 100644 index 00000000000..0e3431168dc --- /dev/null +++ b/tests/components/switcher_kis/test_button.py @@ -0,0 +1,150 @@ +"""Tests for Switcher button platform.""" +from unittest.mock import ANY, patch + +from aioswitcher.api import DeviceState, SwitcherBaseResponse, ThermostatSwing +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, 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_THERMOSTAT_DEVICE as DEVICE + +BASE_ENTITY_ID = f"{BUTTON_DOMAIN}.{slugify(DEVICE.name)}" +ASSUME_ON_EID = BASE_ENTITY_ID + "_assume_on" +ASSUME_OFF_EID = BASE_ENTITY_ID + "_assume_off" +SWING_ON_EID = BASE_ENTITY_ID + "_vertical_swing_on" +SWING_OFF_EID = BASE_ENTITY_ID + "_vertical_swing_off" + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_assume_button(hass: HomeAssistant, mock_bridge, mock_api): + """Test assume on/off button.""" + await init_integration(hass) + assert mock_bridge + + assert hass.states.get(ASSUME_ON_EID) is not None + assert hass.states.get(ASSUME_OFF_EID) is not None + assert hass.states.get(SWING_ON_EID) is None + assert hass.states.get(SWING_OFF_EID) is None + + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + ) as mock_control_device: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ASSUME_ON_EID}, + blocking=True, + ) + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with( + ANY, state=DeviceState.ON, update_state=True + ) + + mock_control_device.reset_mock() + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ASSUME_OFF_EID}, + blocking=True, + ) + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with( + ANY, state=DeviceState.OFF, update_state=True + ) + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_swing_button(hass: HomeAssistant, mock_bridge, mock_api, monkeypatch): + """Test vertical swing on/off button.""" + monkeypatch.setattr(DEVICE, "remote_id", "ELEC7022") + await init_integration(hass) + assert mock_bridge + + assert hass.states.get(ASSUME_ON_EID) is None + assert hass.states.get(ASSUME_OFF_EID) is None + assert hass.states.get(SWING_ON_EID) is not None + assert hass.states.get(SWING_OFF_EID) is not None + + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + ) as mock_control_device: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: SWING_ON_EID}, + blocking=True, + ) + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(ANY, swing=ThermostatSwing.ON) + + mock_control_device.reset_mock() + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: SWING_OFF_EID}, + blocking=True, + ) + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(ANY, swing=ThermostatSwing.OFF) + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +async def test_control_device_fail(hass, mock_bridge, mock_api, monkeypatch): + """Test control device fail.""" + await init_integration(hass) + assert mock_bridge + + assert hass.states.get(ASSUME_ON_EID) is not None + + # Test exception during set hvac mode + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + side_effect=RuntimeError("fake error"), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ASSUME_ON_EID}, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with( + ANY, state=DeviceState.ON, update_state=True + ) + + state = hass.states.get(ASSUME_ON_EID) + assert state.state == STATE_UNAVAILABLE + + # Make device available again + mock_bridge.mock_callbacks([DEVICE]) + await hass.async_block_till_done() + + assert hass.states.get(ASSUME_ON_EID) is not None + + # Test error response during turn on + with patch( + "homeassistant.components.switcher_kis.climate.SwitcherType2Api.control_breeze_device", + return_value=SwitcherBaseResponse(None), + ) as mock_control_device: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ASSUME_ON_EID}, + blocking=True, + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with( + ANY, state=DeviceState.ON, update_state=True + ) + + state = hass.states.get(ASSUME_ON_EID) + assert state.state == STATE_UNAVAILABLE