From be25603b76a316232f0a79f181a9522f0ef50180 Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:11:47 +0200 Subject: [PATCH] Refactor optimistic update and delayed refresh for Liebherr integration (#163121) --- homeassistant/components/liebherr/const.py | 3 ++ homeassistant/components/liebherr/entity.py | 30 ++++++++++++++- homeassistant/components/liebherr/number.py | 37 ++++++------------- .../components/liebherr/strings.json | 2 +- homeassistant/components/liebherr/switch.py | 35 +----------------- tests/components/liebherr/conftest.py | 11 ++++++ tests/components/liebherr/test_number.py | 7 +++- tests/components/liebherr/test_switch.py | 2 +- 8 files changed, 64 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/liebherr/const.py b/homeassistant/components/liebherr/const.py index f02c28e46d1..82af6817c09 100644 --- a/homeassistant/components/liebherr/const.py +++ b/homeassistant/components/liebherr/const.py @@ -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) diff --git a/homeassistant/components/liebherr/entity.py b/homeassistant/components/liebherr/entity.py index 1e5dc7ca385..eb343491dce 100644 --- a/homeassistant/components/liebherr/entity.py +++ b/homeassistant/components/liebherr/entity.py @@ -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. diff --git a/homeassistant/components/liebherr/number.py b/homeassistant/components/liebherr/number.py index 0841d29174a..6ba938e0a2c 100644 --- a/homeassistant/components/liebherr/number.py +++ b/homeassistant/components/liebherr/number.py @@ -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() + ), + ) diff --git a/homeassistant/components/liebherr/strings.json b/homeassistant/components/liebherr/strings.json index 3549760f577..dd4af5c6d5a 100644 --- a/homeassistant/components/liebherr/strings.json +++ b/homeassistant/components/liebherr/strings.json @@ -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" } } } diff --git a/homeassistant/components/liebherr/switch.py b/homeassistant/components/liebherr/switch.py index db07860d677..c956fa163c1 100644 --- a/homeassistant/components/liebherr/switch.py +++ b/homeassistant/components/liebherr/switch.py @@ -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): diff --git a/tests/components/liebherr/conftest.py b/tests/components/liebherr/conftest.py index 536b76a34b1..f3a253ea022 100644 --- a/tests/components/liebherr/conftest.py +++ b/tests/components/liebherr/conftest.py @@ -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.""" diff --git a/tests/components/liebherr/test_number.py b/tests/components/liebherr/test_number.py index 480df1413e0..95ccdc6bfa8 100644 --- a/tests/components/liebherr/test_number.py +++ b/tests/components/liebherr/test_number.py @@ -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, diff --git a/tests/components/liebherr/test_switch.py b/tests/components/liebherr/test_switch.py index 9bed382f48f..3fcfd79cd09 100644 --- a/tests/components/liebherr/test_switch.py +++ b/tests/components/liebherr/test_switch.py @@ -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,