Refactor optimistic update and delayed refresh for Liebherr integration (#163121)

This commit is contained in:
mettolen
2026-02-19 00:11:47 +02:00
committed by GitHub
parent 2e0f727981
commit be25603b76
8 changed files with 64 additions and 63 deletions

View File

@@ -1,6 +1,9 @@
"""Constants for the liebherr integration."""
from datetime import timedelta
from typing import Final
DOMAIN: Final = "liebherr"
MANUFACTURER: Final = "Liebherr"
REFRESH_DELAY: Final = timedelta(seconds=5)

View File

@@ -2,12 +2,22 @@
from __future__ import annotations
from pyliebherrhomeapi import TemperatureControl, ZonePosition
import asyncio
from collections.abc import Coroutine
from typing import Any
from pyliebherrhomeapi import (
LiebherrConnectionError,
LiebherrTimeoutError,
TemperatureControl,
ZonePosition,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .const import DOMAIN, MANUFACTURER, REFRESH_DELAY
from .coordinator import LiebherrCoordinator
# Zone position to translation key mapping
@@ -44,6 +54,22 @@ class LiebherrEntity(CoordinatorEntity[LiebherrCoordinator]):
model_id=device.device_name,
)
async def _async_send_command(
self,
command: Coroutine[Any, Any, None],
) -> None:
"""Send a command with error handling and delayed refresh."""
try:
await command
except (LiebherrConnectionError, LiebherrTimeoutError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
) from err
await asyncio.sleep(REFRESH_DELAY.total_seconds())
await self.coordinator.async_request_refresh()
class LiebherrZoneEntity(LiebherrEntity):
"""Base entity for zone-based Liebherr entities.

View File

@@ -4,13 +4,9 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from pyliebherrhomeapi import (
LiebherrConnectionError,
LiebherrTimeoutError,
TemperatureControl,
TemperatureUnit,
)
from pyliebherrhomeapi import TemperatureControl, TemperatureUnit
from homeassistant.components.number import (
DEFAULT_MAX_VALUE,
@@ -21,10 +17,8 @@ from homeassistant.components.number import (
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
from .entity import LiebherrZoneEntity
@@ -109,10 +103,9 @@ class LiebherrNumber(LiebherrZoneEntity, NumberEntity):
@property
def native_value(self) -> float | None:
"""Return the current value."""
# temperature_control is guaranteed to exist when entity is available
return self.entity_description.value_fn(
self.temperature_control # type: ignore[arg-type]
)
if TYPE_CHECKING:
assert self.temperature_control is not None
return self.entity_description.value_fn(self.temperature_control)
@property
def native_min_value(self) -> float:
@@ -139,27 +132,21 @@ class LiebherrNumber(LiebherrZoneEntity, NumberEntity):
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
# temperature_control is guaranteed to exist when entity is available
if TYPE_CHECKING:
assert self.temperature_control is not None
temp_control = self.temperature_control
unit = (
TemperatureUnit.FAHRENHEIT
if temp_control.unit == TemperatureUnit.FAHRENHEIT # type: ignore[union-attr]
if temp_control.unit == TemperatureUnit.FAHRENHEIT
else TemperatureUnit.CELSIUS
)
try:
await self.coordinator.client.set_temperature(
await self._async_send_command(
self.coordinator.client.set_temperature(
device_id=self.coordinator.device_id,
zone_id=self._zone_id,
target=int(value),
unit=unit,
)
except (LiebherrConnectionError, LiebherrTimeoutError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(err)},
) from err
await self.coordinator.async_request_refresh()
),
)

View File

@@ -93,7 +93,7 @@
},
"exceptions": {
"communication_error": {
"message": "An error occurred while communicating with the device: {error}"
"message": "An error occurred while communicating with the device"
}
}
}

View File

@@ -2,29 +2,20 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from pyliebherrhomeapi import (
LiebherrConnectionError,
LiebherrTimeoutError,
ToggleControl,
ZonePosition,
)
from pyliebherrhomeapi import ToggleControl, ZonePosition
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
from .entity import ZONE_POSITION_MAP, LiebherrEntity
PARALLEL_UPDATES = 1
REFRESH_DELAY = 5
# Control names from the API
CONTROL_SUPERCOOL = "supercool"
@@ -144,7 +135,6 @@ class LiebherrDeviceSwitch(LiebherrEntity, SwitchEntity):
entity_description: LiebherrSwitchEntityDescription
_zone_id: int | None = None
_optimistic_state: bool | None = None
def __init__(
self,
@@ -171,17 +161,10 @@ class LiebherrDeviceSwitch(LiebherrEntity, SwitchEntity):
@property
def is_on(self) -> bool | None:
"""Return true if the switch is on."""
if self._optimistic_state is not None:
return self._optimistic_state
if TYPE_CHECKING:
assert self._toggle_control is not None
return self._toggle_control.value
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._optimistic_state = None
super()._handle_coordinator_update()
@property
def available(self) -> bool:
"""Return if entity is available."""
@@ -205,21 +188,7 @@ class LiebherrDeviceSwitch(LiebherrEntity, SwitchEntity):
async def _async_set_value(self, value: bool) -> None:
"""Set the switch value."""
try:
await self._async_call_set_fn(value)
except (LiebherrConnectionError, LiebherrTimeoutError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(err)},
) from err
# Track expected state locally to avoid mutating shared coordinator data
self._optimistic_state = value
self.async_write_ha_state()
await asyncio.sleep(REFRESH_DELAY)
await self.coordinator.async_request_refresh()
await self._async_send_command(self._async_call_set_fn(value))
class LiebherrZoneSwitch(LiebherrDeviceSwitch):

View File

@@ -2,6 +2,7 @@
from collections.abc import Generator
import copy
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from pyliebherrhomeapi import (
@@ -86,6 +87,16 @@ MOCK_DEVICE_STATE = DeviceState(
)
@pytest.fixture(autouse=True)
def patch_refresh_delay() -> Generator[None]:
"""Patch REFRESH_DELAY to 0 to avoid delays in tests."""
with patch(
"homeassistant.components.liebherr.entity.REFRESH_DELAY",
timedelta(seconds=0),
):
yield
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""

View File

@@ -172,6 +172,8 @@ async def test_set_temperature(
"""Test setting the temperature."""
entity_id = "number.test_fridge_top_zone_setpoint"
initial_call_count = mock_liebherr_client.get_device_state.call_count
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
@@ -186,6 +188,9 @@ async def test_set_temperature(
unit=TemperatureUnit.CELSIUS,
)
# Verify coordinator refresh was triggered
assert mock_liebherr_client.get_device_state.call_count > initial_call_count
@pytest.mark.usefixtures("init_integration")
async def test_set_temperature_failure(
@@ -201,7 +206,7 @@ async def test_set_temperature_failure(
with pytest.raises(
HomeAssistantError,
match="An error occurred while communicating with the device: Connection failed",
match="An error occurred while communicating with the device",
):
await hass.services.async_call(
NUMBER_DOMAIN,

View File

@@ -140,7 +140,7 @@ async def test_switch_failure(
with pytest.raises(
HomeAssistantError,
match="An error occurred while communicating with the device: Connection failed",
match="An error occurred while communicating with the device",
):
await hass.services.async_call(
SWITCH_DOMAIN,