diff --git a/homeassistant/components/technove/__init__.py b/homeassistant/components/technove/__init__.py index a235f98433b..999cb2e2f34 100644 --- a/homeassistant/components/technove/__init__.py +++ b/homeassistant/components/technove/__init__.py @@ -8,10 +8,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import TechnoVEDataUpdateCoordinator -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.SENSOR, -] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/technove/helpers.py b/homeassistant/components/technove/helpers.py new file mode 100644 index 00000000000..43bd8f04794 --- /dev/null +++ b/homeassistant/components/technove/helpers.py @@ -0,0 +1,40 @@ +"""Helpers for TechnoVE.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate, ParamSpec, TypeVar + +from technove import TechnoVEConnectionError, TechnoVEError + +from homeassistant.exceptions import HomeAssistantError + +from .entity import TechnoVEEntity + +_TechnoVEEntityT = TypeVar("_TechnoVEEntityT", bound=TechnoVEEntity) +_P = ParamSpec("_P") + + +def technove_exception_handler( + func: Callable[Concatenate[_TechnoVEEntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_TechnoVEEntityT, _P], Coroutine[Any, Any, None]]: + """Decorate TechnoVE calls to handle TechnoVE exceptions. + + A decorator that wraps the passed in function, catches TechnoVE errors, + and handles the availability of the device in the data coordinator. + """ + + async def handler( + self: _TechnoVEEntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: + try: + await func(self, *args, **kwargs) + + except TechnoVEConnectionError as error: + self.coordinator.last_update_success = False + self.coordinator.async_update_listeners() + raise HomeAssistantError("Error communicating with TechnoVE API") from error + + except TechnoVEError as error: + raise HomeAssistantError("Invalid response from TechnoVE API") from error + + return handler diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index f38bf61d8ed..1e7550c8842 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -68,6 +68,11 @@ "high_charge_period": "High charge period" } } + }, + "switch": { + "auto_charge": { + "name": "Auto charge" + } } } } diff --git a/homeassistant/components/technove/switch.py b/homeassistant/components/technove/switch.py new file mode 100644 index 00000000000..3ee7f1c302d --- /dev/null +++ b/homeassistant/components/technove/switch.py @@ -0,0 +1,86 @@ +"""Support for TechnoVE switches.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from technove import Station as TechnoVEStation, TechnoVE + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TechnoVEDataUpdateCoordinator +from .entity import TechnoVEEntity +from .helpers import technove_exception_handler + + +@dataclass(frozen=True, kw_only=True) +class TechnoVESwitchDescription(SwitchEntityDescription): + """Describes TechnoVE binary sensor entity.""" + + is_on_fn: Callable[[TechnoVEStation], bool] + turn_on_fn: Callable[[TechnoVE], Awaitable[dict[str, Any]]] + turn_off_fn: Callable[[TechnoVE], Awaitable[dict[str, Any]]] + + +SWITCHES = [ + TechnoVESwitchDescription( + key="auto_charge", + translation_key="auto_charge", + entity_category=EntityCategory.CONFIG, + is_on_fn=lambda station: station.info.auto_charge, + turn_on_fn=lambda technoVE: technoVE.set_auto_charge(enabled=True), + turn_off_fn=lambda technoVE: technoVE.set_auto_charge(enabled=False), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up TechnoVE switch based on a config entry.""" + coordinator: TechnoVEDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TechnoVESwitchEntity(coordinator, description) for description in SWITCHES + ) + + +class TechnoVESwitchEntity(TechnoVEEntity, SwitchEntity): + """Defines a TechnoVE switch entity.""" + + entity_description: TechnoVESwitchDescription + + def __init__( + self, + coordinator: TechnoVEDataUpdateCoordinator, + description: TechnoVESwitchDescription, + ) -> None: + """Initialize a TechnoVE switch entity.""" + self.entity_description = description + super().__init__(coordinator, description.key) + + @property + def is_on(self) -> bool: + """Return the state of the TechnoVE switch.""" + + return self.entity_description.is_on_fn(self.coordinator.data) + + @technove_exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the TechnoVE switch.""" + await self.entity_description.turn_on_fn(self.coordinator.technove) + await self.coordinator.async_request_refresh() + + @technove_exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the TechnoVE switch.""" + await self.entity_description.turn_off_fn(self.coordinator.technove) + await self.coordinator.async_request_refresh() diff --git a/tests/components/technove/snapshots/test_switch.ambr b/tests/components/technove/snapshots/test_switch.ambr new file mode 100644 index 00000000000..676646dd347 --- /dev/null +++ b/tests/components/technove/snapshots/test_switch.ambr @@ -0,0 +1,46 @@ +# serializer version: 1 +# name: test_switches[switch.technove_station_auto_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.technove_station_auto_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto charge', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_charge', + 'unique_id': 'AA:AA:AA:AA:AA:BB_auto_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.technove_station_auto_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TechnoVE Station Auto charge', + }), + 'context': , + 'entity_id': 'switch.technove_station_auto_charge', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/technove/test_switch.py b/tests/components/technove/test_switch.py new file mode 100644 index 00000000000..d0b709a4eda --- /dev/null +++ b/tests/components/technove/test_switch.py @@ -0,0 +1,158 @@ +"""Tests for the TechnoVE switch platform.""" +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion +from technove import TechnoVEConnectionError, TechnoVEError + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_technove") +async def test_switches( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the creation and values of the TechnoVE switches.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SWITCH]) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") + + +@pytest.mark.parametrize( + ("entity_id", "method", "called_with_on", "called_with_off"), + [ + ( + "switch.technove_station_auto_charge", + "set_auto_charge", + {"enabled": True}, + {"enabled": False}, + ), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_switch_on_off( + hass: HomeAssistant, + mock_technove: MagicMock, + entity_id: str, + method: str, + called_with_on: dict[str, bool | int], + called_with_off: dict[str, bool | int], +) -> None: + """Test on/off services.""" + state = hass.states.get(entity_id) + method_mock = getattr(mock_technove, method) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: state.entity_id}, + blocking=True, + ) + + assert method_mock.call_count == 1 + method_mock.assert_called_with(**called_with_on) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: state.entity_id}, + blocking=True, + ) + + assert method_mock.call_count == 2 + method_mock.assert_called_with(**called_with_off) + + +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ( + "switch.technove_station_auto_charge", + "set_auto_charge", + ), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_invalid_response( + hass: HomeAssistant, + mock_technove: MagicMock, + entity_id: str, + method: str, +) -> None: + """Test invalid response, not becoming unavailable.""" + state = hass.states.get(entity_id) + method_mock = getattr(mock_technove, method) + + method_mock.side_effect = TechnoVEError + with pytest.raises(HomeAssistantError, match="Invalid response from TechnoVE API"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: state.entity_id}, + blocking=True, + ) + + assert method_mock.call_count == 1 + assert (state := hass.states.get(state.entity_id)) + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ( + "switch.technove_station_auto_charge", + "set_auto_charge", + ), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_connection_error( + hass: HomeAssistant, + mock_technove: MagicMock, + entity_id: str, + method: str, +) -> None: + """Test connection error, leading to becoming unavailable.""" + state = hass.states.get(entity_id) + method_mock = getattr(mock_technove, method) + + method_mock.side_effect = TechnoVEConnectionError + with pytest.raises( + HomeAssistantError, match="Error communicating with TechnoVE API" + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: state.entity_id}, + blocking=True, + ) + + assert method_mock.call_count == 1 + assert (state := hass.states.get(state.entity_id)) + assert state.state == STATE_UNAVAILABLE