Enhance Liebherr integration with optimistic state updates for controls (#181603)

This commit is contained in:
mettolen
2026-09-22 14:15:26 +02:00
committed by GitHub
parent 1923c2e88d
commit 22c95623c6
10 changed files with 244 additions and 27 deletions
@@ -1,9 +1,10 @@
"""DataUpdateCoordinator for Liebherr integration."""
import asyncio
from collections.abc import Callable
from dataclasses import dataclass, field, replace
import logging
from typing import override
from typing import cast, override
from pyliebherrhomeapi import (
DeviceControl,
@@ -157,20 +158,45 @@ class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]):
self._replace_next_event = False
new_state = replace(self.data, controls=list(controls))
else:
merged: dict[tuple[type[DeviceControl], str, int | None], DeviceControl] = {
(
type(control),
control.name,
getattr(control, "zone_id", None),
): control
for control in self.data.controls
}
for control in controls:
key = (type(control), control.name, getattr(control, "zone_id", None))
merged[key] = control
new_state = replace(self.data, controls=list(merged.values()))
new_state = self._merged_state(controls)
self.async_set_updated_data(new_state)
@callback
def async_apply_control[ControlT: DeviceControl](
self, control: ControlT, updater: Callable[[ControlT], ControlT]
) -> None:
"""Optimistically update the latest cached version of a control."""
assert self.data is not None
key = (type(control), control.name, getattr(control, "zone_id", None))
for cached_control in self.data.controls:
cached_key = (
type(cached_control),
cached_control.name,
getattr(cached_control, "zone_id", None),
)
if cached_key == key:
self.data = self._merged_state(
[updater(cast(ControlT, cached_control))]
)
self.async_update_listeners()
return
def _merged_state(self, controls: list[DeviceControl]) -> DeviceState:
"""Return coordinator state with control updates merged."""
assert self.data is not None
merged: dict[tuple[type[DeviceControl], str, int | None], DeviceControl] = {
(
type(control),
control.name,
getattr(control, "zone_id", None),
): control
for control in self.data.controls
}
for control in controls:
key = (type(control), control.name, getattr(control, "zone_id", None))
merged[key] = control
return replace(self.data, controls=list(merged.values()))
@callback
def _handle_stream_connected(self) -> None:
"""Handle SSE (re)connect: next event carries the full state."""
+9 -6
View File
@@ -1,9 +1,10 @@
"""Base entity for Liebherr integration."""
from collections.abc import Coroutine
from collections.abc import Callable, Coroutine
from typing import Any
from pyliebherrhomeapi import (
DeviceControl,
LiebherrConnectionError,
LiebherrTimeoutError,
TemperatureControl,
@@ -51,14 +52,13 @@ class LiebherrEntity(CoordinatorEntity[LiebherrCoordinator]):
model_id=device.device_name,
)
async def _async_send_command(
async def _async_send_command[ControlT: DeviceControl](
self,
command: Coroutine[Any, Any, None],
control: ControlT | None = None,
updater: Callable[[ControlT], ControlT] | None = None,
) -> None:
"""Send a command with error handling.
State updates arrive via the SSE stream — no explicit refresh needed.
"""
"""Send a command and optimistically apply its successful result."""
try:
await command
except (LiebherrConnectionError, LiebherrTimeoutError) as err:
@@ -66,6 +66,9 @@ class LiebherrEntity(CoordinatorEntity[LiebherrCoordinator]):
translation_domain=DOMAIN,
translation_key="communication_error",
) from err
if control is not None:
assert updater is not None
self.coordinator.async_apply_control(control, updater)
class LiebherrZoneEntity(LiebherrEntity):
+10 -2
View File
@@ -1,5 +1,6 @@
"""Light platform for Liebherr integration."""
from dataclasses import replace
import math
from typing import TYPE_CHECKING, Any, override
@@ -122,15 +123,22 @@ class LiebherrPresentationLight(LiebherrEntity, LightEntity):
self.coordinator.client.set_presentation_light(
device_id=self.coordinator.device_id,
target=target,
)
),
control,
lambda control: replace(control, value=target),
)
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
control = self._light_control
if TYPE_CHECKING:
assert control is not None
await self._async_send_command(
self.coordinator.client.set_presentation_light(
device_id=self.coordinator.device_id,
target=0,
)
),
control,
lambda control: replace(control, value=0),
)
+3 -1
View File
@@ -1,7 +1,7 @@
"""Number platform for Liebherr integration."""
from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import dataclass, replace
from itertools import pairwise
from typing import TYPE_CHECKING, override
@@ -214,4 +214,6 @@ class LiebherrNumber(LiebherrZoneEntity, NumberEntity):
target=target,
unit=unit,
),
temp_control,
lambda control: replace(control, target=target, unit=unit),
)
+17 -2
View File
@@ -1,9 +1,9 @@
"""Select platform for Liebherr integration."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from dataclasses import dataclass, replace
from enum import StrEnum
from typing import TYPE_CHECKING, Any, override
from typing import TYPE_CHECKING, Any, cast, override
from pyliebherrhomeapi import (
BioFreshPlusControl,
@@ -29,6 +29,13 @@ PARALLEL_UPDATES = 1
type SelectControl = IceMakerControl | HydroBreezeControl | BioFreshPlusControl
def _replace_mode(control: SelectControl, mode: StrEnum) -> SelectControl:
"""Replace the current mode of a select control."""
if isinstance(control, IceMakerControl):
return replace(control, ice_maker_mode=mode)
return replace(control, current_mode=mode)
@dataclass(frozen=True, kw_only=True)
class LiebherrSelectEntityDescription(SelectEntityDescription):
"""Describes a Liebherr select entity."""
@@ -233,6 +240,14 @@ class LiebherrSelectEntity(LiebherrEntity, SelectEntity):
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
mode = self.entity_description.mode_enum(option)
control = self._select_control
if TYPE_CHECKING:
assert isinstance(
control,
IceMakerControl | HydroBreezeControl | BioFreshPlusControl,
)
await self._async_send_command(
self.entity_description.set_fn(self.coordinator, self._zone_id, mode),
control,
lambda control: _replace_mode(cast(SelectControl, control), mode),
)
+9 -2
View File
@@ -1,7 +1,7 @@
"""Switch platform for Liebherr integration."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any, override
from pyliebherrhomeapi import ToggleControl, ZonePosition
@@ -212,7 +212,14 @@ class LiebherrDeviceSwitch(LiebherrEntity, SwitchEntity):
async def _async_set_value(self, value: bool) -> None:
"""Set the switch value."""
await self._async_send_command(self._async_call_set_fn(value))
control = self._toggle_control
if TYPE_CHECKING:
assert control is not None
await self._async_send_command(
self._async_call_set_fn(value),
control,
lambda control: replace(control, value=value),
)
class LiebherrZoneSwitch(LiebherrDeviceSwitch):
+17
View File
@@ -100,6 +100,23 @@ async def test_light_service_calls(
)
@pytest.mark.usefixtures("init_integration")
async def test_light_updates_optimistically(hass: HomeAssistant) -> None:
"""Test light state updates before an SSE event arrives."""
entity_id = "light.test_fridge_presentation_light"
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_OFF
@pytest.mark.usefixtures("init_integration")
async def test_light_failure(
hass: HomeAssistant,
+102 -1
View File
@@ -1,6 +1,8 @@
"""Test the Liebherr number platform."""
import asyncio
import copy
from dataclasses import replace
from unittest.mock import MagicMock, patch
from pyliebherrhomeapi import (
@@ -28,7 +30,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from .conftest import MOCK_DEVICE, SSEStreamHelper
from .conftest import MOCK_DEVICE, MOCK_DEVICE_STATE, SSEStreamHelper
from tests.common import MockConfigEntry, snapshot_platform
@@ -132,6 +134,105 @@ async def test_set_temperature(
target=6,
unit=TemperatureUnit.CELSIUS,
)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "6"
@pytest.mark.usefixtures("init_integration")
async def test_set_temperature_preserves_sse_update_during_command(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
mock_config_entry: MockConfigEntry,
sse_helper: SSEStreamHelper,
) -> None:
"""Test an SSE update received during a command is preserved."""
command_started = asyncio.Event()
release_command = asyncio.Event()
async def set_temperature(**kwargs: object) -> None:
command_started.set()
await release_command.wait()
mock_liebherr_client.set_temperature.side_effect = set_temperature
service_call = hass.async_create_task(
hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: "number.test_fridge_top_zone_setpoint",
ATTR_VALUE: 6,
},
blocking=True,
)
)
await command_started.wait()
temperature_control = MOCK_DEVICE_STATE.get_temperature_controls()[1]
mock_liebherr_client.get_device_state.side_effect = lambda *args, **kwargs: (
DeviceState(
device=MOCK_DEVICE,
controls=[replace(temperature_control, value=7)],
)
)
coordinator = mock_config_entry.runtime_data.coordinators[MOCK_DEVICE.device_id]
sse_updated = asyncio.Event()
remove_listener = coordinator.async_add_listener(sse_updated.set)
push_task = hass.async_create_task(sse_helper.async_push())
await sse_updated.wait()
remove_listener()
release_command.set()
await asyncio.gather(service_call, push_task)
cached_control = coordinator.data.get_temperature_controls()[1]
assert cached_control.value == 7
assert cached_control.target == 6
@pytest.mark.usefixtures("init_integration")
async def test_set_temperature_preserves_sse_disconnect_during_command(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
mock_config_entry: MockConfigEntry,
sse_helper: SSEStreamHelper,
) -> None:
"""Test an SSE disconnect during a command keeps the entity unavailable."""
command_started = asyncio.Event()
release_command = asyncio.Event()
async def set_temperature(**kwargs: object) -> None:
command_started.set()
await release_command.wait()
mock_liebherr_client.set_temperature.side_effect = set_temperature
service_call = hass.async_create_task(
hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: "number.test_fridge_top_zone_setpoint",
ATTR_VALUE: 6,
},
blocking=True,
)
)
await command_started.wait()
mock_liebherr_client.get_device_state.side_effect = LiebherrConnectionError
coordinator = mock_config_entry.runtime_data.coordinators[MOCK_DEVICE.device_id]
disconnected = asyncio.Event()
remove_listener = coordinator.async_add_listener(disconnected.set)
push_task = hass.async_create_task(sse_helper.async_push())
await disconnected.wait()
remove_listener()
release_command.set()
await asyncio.gather(service_call, push_task)
assert not coordinator.last_update_success
assert coordinator.data.get_temperature_controls()[1].target == 6
state = hass.states.get("number.test_fridge_top_zone_setpoint")
assert state is not None
assert state.state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("init_integration")
+17
View File
@@ -139,6 +139,23 @@ async def test_select_service_calls(
getattr(mock_liebherr_client, method).assert_called_once_with(**kwargs)
@pytest.mark.usefixtures("init_integration")
async def test_select_updates_optimistically(hass: HomeAssistant) -> None:
"""Test select state updates before an SSE event arrives."""
entity_id = "select.test_fridge_bottom_zone_icemaker"
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: IceMakerMode.ON.value},
blocking=True,
)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == IceMakerMode.ON.value
@pytest.mark.parametrize(
("entity_id", "method", "option"),
[
+21
View File
@@ -23,6 +23,7 @@ from homeassistant.const import (
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
Platform,
)
@@ -112,6 +113,26 @@ async def test_switch_service_calls(
getattr(mock_liebherr_client, method).assert_called_once_with(**kwargs)
@pytest.mark.usefixtures("init_integration")
async def test_switch_updates_optimistically(hass: HomeAssistant) -> None:
"""Test switch state updates before an SSE event arrives."""
entity_id = "switch.test_fridge_partymode"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_OFF
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_ON
@pytest.mark.parametrize(
("entity_id", "method"),
[