mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 17:31:15 -04:00
Enhance Liebherr integration with optimistic state updates for controls (#181603)
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user