diff --git a/homeassistant/components/liebherr/__init__.py b/homeassistant/components/liebherr/__init__.py index 559107ee265..1ce8188c04b 100644 --- a/homeassistant/components/liebherr/__init__.py +++ b/homeassistant/components/liebherr/__init__.py @@ -17,7 +17,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .coordinator import LiebherrConfigEntry, LiebherrCoordinator -PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> bool: diff --git a/homeassistant/components/liebherr/icons.json b/homeassistant/components/liebherr/icons.json new file mode 100644 index 00000000000..39e9f59e50c --- /dev/null +++ b/homeassistant/components/liebherr/icons.json @@ -0,0 +1,66 @@ +{ + "entity": { + "switch": { + "night_mode": { + "default": "mdi:sleep", + "state": { + "off": "mdi:sleep-off" + } + }, + "party_mode": { + "default": "mdi:glass-cocktail", + "state": { + "off": "mdi:glass-cocktail-off" + } + }, + "supercool": { + "default": "mdi:snowflake", + "state": { + "off": "mdi:snowflake-off" + } + }, + "supercool_bottom_zone": { + "default": "mdi:snowflake", + "state": { + "off": "mdi:snowflake-off" + } + }, + "supercool_middle_zone": { + "default": "mdi:snowflake", + "state": { + "off": "mdi:snowflake-off" + } + }, + "supercool_top_zone": { + "default": "mdi:snowflake", + "state": { + "off": "mdi:snowflake-off" + } + }, + "superfrost": { + "default": "mdi:snowflake-alert", + "state": { + "off": "mdi:snowflake-off" + } + }, + "superfrost_bottom_zone": { + "default": "mdi:snowflake-alert", + "state": { + "off": "mdi:snowflake-off" + } + }, + "superfrost_middle_zone": { + "default": "mdi:snowflake-alert", + "state": { + "off": "mdi:snowflake-off" + } + }, + "superfrost_top_zone": { + "default": "mdi:snowflake-alert", + "state": { + "off": "mdi:snowflake-off" + } + } + } + } +} diff --git a/homeassistant/components/liebherr/number.py b/homeassistant/components/liebherr/number.py index ab4188c881a..0841d29174a 100644 --- a/homeassistant/components/liebherr/number.py +++ b/homeassistant/components/liebherr/number.py @@ -158,7 +158,8 @@ class LiebherrNumber(LiebherrZoneEntity, NumberEntity): except (LiebherrConnectionError, LiebherrTimeoutError) as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="set_temperature_failed", + translation_key="communication_error", + translation_placeholders={"error": str(err)}, ) from err await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/liebherr/quality_scale.yaml b/homeassistant/components/liebherr/quality_scale.yaml index 25b6bd26116..1d24e92c1df 100644 --- a/homeassistant/components/liebherr/quality_scale.yaml +++ b/homeassistant/components/liebherr/quality_scale.yaml @@ -55,11 +55,13 @@ rules: docs-use-cases: done dynamic-devices: todo entity-category: done - entity-device-class: todo - entity-disabled-by-default: todo + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: This integration does not have any entities that should be disabled by default. entity-translations: done - exception-translations: todo - icon-translations: todo + exception-translations: done + icon-translations: done reconfiguration-flow: status: exempt comment: The only configuration option is the API key, which is handled by the reauthentication flow. diff --git a/homeassistant/components/liebherr/strings.json b/homeassistant/components/liebherr/strings.json index 9d4ad80e990..3549760f577 100644 --- a/homeassistant/components/liebherr/strings.json +++ b/homeassistant/components/liebherr/strings.json @@ -57,11 +57,43 @@ "top_zone": { "name": "Top zone" } + }, + "switch": { + "night_mode": { + "name": "Night mode" + }, + "party_mode": { + "name": "Party mode" + }, + "supercool": { + "name": "SuperCool" + }, + "supercool_bottom_zone": { + "name": "Bottom zone SuperCool" + }, + "supercool_middle_zone": { + "name": "Middle zone SuperCool" + }, + "supercool_top_zone": { + "name": "Top zone SuperCool" + }, + "superfrost": { + "name": "SuperFrost" + }, + "superfrost_bottom_zone": { + "name": "Bottom zone SuperFrost" + }, + "superfrost_middle_zone": { + "name": "Middle zone SuperFrost" + }, + "superfrost_top_zone": { + "name": "Top zone SuperFrost" + } } }, "exceptions": { - "set_temperature_failed": { - "message": "Failed to set temperature" + "communication_error": { + "message": "An error occurred while communicating with the device: {error}" } } } diff --git a/homeassistant/components/liebherr/switch.py b/homeassistant/components/liebherr/switch.py new file mode 100644 index 00000000000..db07860d677 --- /dev/null +++ b/homeassistant/components/liebherr/switch.py @@ -0,0 +1,255 @@ +"""Switch platform for Liebherr integration.""" + +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 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" +CONTROL_SUPERFROST = "superfrost" +CONTROL_PARTY_MODE = "partymode" +CONTROL_NIGHT_MODE = "nightmode" + + +@dataclass(frozen=True, kw_only=True) +class LiebherrSwitchEntityDescription(SwitchEntityDescription): + """Base description for Liebherr switch entities.""" + + control_name: str + + +@dataclass(frozen=True, kw_only=True) +class LiebherrZoneSwitchEntityDescription(LiebherrSwitchEntityDescription): + """Describes a Liebherr zone-based switch entity.""" + + set_fn: Callable[[LiebherrCoordinator, int, bool], Awaitable[None]] + + +@dataclass(frozen=True, kw_only=True) +class LiebherrDeviceSwitchEntityDescription(LiebherrSwitchEntityDescription): + """Describes a Liebherr device-wide switch entity.""" + + set_fn: Callable[[LiebherrCoordinator, bool], Awaitable[None]] + + +ZONE_SWITCH_TYPES: dict[str, LiebherrZoneSwitchEntityDescription] = { + CONTROL_SUPERCOOL: LiebherrZoneSwitchEntityDescription( + key="supercool", + translation_key="supercool", + control_name=CONTROL_SUPERCOOL, + set_fn=lambda coordinator, zone_id, value: coordinator.client.set_supercool( + device_id=coordinator.device_id, + zone_id=zone_id, + value=value, + ), + ), + CONTROL_SUPERFROST: LiebherrZoneSwitchEntityDescription( + key="superfrost", + translation_key="superfrost", + control_name=CONTROL_SUPERFROST, + set_fn=lambda coordinator, zone_id, value: coordinator.client.set_superfrost( + device_id=coordinator.device_id, + zone_id=zone_id, + value=value, + ), + ), +} + +DEVICE_SWITCH_TYPES: dict[str, LiebherrDeviceSwitchEntityDescription] = { + CONTROL_PARTY_MODE: LiebherrDeviceSwitchEntityDescription( + key="party_mode", + translation_key="party_mode", + control_name=CONTROL_PARTY_MODE, + set_fn=lambda coordinator, value: coordinator.client.set_party_mode( + device_id=coordinator.device_id, + value=value, + ), + ), + CONTROL_NIGHT_MODE: LiebherrDeviceSwitchEntityDescription( + key="night_mode", + translation_key="night_mode", + control_name=CONTROL_NIGHT_MODE, + set_fn=lambda coordinator, value: coordinator.client.set_night_mode( + device_id=coordinator.device_id, + value=value, + ), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LiebherrConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Liebherr switch entities.""" + entities: list[LiebherrDeviceSwitch | LiebherrZoneSwitch] = [] + + for coordinator in entry.runtime_data.values(): + has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1 + + for control in coordinator.data.controls: + if not isinstance(control, ToggleControl): + continue + + # Zone-based switches (SuperCool, SuperFrost) + if control.zone_id is not None and ( + desc := ZONE_SWITCH_TYPES.get(control.name) + ): + entities.append( + LiebherrZoneSwitch( + coordinator=coordinator, + description=desc, + zone_id=control.zone_id, + has_multiple_zones=has_multiple_zones, + ) + ) + + # Device-wide switches (Party Mode, Night Mode) + elif device_desc := DEVICE_SWITCH_TYPES.get(control.name): + entities.append( + LiebherrDeviceSwitch( + coordinator=coordinator, + description=device_desc, + ) + ) + + async_add_entities(entities) + + +class LiebherrDeviceSwitch(LiebherrEntity, SwitchEntity): + """Representation of a device-wide Liebherr switch.""" + + entity_description: LiebherrSwitchEntityDescription + _zone_id: int | None = None + _optimistic_state: bool | None = None + + def __init__( + self, + coordinator: LiebherrCoordinator, + description: LiebherrSwitchEntityDescription, + ) -> None: + """Initialize the device switch entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.device_id}_{description.key}" + + @property + def _toggle_control(self) -> ToggleControl | None: + """Get the toggle control for this entity.""" + for control in self.coordinator.data.controls: + if ( + isinstance(control, ToggleControl) + and control.name == self.entity_description.control_name + and (self._zone_id is None or control.zone_id == self._zone_id) + ): + return control + return None + + @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.""" + return super().available and self._toggle_control is not None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._async_set_value(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._async_set_value(False) + + async def _async_call_set_fn(self, value: bool) -> None: + """Call the set function for this switch.""" + if TYPE_CHECKING: + assert isinstance( + self.entity_description, LiebherrDeviceSwitchEntityDescription + ) + await self.entity_description.set_fn(self.coordinator, value) + + 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() + + +class LiebherrZoneSwitch(LiebherrDeviceSwitch): + """Representation of a zone-based Liebherr switch.""" + + entity_description: LiebherrZoneSwitchEntityDescription + _zone_id: int + + def __init__( + self, + coordinator: LiebherrCoordinator, + description: LiebherrZoneSwitchEntityDescription, + zone_id: int, + has_multiple_zones: bool, + ) -> None: + """Initialize the zone switch entity.""" + super().__init__(coordinator, description) + self._zone_id = zone_id + self._attr_unique_id = f"{coordinator.device_id}_{description.key}_{zone_id}" + + # Add zone suffix only for multi-zone devices + if has_multiple_zones: + temp_controls = coordinator.data.get_temperature_controls() + if ( + (tc := temp_controls.get(zone_id)) + and isinstance(tc.zone_position, ZonePosition) + and (zone_key := ZONE_POSITION_MAP.get(tc.zone_position)) + ): + self._attr_translation_key = f"{description.translation_key}_{zone_key}" + + async def _async_call_set_fn(self, value: bool) -> None: + """Call the set function for this zone switch.""" + await self.entity_description.set_fn(self.coordinator, self._zone_id, value) diff --git a/tests/components/liebherr/conftest.py b/tests/components/liebherr/conftest.py index 4e81b723bf6..536b76a34b1 100644 --- a/tests/components/liebherr/conftest.py +++ b/tests/components/liebherr/conftest.py @@ -1,6 +1,7 @@ """Common fixtures for the liebherr tests.""" from collections.abc import Generator +import copy from unittest.mock import AsyncMock, MagicMock, patch from pyliebherrhomeapi import ( @@ -9,6 +10,7 @@ from pyliebherrhomeapi import ( DeviceType, TemperatureControl, TemperatureUnit, + ToggleControl, ZonePosition, ) import pytest @@ -52,6 +54,34 @@ MOCK_DEVICE_STATE = DeviceState( max=-16, unit=TemperatureUnit.CELSIUS, ), + ToggleControl( + name="supercool", + type="ToggleControl", + zone_id=1, + zone_position=ZonePosition.TOP, + value=False, + ), + ToggleControl( + name="superfrost", + type="ToggleControl", + zone_id=2, + zone_position=ZonePosition.BOTTOM, + value=True, + ), + ToggleControl( + name="partymode", + type="ToggleControl", + zone_id=None, + zone_position=None, + value=False, + ), + ToggleControl( + name="nightmode", + type="ToggleControl", + zone_id=None, + zone_position=None, + value=True, + ), ], ) @@ -90,8 +120,15 @@ def mock_liebherr_client() -> Generator[MagicMock]: ): client = mock_client.return_value client.get_devices.return_value = [MOCK_DEVICE] - client.get_device_state.return_value = MOCK_DEVICE_STATE + # Return a fresh copy each call so mutations don't leak between calls. + client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy( + MOCK_DEVICE_STATE + ) client.set_temperature = AsyncMock() + client.set_supercool = AsyncMock() + client.set_superfrost = AsyncMock() + client.set_party_mode = AsyncMock() + client.set_night_mode = AsyncMock() yield client diff --git a/tests/components/liebherr/snapshots/test_diagnostics.ambr b/tests/components/liebherr/snapshots/test_diagnostics.ambr index 54db49bd365..1d68cbe37d1 100644 --- a/tests/components/liebherr/snapshots/test_diagnostics.ambr +++ b/tests/components/liebherr/snapshots/test_diagnostics.ambr @@ -32,6 +32,34 @@ 'zone_id': 2, 'zone_position': 'bottom', }), + dict({ + 'name': 'supercool', + 'type': 'ToggleControl', + 'value': False, + 'zone_id': 1, + 'zone_position': 'top', + }), + dict({ + 'name': 'superfrost', + 'type': 'ToggleControl', + 'value': True, + 'zone_id': 2, + 'zone_position': 'bottom', + }), + dict({ + 'name': 'partymode', + 'type': 'ToggleControl', + 'value': False, + 'zone_id': None, + 'zone_position': None, + }), + dict({ + 'name': 'nightmode', + 'type': 'ToggleControl', + 'value': True, + 'zone_id': None, + 'zone_position': None, + }), ]), 'device': dict({ 'device_id': 'test_device_id', diff --git a/tests/components/liebherr/snapshots/test_switch.ambr b/tests/components/liebherr/snapshots/test_switch.ambr new file mode 100644 index 00000000000..a95a39632ba --- /dev/null +++ b/tests/components/liebherr/snapshots/test_switch.ambr @@ -0,0 +1,246 @@ +# serializer version: 1 +# name: test_single_zone_switch[switch.single_zone_fridge_supercool-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.single_zone_fridge_supercool', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'SuperCool', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SuperCool', + 'platform': 'liebherr', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'supercool', + 'unique_id': 'single_zone_id_supercool_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_single_zone_switch[switch.single_zone_fridge_supercool-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Single Zone Fridge SuperCool', + }), + 'context': , + 'entity_id': 'switch.single_zone_fridge_supercool', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.test_fridge_bottom_zone_superfrost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_fridge_bottom_zone_superfrost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bottom zone SuperFrost', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bottom zone SuperFrost', + 'platform': 'liebherr', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'superfrost_bottom_zone', + 'unique_id': 'test_device_id_superfrost_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.test_fridge_bottom_zone_superfrost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Fridge Bottom zone SuperFrost', + }), + 'context': , + 'entity_id': 'switch.test_fridge_bottom_zone_superfrost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.test_fridge_night_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_fridge_night_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Night mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Night mode', + 'platform': 'liebherr', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'night_mode', + 'unique_id': 'test_device_id_night_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.test_fridge_night_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Fridge Night mode', + }), + 'context': , + 'entity_id': 'switch.test_fridge_night_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.test_fridge_party_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_fridge_party_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Party mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Party mode', + 'platform': 'liebherr', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'party_mode', + 'unique_id': 'test_device_id_party_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.test_fridge_party_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Fridge Party mode', + }), + 'context': , + 'entity_id': 'switch.test_fridge_party_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.test_fridge_top_zone_supercool-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_fridge_top_zone_supercool', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Top zone SuperCool', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Top zone SuperCool', + 'platform': 'liebherr', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'supercool_top_zone', + 'unique_id': 'test_device_id_supercool_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.test_fridge_top_zone_supercool-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Fridge Top zone SuperCool', + }), + 'context': , + 'entity_id': 'switch.test_fridge_top_zone_supercool', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/liebherr/test_number.py b/tests/components/liebherr/test_number.py index 1e6a84a41b3..480df1413e0 100644 --- a/tests/components/liebherr/test_number.py +++ b/tests/components/liebherr/test_number.py @@ -1,5 +1,6 @@ """Test the Liebherr number platform.""" +import copy from datetime import timedelta from unittest.mock import MagicMock, patch @@ -28,7 +29,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import MOCK_DEVICE +from .conftest import MOCK_DEVICE, MOCK_DEVICE_STATE from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -71,7 +72,7 @@ async def test_single_zone_number( device_name="K2601", ) mock_liebherr_client.get_devices.return_value = [device] - mock_liebherr_client.get_device_state.return_value = DeviceState( + single_zone_state = DeviceState( device=device, controls=[ TemperatureControl( @@ -87,6 +88,9 @@ async def test_single_zone_number( ) ], ) + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy( + single_zone_state + ) mock_config_entry.add_to_hass(hass) with patch("homeassistant.components.liebherr.PLATFORMS", platforms): @@ -111,7 +115,7 @@ async def test_multi_zone_with_none_position( device_name="CBNes9999", ) mock_liebherr_client.get_devices.return_value = [device] - mock_liebherr_client.get_device_state.return_value = DeviceState( + multi_zone_state = DeviceState( device=device, controls=[ TemperatureControl( @@ -138,6 +142,9 @@ async def test_multi_zone_with_none_position( ), ], ) + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy( + multi_zone_state + ) mock_config_entry.add_to_hass(hass) with patch("homeassistant.components.liebherr.PLATFORMS", platforms): @@ -192,7 +199,10 @@ async def test_set_temperature_failure( "Connection failed" ) - with pytest.raises(HomeAssistantError, match="Failed to set temperature"): + with pytest.raises( + HomeAssistantError, + match="An error occurred while communicating with the device: Connection failed", + ): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -231,7 +241,9 @@ async def test_number_update_failure( assert state.state == STATE_UNAVAILABLE # Simulate recovery - mock_liebherr_client.get_device_state.side_effect = None + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy( + MOCK_DEVICE_STATE + ) freezer.tick(timedelta(seconds=61)) async_fire_time_changed(hass) @@ -261,7 +273,7 @@ async def test_number_when_control_missing( assert state.attributes["unit_of_measurement"] == "°C" # Device stops reporting controls - mock_liebherr_client.get_device_state.return_value = DeviceState( + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: DeviceState( device=MOCK_DEVICE, controls=[] ) @@ -290,7 +302,7 @@ async def test_number_with_none_min_max( device_name="K2601", ) mock_liebherr_client.get_devices.return_value = [device] - mock_liebherr_client.get_device_state.return_value = DeviceState( + none_min_max_state = DeviceState( device=device, controls=[ TemperatureControl( @@ -306,6 +318,9 @@ async def test_number_with_none_min_max( ) ], ) + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy( + none_min_max_state + ) mock_config_entry.add_to_hass(hass) with patch("homeassistant.components.liebherr.PLATFORMS", platforms): diff --git a/tests/components/liebherr/test_sensor.py b/tests/components/liebherr/test_sensor.py index 62a5bf36e04..4e6b6ac1835 100644 --- a/tests/components/liebherr/test_sensor.py +++ b/tests/components/liebherr/test_sensor.py @@ -1,5 +1,6 @@ """Test the Liebherr sensor platform.""" +import copy from datetime import timedelta from unittest.mock import MagicMock, patch @@ -26,7 +27,7 @@ from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MOCK_DEVICE +from .conftest import MOCK_DEVICE, MOCK_DEVICE_STATE from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -59,7 +60,7 @@ async def test_single_zone_sensor( device_name="K2601", ) mock_liebherr_client.get_devices.return_value = [device] - mock_liebherr_client.get_device_state.return_value = DeviceState( + single_zone_state = DeviceState( device=device, controls=[ TemperatureControl( @@ -72,6 +73,9 @@ async def test_single_zone_sensor( ) ], ) + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy( + single_zone_state + ) mock_config_entry.add_to_hass(hass) with patch("homeassistant.components.liebherr.PLATFORMS", platforms): @@ -96,7 +100,7 @@ async def test_multi_zone_with_none_position( device_name="CBNes9999", ) mock_liebherr_client.get_devices.return_value = [device] - mock_liebherr_client.get_device_state.return_value = DeviceState( + multi_zone_state = DeviceState( device=device, controls=[ TemperatureControl( @@ -117,6 +121,9 @@ async def test_multi_zone_with_none_position( ), ], ) + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy( + multi_zone_state + ) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -170,7 +177,9 @@ async def test_sensor_update_failure( assert state.state == STATE_UNAVAILABLE # Simulate recovery - mock_liebherr_client.get_device_state.side_effect = None + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy( + MOCK_DEVICE_STATE + ) freezer.tick(timedelta(seconds=61)) async_fire_time_changed(hass) @@ -237,7 +246,7 @@ async def test_sensor_unavailable_when_control_missing( assert state.state == "5" # Device stops reporting controls (e.g., zone removed or API issue) - mock_liebherr_client.get_device_state.return_value = DeviceState( + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: DeviceState( device=MOCK_DEVICE, controls=[] ) diff --git a/tests/components/liebherr/test_switch.py b/tests/components/liebherr/test_switch.py new file mode 100644 index 00000000000..9bed382f48f --- /dev/null +++ b/tests/components/liebherr/test_switch.py @@ -0,0 +1,268 @@ +"""Test the Liebherr switch platform.""" + +import copy +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pyliebherrhomeapi import ( + Device, + DeviceState, + DeviceType, + TemperatureControl, + TemperatureUnit, + ToggleControl, + ZonePosition, +) +from pyliebherrhomeapi.exceptions import LiebherrConnectionError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .conftest import MOCK_DEVICE, MOCK_DEVICE_STATE + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.SWITCH] + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +@pytest.mark.usefixtures("init_integration") +async def test_switches( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test all switch entities with multi-zone device.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "service", "method", "kwargs"), + [ + ( + "switch.test_fridge_top_zone_supercool", + SERVICE_TURN_ON, + "set_supercool", + {"device_id": "test_device_id", "zone_id": 1, "value": True}, + ), + ( + "switch.test_fridge_top_zone_supercool", + SERVICE_TURN_OFF, + "set_supercool", + {"device_id": "test_device_id", "zone_id": 1, "value": False}, + ), + ( + "switch.test_fridge_bottom_zone_superfrost", + SERVICE_TURN_ON, + "set_superfrost", + {"device_id": "test_device_id", "zone_id": 2, "value": True}, + ), + ( + "switch.test_fridge_party_mode", + SERVICE_TURN_ON, + "set_party_mode", + {"device_id": "test_device_id", "value": True}, + ), + ( + "switch.test_fridge_night_mode", + SERVICE_TURN_OFF, + "set_night_mode", + {"device_id": "test_device_id", "value": False}, + ), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_switch_service_calls( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + entity_id: str, + service: str, + method: str, + kwargs: dict[str, Any], +) -> None: + """Test switch turn on/off service calls.""" + initial_call_count = mock_liebherr_client.get_device_state.call_count + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + getattr(mock_liebherr_client, method).assert_called_once_with(**kwargs) + + # Verify coordinator refresh was triggered + assert mock_liebherr_client.get_device_state.call_count > initial_call_count + + +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ("switch.test_fridge_top_zone_supercool", "set_supercool"), + ("switch.test_fridge_party_mode", "set_party_mode"), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_switch_failure( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + entity_id: str, + method: str, +) -> None: + """Test switch fails gracefully on connection error.""" + getattr(mock_liebherr_client, method).side_effect = LiebherrConnectionError( + "Connection failed" + ) + + with pytest.raises( + HomeAssistantError, + match="An error occurred while communicating with the device: Connection failed", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_switch_update_failure( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test switch becomes unavailable when coordinator update fails and recovers.""" + entity_id = "switch.test_fridge_top_zone_supercool" + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + + # Simulate update error + mock_liebherr_client.get_device_state.side_effect = LiebherrConnectionError( + "Connection failed" + ) + + freezer.tick(timedelta(seconds=61)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # Simulate recovery + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy( + MOCK_DEVICE_STATE + ) + + freezer.tick(timedelta(seconds=61)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + + +@pytest.mark.usefixtures("init_integration") +async def test_switch_when_control_missing( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test switch entity behavior when toggle control is removed.""" + entity_id = "switch.test_fridge_top_zone_supercool" + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + + # Device stops reporting toggle controls + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: DeviceState( + device=MOCK_DEVICE, controls=[] + ) + + freezer.tick(timedelta(seconds=61)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_single_zone_switch( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_liebherr_client: MagicMock, + mock_config_entry: MockConfigEntry, + platforms: list[Platform], +) -> None: + """Test single zone device uses name without zone suffix.""" + device = Device( + device_id="single_zone_id", + nickname="Single Zone Fridge", + device_type=DeviceType.FRIDGE, + device_name="K2601", + ) + mock_liebherr_client.get_devices.return_value = [device] + single_zone_state = DeviceState( + device=device, + controls=[ + TemperatureControl( + zone_id=1, + zone_position=ZonePosition.TOP, + name="Fridge", + type="fridge", + value=4, + target=4, + min=2, + max=8, + unit=TemperatureUnit.CELSIUS, + ), + ToggleControl( + name="supercool", + type="ToggleControl", + zone_id=1, + zone_position=ZonePosition.TOP, + value=False, + ), + ], + ) + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy( + single_zone_state + ) + + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.liebherr.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)