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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/letpot", "documentation": "https://www.home-assistant.io/integrations/letpot",
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["letpot"],
"quality_scale": "bronze", "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, state_class=SensorStateClass.MEASUREMENT,
supported_fn=( supported_fn=(
lambda coordinator: DeviceFeature.TEMPERATURE lambda coordinator: DeviceFeature.TEMPERATURE
in coordinator.device_client.device_features in coordinator.device_client.device_info(
coordinator.device.serial_number
).features
), ),
), ),
LetPotSensorEntityDescription( LetPotSensorEntityDescription(
@ -61,7 +63,9 @@ SENSORS: tuple[LetPotSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
supported_fn=( supported_fn=(
lambda coordinator: DeviceFeature.WATER_LEVEL 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.""" """Describes a LetPot switch entity."""
value_fn: Callable[[LetPotDeviceStatus], bool | None] 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, ...] = ( SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = (
@ -33,7 +33,9 @@ SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = (
key="alarm_sound", key="alarm_sound",
translation_key="alarm_sound", translation_key="alarm_sound",
value_fn=lambda status: status.system_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, entity_category=EntityCategory.CONFIG,
supported_fn=lambda coordinator: coordinator.data.system_sound is not None, supported_fn=lambda coordinator: coordinator.data.system_sound is not None,
), ),
@ -41,25 +43,35 @@ SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = (
key="auto_mode", key="auto_mode",
translation_key="auto_mode", translation_key="auto_mode",
value_fn=lambda status: status.water_mode == 1, 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, entity_category=EntityCategory.CONFIG,
supported_fn=( supported_fn=(
lambda coordinator: DeviceFeature.PUMP_AUTO lambda coordinator: DeviceFeature.PUMP_AUTO
in coordinator.device_client.device_features in coordinator.device_client.device_info(
coordinator.device.serial_number
).features
), ),
), ),
LetPotSwitchEntityDescription( LetPotSwitchEntityDescription(
key="power", key="power",
translation_key="power", translation_key="power",
value_fn=lambda status: status.system_on, 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, entity_category=EntityCategory.CONFIG,
), ),
LetPotSwitchEntityDescription( LetPotSwitchEntityDescription(
key="pump_cycling", key="pump_cycling",
translation_key="pump_cycling", translation_key="pump_cycling",
value_fn=lambda status: status.pump_mode == 1, 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, entity_category=EntityCategory.CONFIG,
), ),
) )
@ -104,11 +116,13 @@ class LetPotSwitchEntity(LetPotEntity, SwitchEntity):
@exception_handler @exception_handler
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on.""" """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 @exception_handler
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off.""" """Turn the entity off."""
await self.entity_description.set_value_fn( 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.""" """Describes a LetPot time entity."""
value_fn: Callable[[LetPotDeviceStatus], time | None] 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, ...] = ( TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = (
@ -34,8 +34,10 @@ TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = (
key="light_schedule_end", key="light_schedule_end",
translation_key="light_schedule_end", translation_key="light_schedule_end",
value_fn=lambda status: None if status is None else status.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( set_value_fn=(
start=None, end=value lambda device_client, serial, value: device_client.set_light_schedule(
serial=serial, start=None, end=value
)
), ),
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
@ -43,8 +45,10 @@ TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = (
key="light_schedule_start", key="light_schedule_start",
translation_key="light_schedule_start", translation_key="light_schedule_start",
value_fn=lambda status: None if status is None else status.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( set_value_fn=(
start=value, end=None lambda device_client, serial, value: device_client.set_light_schedule(
serial=serial, start=value, end=None
)
), ),
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
@ -89,5 +93,5 @@ class LetPotTimeEntity(LetPotEntity, TimeEntity):
async def async_set_value(self, value: time) -> None: async def async_set_value(self, value: time) -> None:
"""Set the time.""" """Set the time."""
await self.entity_description.set_value_fn( 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 lektricowifi==0.1
# homeassistant.components.letpot # homeassistant.components.letpot
letpot==0.4.0 letpot==0.5.0
# homeassistant.components.foscam # homeassistant.components.foscam
libpyfoscamcgi==0.0.6 libpyfoscamcgi==0.0.6

View File

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

View File

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

View File

@ -37,7 +37,7 @@ async def test_load_unload_config_entry(
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED 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") @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_config_entry: MockConfigEntry,
mock_client: MagicMock, mock_client: MagicMock,
mock_device_client: MagicMock, mock_device_client: MagicMock,
device_type: str,
service: str, service: str,
parameter_value: bool, parameter_value: bool,
) -> None: ) -> None:
@ -71,7 +72,9 @@ async def test_set_switch(
target={"entity_id": "switch.garden_power"}, 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( @pytest.mark.parametrize(

View File

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