diff --git a/homeassistant/components/liebherr/coordinator.py b/homeassistant/components/liebherr/coordinator.py index 7b0feb9fe7ef..4602b5433ee7 100644 --- a/homeassistant/components/liebherr/coordinator.py +++ b/homeassistant/components/liebherr/coordinator.py @@ -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.""" diff --git a/homeassistant/components/liebherr/entity.py b/homeassistant/components/liebherr/entity.py index b41ddf7f39c3..47bff5c42c30 100644 --- a/homeassistant/components/liebherr/entity.py +++ b/homeassistant/components/liebherr/entity.py @@ -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): diff --git a/homeassistant/components/liebherr/light.py b/homeassistant/components/liebherr/light.py index 539dd2cb6383..44edfbdab3c2 100644 --- a/homeassistant/components/liebherr/light.py +++ b/homeassistant/components/liebherr/light.py @@ -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), ) diff --git a/homeassistant/components/liebherr/number.py b/homeassistant/components/liebherr/number.py index 405bad785204..732e04f6400c 100644 --- a/homeassistant/components/liebherr/number.py +++ b/homeassistant/components/liebherr/number.py @@ -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), ) diff --git a/homeassistant/components/liebherr/select.py b/homeassistant/components/liebherr/select.py index 1755e6d31032..0a47d1f64e72 100644 --- a/homeassistant/components/liebherr/select.py +++ b/homeassistant/components/liebherr/select.py @@ -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), ) diff --git a/homeassistant/components/liebherr/switch.py b/homeassistant/components/liebherr/switch.py index 799a42ba09c1..650c01a352bb 100644 --- a/homeassistant/components/liebherr/switch.py +++ b/homeassistant/components/liebherr/switch.py @@ -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): diff --git a/tests/components/liebherr/test_light.py b/tests/components/liebherr/test_light.py index b4197a01b4c1..b29dcec1351f 100644 --- a/tests/components/liebherr/test_light.py +++ b/tests/components/liebherr/test_light.py @@ -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, diff --git a/tests/components/liebherr/test_number.py b/tests/components/liebherr/test_number.py index eb326ec0a170..8fef80407e92 100644 --- a/tests/components/liebherr/test_number.py +++ b/tests/components/liebherr/test_number.py @@ -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") diff --git a/tests/components/liebherr/test_select.py b/tests/components/liebherr/test_select.py index 7a4d95670011..4ad4ba6904aa 100644 --- a/tests/components/liebherr/test_select.py +++ b/tests/components/liebherr/test_select.py @@ -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"), [ diff --git a/tests/components/liebherr/test_switch.py b/tests/components/liebherr/test_switch.py index e9152540d08f..cb3de00edcc9 100644 --- a/tests/components/liebherr/test_switch.py +++ b/tests/components/liebherr/test_switch.py @@ -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"), [