Bump letpot to 0.5.0 (#148922)

This commit is contained in:
Joris Pelgröm 2025-07-17 08:57:11 +02:00 committed by GitHub
parent ae03fc2295
commit 656822b39c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 105 additions and 51 deletions

View File

@ -6,6 +6,7 @@ import asyncio
from letpot.client import LetPotClient
from letpot.converters import CONVERTERS
from letpot.deviceclient import LetPotDeviceClient
from letpot.exceptions import LetPotAuthenticationException, LetPotException
from letpot.models import AuthenticationInfo
@ -68,8 +69,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bo
except LetPotException as exc:
raise ConfigEntryNotReady from exc
device_client = LetPotDeviceClient(auth)
coordinators: list[LetPotDeviceCoordinator] = [
LetPotDeviceCoordinator(hass, entry, auth, device)
LetPotDeviceCoordinator(hass, entry, device, device_client)
for device in devices
if any(converter.supports_type(device.device_type) for converter in CONVERTERS)
]
@ -92,5 +95,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> b
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
for coordinator in entry.runtime_data:
coordinator.device_client.disconnect()
await coordinator.device_client.unsubscribe(
coordinator.device.serial_number
)
return unload_ok

View File

@ -58,7 +58,9 @@ BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = (
device_class=BinarySensorDeviceClass.RUNNING,
supported_fn=(
lambda coordinator: DeviceFeature.PUMP_STATUS
in coordinator.device_client.device_features
in coordinator.device_client.device_info(
coordinator.device.serial_number
).features
),
),
LetPotBinarySensorEntityDescription(

View File

@ -8,7 +8,7 @@ import logging
from letpot.deviceclient import LetPotDeviceClient
from letpot.exceptions import LetPotAuthenticationException, LetPotException
from letpot.models import AuthenticationInfo, LetPotDevice, LetPotDeviceStatus
from letpot.models import LetPotDevice, LetPotDeviceStatus
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@ -34,8 +34,8 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]):
self,
hass: HomeAssistant,
config_entry: LetPotConfigEntry,
info: AuthenticationInfo,
device: LetPotDevice,
device_client: LetPotDeviceClient,
) -> None:
"""Initialize coordinator."""
super().__init__(
@ -45,9 +45,8 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]):
name=f"LetPot {device.serial_number}",
update_interval=timedelta(minutes=10),
)
self._info = info
self.device = device
self.device_client = LetPotDeviceClient(info, device.serial_number)
self.device_client = device_client
def _handle_status_update(self, status: LetPotDeviceStatus) -> None:
"""Distribute status update to entities."""
@ -56,7 +55,9 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]):
async def _async_setup(self) -> None:
"""Set up subscription for coordinator."""
try:
await self.device_client.subscribe(self._handle_status_update)
await self.device_client.subscribe(
self.device.serial_number, self._handle_status_update
)
except LetPotAuthenticationException as exc:
raise ConfigEntryAuthFailed from exc
@ -64,7 +65,7 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]):
"""Request an update from the device and wait for a status update or timeout."""
try:
async with asyncio.timeout(REQUEST_UPDATE_TIMEOUT):
await self.device_client.get_current_status()
await self.device_client.get_current_status(self.device.serial_number)
except LetPotException as exc:
raise UpdateFailed(exc) from exc

View File

@ -30,12 +30,13 @@ class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]):
def __init__(self, coordinator: LetPotDeviceCoordinator) -> None:
"""Initialize a LetPot entity."""
super().__init__(coordinator)
info = coordinator.device_client.device_info(coordinator.device.serial_number)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.device.serial_number)},
name=coordinator.device.name,
manufacturer="LetPot",
model=coordinator.device_client.device_model_name,
model_id=coordinator.device_client.device_model_code,
model=info.model_name,
model_id=info.model_code,
serial_number=coordinator.device.serial_number,
)

View File

@ -6,6 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/letpot",
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["letpot"],
"quality_scale": "bronze",
"requirements": ["letpot==0.4.0"]
"requirements": ["letpot==0.5.0"]
}

View File

@ -50,7 +50,9 @@ SENSORS: tuple[LetPotSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
supported_fn=(
lambda coordinator: DeviceFeature.TEMPERATURE
in coordinator.device_client.device_features
in coordinator.device_client.device_info(
coordinator.device.serial_number
).features
),
),
LetPotSensorEntityDescription(
@ -61,7 +63,9 @@ SENSORS: tuple[LetPotSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
supported_fn=(
lambda coordinator: DeviceFeature.WATER_LEVEL
in coordinator.device_client.device_features
in coordinator.device_client.device_info(
coordinator.device.serial_number
).features
),
),
)

View File

@ -25,7 +25,7 @@ class LetPotSwitchEntityDescription(LetPotEntityDescription, SwitchEntityDescrip
"""Describes a LetPot switch entity."""
value_fn: Callable[[LetPotDeviceStatus], bool | None]
set_value_fn: Callable[[LetPotDeviceClient, bool], Coroutine[Any, Any, None]]
set_value_fn: Callable[[LetPotDeviceClient, str, bool], Coroutine[Any, Any, None]]
SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = (
@ -33,7 +33,9 @@ SWITCHES: tuple[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),
set_value_fn=(
lambda device_client, serial, value: device_client.set_sound(serial, value)
),
entity_category=EntityCategory.CONFIG,
supported_fn=lambda coordinator: coordinator.data.system_sound is not None,
),
@ -41,25 +43,35 @@ SWITCHES: tuple[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),
set_value_fn=(
lambda device_client, serial, value: device_client.set_water_mode(
serial, value
)
),
entity_category=EntityCategory.CONFIG,
supported_fn=(
lambda coordinator: DeviceFeature.PUMP_AUTO
in coordinator.device_client.device_features
in coordinator.device_client.device_info(
coordinator.device.serial_number
).features
),
),
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),
set_value_fn=lambda device_client, serial, value: device_client.set_power(
serial, 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),
set_value_fn=lambda device_client, serial, value: device_client.set_pump_mode(
serial, value
),
entity_category=EntityCategory.CONFIG,
),
)
@ -104,11 +116,13 @@ class LetPotSwitchEntity(LetPotEntity, SwitchEntity):
@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)
await self.entity_description.set_value_fn(
self.coordinator.device_client, self.coordinator.device.serial_number, 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
self.coordinator.device_client, self.coordinator.device.serial_number, False
)

View File

@ -26,7 +26,7 @@ class LetPotTimeEntityDescription(TimeEntityDescription):
"""Describes a LetPot time entity."""
value_fn: Callable[[LetPotDeviceStatus], time | None]
set_value_fn: Callable[[LetPotDeviceClient, time], Coroutine[Any, Any, None]]
set_value_fn: Callable[[LetPotDeviceClient, str, time], Coroutine[Any, Any, None]]
TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = (
@ -34,8 +34,10 @@ TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = (
key="light_schedule_end",
translation_key="light_schedule_end",
value_fn=lambda status: None if status is None else status.light_schedule_end,
set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule(
start=None, end=value
set_value_fn=(
lambda device_client, serial, value: device_client.set_light_schedule(
serial=serial, start=None, end=value
)
),
entity_category=EntityCategory.CONFIG,
),
@ -43,8 +45,10 @@ TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = (
key="light_schedule_start",
translation_key="light_schedule_start",
value_fn=lambda status: None if status is None else status.light_schedule_start,
set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule(
start=value, end=None
set_value_fn=(
lambda device_client, serial, value: device_client.set_light_schedule(
serial=serial, start=value, end=None
)
),
entity_category=EntityCategory.CONFIG,
),
@ -89,5 +93,5 @@ class LetPotTimeEntity(LetPotEntity, TimeEntity):
async def async_set_value(self, value: time) -> None:
"""Set the time."""
await self.entity_description.set_value_fn(
self.coordinator.device_client, value
self.coordinator.device_client, self.coordinator.device.serial_number, value
)

2
requirements_all.txt generated
View File

@ -1334,7 +1334,7 @@ led-ble==1.1.7
lektricowifi==0.1
# homeassistant.components.letpot
letpot==0.4.0
letpot==0.5.0
# homeassistant.components.foscam
libpyfoscamcgi==0.0.6

View File

@ -1153,7 +1153,7 @@ led-ble==1.1.7
lektricowifi==0.1
# homeassistant.components.letpot
letpot==0.4.0
letpot==0.5.0
# homeassistant.components.foscam
libpyfoscamcgi==0.0.6

View File

@ -3,7 +3,12 @@
from collections.abc import Callable, Generator
from unittest.mock import AsyncMock, patch
from letpot.models import DeviceFeature, LetPotDevice, LetPotDeviceStatus
from letpot.models import (
DeviceFeature,
LetPotDevice,
LetPotDeviceInfo,
LetPotDeviceStatus,
)
import pytest
from homeassistant.components.letpot.const import (
@ -26,6 +31,16 @@ def device_type() -> str:
return "LPH63"
def _mock_device_info(device_type: str) -> LetPotDeviceInfo:
"""Return mock device info for the given type."""
return LetPotDeviceInfo(
model=device_type,
model_name=f"LetPot {device_type}",
model_code=device_type,
features=_mock_device_features(device_type),
)
def _mock_device_features(device_type: str) -> DeviceFeature:
"""Return mock device feature support for the given type."""
if device_type == "LPH31":
@ -89,32 +104,33 @@ def mock_client(device_type: str) -> Generator[AsyncMock]:
@pytest.fixture
def mock_device_client(device_type: str) -> Generator[AsyncMock]:
def mock_device_client() -> Generator[AsyncMock]:
"""Mock a LetPotDeviceClient."""
with patch(
"homeassistant.components.letpot.coordinator.LetPotDeviceClient",
"homeassistant.components.letpot.LetPotDeviceClient",
autospec=True,
) as mock_device_client:
device_client = mock_device_client.return_value
device_client.device_features = _mock_device_features(device_type)
device_client.device_model_code = device_type
device_client.device_model_name = f"LetPot {device_type}"
device_status = _mock_device_status(device_type)
subscribe_callbacks: list[Callable] = []
subscribe_callbacks: dict[str, Callable] = {}
def subscribe_side_effect(callback: Callable) -> None:
subscribe_callbacks.append(callback)
def subscribe_side_effect(serial: str, callback: Callable) -> None:
subscribe_callbacks[serial] = callback
def status_side_effect() -> None:
# Deliver a status update to any subscribers, like the real client
for callback in subscribe_callbacks:
callback(device_status)
def request_status_side_effect(serial: str) -> None:
# Deliver a status update to the subscriber, like the real client
if (callback := subscribe_callbacks.get(serial)) is not None:
callback(_mock_device_status(serial[:5]))
device_client.get_current_status.side_effect = status_side_effect
device_client.get_current_status.return_value = device_status
device_client.last_status.return_value = device_status
device_client.request_status_update.side_effect = status_side_effect
def get_current_status_side_effect(serial: str) -> LetPotDeviceStatus:
request_status_side_effect(serial)
return _mock_device_status(serial[:5])
device_client.device_info.side_effect = lambda serial: _mock_device_info(
serial[:5]
)
device_client.get_current_status.side_effect = get_current_status_side_effect
device_client.request_status_update.side_effect = request_status_side_effect
device_client.subscribe.side_effect = subscribe_side_effect
yield device_client

View File

@ -37,7 +37,7 @@ async def test_load_unload_config_entry(
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
mock_device_client.disconnect.assert_called_once()
mock_device_client.unsubscribe.assert_called_once()
@pytest.mark.freeze_time("2025-02-15 00:00:00")

View File

@ -58,6 +58,7 @@ async def test_set_switch(
mock_config_entry: MockConfigEntry,
mock_client: MagicMock,
mock_device_client: MagicMock,
device_type: str,
service: str,
parameter_value: bool,
) -> None:
@ -71,7 +72,9 @@ async def test_set_switch(
target={"entity_id": "switch.garden_power"},
)
mock_device_client.set_power.assert_awaited_once_with(parameter_value)
mock_device_client.set_power.assert_awaited_once_with(
f"{device_type}ABCD", parameter_value
)
@pytest.mark.parametrize(

View File

@ -38,6 +38,7 @@ async def test_set_time(
mock_config_entry: MockConfigEntry,
mock_client: MagicMock,
mock_device_client: MagicMock,
device_type: str,
) -> None:
"""Test setting the time entity."""
await setup_integration(hass, mock_config_entry)
@ -50,7 +51,9 @@ async def test_set_time(
target={"entity_id": "time.garden_light_on"},
)
mock_device_client.set_light_schedule.assert_awaited_once_with(time(7, 0), None)
mock_device_client.set_light_schedule.assert_awaited_once_with(
f"{device_type}ABCD", time(7, 0), None
)
@pytest.mark.parametrize(