diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index 905887463d7..5bfc4edfc0c 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -23,7 +23,7 @@ from .const import ( ) from .coordinator import LetPotDeviceCoordinator -PLATFORMS: list[Platform] = [Platform.TIME] +PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.TIME] type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]] diff --git a/homeassistant/components/letpot/icons.json b/homeassistant/components/letpot/icons.json new file mode 100644 index 00000000000..2a2b727adcd --- /dev/null +++ b/homeassistant/components/letpot/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "switch": { + "alarm_sound": { + "default": "mdi:bell-ring", + "state": { + "off": "mdi:bell-off" + } + }, + "auto_mode": { + "default": "mdi:water-pump", + "state": { + "off": "mdi:water-pump-off" + } + }, + "pump_cycling": { + "default": "mdi:pump", + "state": { + "off": "mdi:pump-off" + } + } + } + } +} diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 7f8c3d3c04c..0eda413a461 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -64,7 +64,7 @@ rules: entity-disabled-by-default: todo entity-translations: done exception-translations: done - icon-translations: todo + icon-translations: done reconfiguration-flow: todo repair-issues: todo stale-devices: todo diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json index 94d3ad02cfa..12913085644 100644 --- a/homeassistant/components/letpot/strings.json +++ b/homeassistant/components/letpot/strings.json @@ -32,6 +32,20 @@ } }, "entity": { + "switch": { + "alarm_sound": { + "name": "Alarm sound" + }, + "auto_mode": { + "name": "Auto mode" + }, + "power": { + "name": "Power" + }, + "pump_cycling": { + "name": "Pump cycling" + } + }, "time": { "light_schedule_end": { "name": "Light off" diff --git a/homeassistant/components/letpot/switch.py b/homeassistant/components/letpot/switch.py new file mode 100644 index 00000000000..36d07276c48 --- /dev/null +++ b/homeassistant/components/letpot/switch.py @@ -0,0 +1,119 @@ +"""Support for LetPot switch entities.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from letpot.deviceclient import LetPotDeviceClient +from letpot.models import DeviceFeature, LetPotDeviceStatus + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import LetPotConfigEntry +from .coordinator import LetPotDeviceCoordinator +from .entity import LetPotEntity, exception_handler + +# Each change pushes a 'full' device status with the change. The library will cache +# pending changes to avoid overwriting, but try to avoid a lot of parallelism. +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class LetPotSwitchEntityDescription(SwitchEntityDescription): + """Describes a LetPot switch entity.""" + + value_fn: Callable[[LetPotDeviceStatus], bool | None] + set_value_fn: Callable[[LetPotDeviceClient, bool], Coroutine[Any, Any, None]] + + +BASE_SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( + LetPotSwitchEntityDescription( + key="power", + translation_key="power", + value_fn=lambda status: status.system_on, + set_value_fn=lambda device_client, value: device_client.set_power(value), + entity_category=EntityCategory.CONFIG, + ), + LetPotSwitchEntityDescription( + key="pump_cycling", + translation_key="pump_cycling", + value_fn=lambda status: status.pump_mode == 1, + set_value_fn=lambda device_client, value: device_client.set_pump_mode(value), + entity_category=EntityCategory.CONFIG, + ), +) +ALARM_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription( + key="alarm_sound", + translation_key="alarm_sound", + value_fn=lambda status: status.system_sound, + set_value_fn=lambda device_client, value: device_client.set_sound(value), + entity_category=EntityCategory.CONFIG, +) +AUTO_MODE_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription( + key="auto_mode", + translation_key="auto_mode", + value_fn=lambda status: status.water_mode == 1, + set_value_fn=lambda device_client, value: device_client.set_water_mode(value), + entity_category=EntityCategory.CONFIG, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LetPotConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up LetPot switch entities based on a config entry and device status/features.""" + coordinators = entry.runtime_data + entities: list[SwitchEntity] = [ + LetPotSwitchEntity(coordinator, description) + for description in BASE_SWITCHES + for coordinator in coordinators + ] + entities.extend( + LetPotSwitchEntity(coordinator, ALARM_SWITCH) + for coordinator in coordinators + if coordinator.data.system_sound is not None + ) + entities.extend( + LetPotSwitchEntity(coordinator, AUTO_MODE_SWITCH) + for coordinator in coordinators + if DeviceFeature.PUMP_AUTO in coordinator.device_client.device_features + ) + async_add_entities(entities) + + +class LetPotSwitchEntity(LetPotEntity, SwitchEntity): + """Defines a LetPot switch entity.""" + + entity_description: LetPotSwitchEntityDescription + + def __init__( + self, + coordinator: LetPotDeviceCoordinator, + description: LetPotSwitchEntityDescription, + ) -> None: + """Initialize LetPot switch entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + + @property + def is_on(self) -> bool | None: + """Return if the entity is on.""" + return self.entity_description.value_fn(self.coordinator.data) + + @exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.entity_description.set_value_fn(self.coordinator.device_client, True) + + @exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.entity_description.set_value_fn( + self.coordinator.device_client, False + ) diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py index 7971bca50ae..3e948ad0ac2 100644 --- a/tests/components/letpot/conftest.py +++ b/tests/components/letpot/conftest.py @@ -1,6 +1,6 @@ """Common fixtures for the LetPot tests.""" -from collections.abc import Generator +from collections.abc import Callable, Generator from unittest.mock import AsyncMock, patch from letpot.models import LetPotDevice @@ -67,8 +67,23 @@ def mock_device_client() -> Generator[AsyncMock]: device_client = mock_device_client.return_value device_client.device_model_code = "LPH21" device_client.device_model_name = "LetPot Air" + + subscribe_callbacks: list[Callable] = [] + + def subscribe_side_effect(callback: Callable) -> None: + subscribe_callbacks.append(callback) + + def status_side_effect() -> None: + # Deliver a status update to any subscribers, like the real client + for callback in subscribe_callbacks: + callback(STATUS) + + device_client.get_current_status.side_effect = status_side_effect device_client.get_current_status.return_value = STATUS device_client.last_status.return_value = STATUS + device_client.request_status_update.side_effect = status_side_effect + device_client.subscribe.side_effect = subscribe_side_effect + yield device_client diff --git a/tests/components/letpot/test_switch.py b/tests/components/letpot/test_switch.py new file mode 100644 index 00000000000..d51721c3348 --- /dev/null +++ b/tests/components/letpot/test_switch.py @@ -0,0 +1,53 @@ +"""Test switch entities for the LetPot integration.""" + +from unittest.mock import MagicMock + +from letpot.exceptions import LetPotConnectionException, LetPotException +import pytest + +from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("service", "exception", "user_error"), + [ + ( + SERVICE_TURN_ON, + LetPotConnectionException("Connection failed"), + "An error occurred while communicating with the LetPot device: Connection failed", + ), + ( + SERVICE_TURN_OFF, + LetPotException("Random thing failed"), + "An unknown error occurred while communicating with the LetPot device: Random thing failed", + ), + ], +) +async def test_switch_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + service: str, + exception: Exception, + user_error: str, +) -> None: + """Test switch entity exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_device_client.set_power.side_effect = exception + + assert hass.states.get("switch.garden_power") is not None + with pytest.raises(HomeAssistantError, match=user_error): + await hass.services.async_call( + "switch", + service, + blocking=True, + target={"entity_id": "switch.garden_power"}, + )