Add switch platform to LetPot integration (#136383)

* Add switch platform to LetPot integration

* deviceclient -> device_client

* Remove coordinator data None check

* Add exception handling + test
pull/136936/head^2
Joris Pelgröm 2025-02-01 08:15:36 +01:00 committed by GitHub
parent bb61e31298
commit 012f7112d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 228 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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