1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Add switch entities to Liebherr integration (#162688)

This commit is contained in:
mettolen
2026-02-14 22:41:06 +02:00
committed by GitHub
parent c67c19413b
commit acaa2aeeee
12 changed files with 980 additions and 21 deletions

View File

@@ -17,7 +17,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator 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: async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> bool:

View File

@@ -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"
}
}
}
}
}

View File

@@ -158,7 +158,8 @@ class LiebherrNumber(LiebherrZoneEntity, NumberEntity):
except (LiebherrConnectionError, LiebherrTimeoutError) as err: except (LiebherrConnectionError, LiebherrTimeoutError) as err:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_temperature_failed", translation_key="communication_error",
translation_placeholders={"error": str(err)},
) from err ) from err
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()

View File

@@ -55,11 +55,13 @@ rules:
docs-use-cases: done docs-use-cases: done
dynamic-devices: todo dynamic-devices: todo
entity-category: done entity-category: done
entity-device-class: todo entity-device-class: done
entity-disabled-by-default: todo entity-disabled-by-default:
status: exempt
comment: This integration does not have any entities that should be disabled by default.
entity-translations: done entity-translations: done
exception-translations: todo exception-translations: done
icon-translations: todo icon-translations: done
reconfiguration-flow: reconfiguration-flow:
status: exempt status: exempt
comment: The only configuration option is the API key, which is handled by the reauthentication flow. comment: The only configuration option is the API key, which is handled by the reauthentication flow.

View File

@@ -57,11 +57,43 @@
"top_zone": { "top_zone": {
"name": "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": { "exceptions": {
"set_temperature_failed": { "communication_error": {
"message": "Failed to set temperature" "message": "An error occurred while communicating with the device: {error}"
} }
} }
} }

View File

@@ -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)

View File

@@ -1,6 +1,7 @@
"""Common fixtures for the liebherr tests.""" """Common fixtures for the liebherr tests."""
from collections.abc import Generator from collections.abc import Generator
import copy
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from pyliebherrhomeapi import ( from pyliebherrhomeapi import (
@@ -9,6 +10,7 @@ from pyliebherrhomeapi import (
DeviceType, DeviceType,
TemperatureControl, TemperatureControl,
TemperatureUnit, TemperatureUnit,
ToggleControl,
ZonePosition, ZonePosition,
) )
import pytest import pytest
@@ -52,6 +54,34 @@ MOCK_DEVICE_STATE = DeviceState(
max=-16, max=-16,
unit=TemperatureUnit.CELSIUS, 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 = mock_client.return_value
client.get_devices.return_value = [MOCK_DEVICE] 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_temperature = AsyncMock()
client.set_supercool = AsyncMock()
client.set_superfrost = AsyncMock()
client.set_party_mode = AsyncMock()
client.set_night_mode = AsyncMock()
yield client yield client

View File

@@ -32,6 +32,34 @@
'zone_id': 2, 'zone_id': 2,
'zone_position': 'bottom', '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': dict({
'device_id': 'test_device_id', 'device_id': 'test_device_id',

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'switch.single_zone_fridge_supercool',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_switches[switch.test_fridge_bottom_zone_superfrost-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'switch.test_fridge_bottom_zone_superfrost',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switches[switch.test_fridge_night_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'switch.test_fridge_night_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switches[switch.test_fridge_party_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'switch.test_fridge_party_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_switches[switch.test_fridge_top_zone_supercool-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'switch.test_fridge_top_zone_supercool',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@@ -1,5 +1,6 @@
"""Test the Liebherr number platform.""" """Test the Liebherr number platform."""
import copy
from datetime import timedelta from datetime import timedelta
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -28,7 +29,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er 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 from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -71,7 +72,7 @@ async def test_single_zone_number(
device_name="K2601", device_name="K2601",
) )
mock_liebherr_client.get_devices.return_value = [device] mock_liebherr_client.get_devices.return_value = [device]
mock_liebherr_client.get_device_state.return_value = DeviceState( single_zone_state = DeviceState(
device=device, device=device,
controls=[ controls=[
TemperatureControl( 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) mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.liebherr.PLATFORMS", platforms): with patch("homeassistant.components.liebherr.PLATFORMS", platforms):
@@ -111,7 +115,7 @@ async def test_multi_zone_with_none_position(
device_name="CBNes9999", device_name="CBNes9999",
) )
mock_liebherr_client.get_devices.return_value = [device] mock_liebherr_client.get_devices.return_value = [device]
mock_liebherr_client.get_device_state.return_value = DeviceState( multi_zone_state = DeviceState(
device=device, device=device,
controls=[ controls=[
TemperatureControl( 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) mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.liebherr.PLATFORMS", platforms): with patch("homeassistant.components.liebherr.PLATFORMS", platforms):
@@ -192,7 +199,10 @@ async def test_set_temperature_failure(
"Connection failed" "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( await hass.services.async_call(
NUMBER_DOMAIN, NUMBER_DOMAIN,
SERVICE_SET_VALUE, SERVICE_SET_VALUE,
@@ -231,7 +241,9 @@ async def test_number_update_failure(
assert state.state == STATE_UNAVAILABLE assert state.state == STATE_UNAVAILABLE
# Simulate recovery # 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)) freezer.tick(timedelta(seconds=61))
async_fire_time_changed(hass) async_fire_time_changed(hass)
@@ -261,7 +273,7 @@ async def test_number_when_control_missing(
assert state.attributes["unit_of_measurement"] == "°C" assert state.attributes["unit_of_measurement"] == "°C"
# Device stops reporting controls # 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=[] device=MOCK_DEVICE, controls=[]
) )
@@ -290,7 +302,7 @@ async def test_number_with_none_min_max(
device_name="K2601", device_name="K2601",
) )
mock_liebherr_client.get_devices.return_value = [device] mock_liebherr_client.get_devices.return_value = [device]
mock_liebherr_client.get_device_state.return_value = DeviceState( none_min_max_state = DeviceState(
device=device, device=device,
controls=[ controls=[
TemperatureControl( 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) mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.liebherr.PLATFORMS", platforms): with patch("homeassistant.components.liebherr.PLATFORMS", platforms):

View File

@@ -1,5 +1,6 @@
"""Test the Liebherr sensor platform.""" """Test the Liebherr sensor platform."""
import copy
from datetime import timedelta from datetime import timedelta
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -26,7 +27,7 @@ from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er 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 from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -59,7 +60,7 @@ async def test_single_zone_sensor(
device_name="K2601", device_name="K2601",
) )
mock_liebherr_client.get_devices.return_value = [device] mock_liebherr_client.get_devices.return_value = [device]
mock_liebherr_client.get_device_state.return_value = DeviceState( single_zone_state = DeviceState(
device=device, device=device,
controls=[ controls=[
TemperatureControl( 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) mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.liebherr.PLATFORMS", platforms): with patch("homeassistant.components.liebherr.PLATFORMS", platforms):
@@ -96,7 +100,7 @@ async def test_multi_zone_with_none_position(
device_name="CBNes9999", device_name="CBNes9999",
) )
mock_liebherr_client.get_devices.return_value = [device] mock_liebherr_client.get_devices.return_value = [device]
mock_liebherr_client.get_device_state.return_value = DeviceState( multi_zone_state = DeviceState(
device=device, device=device,
controls=[ controls=[
TemperatureControl( 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) mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id) 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 assert state.state == STATE_UNAVAILABLE
# Simulate recovery # 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)) freezer.tick(timedelta(seconds=61))
async_fire_time_changed(hass) async_fire_time_changed(hass)
@@ -237,7 +246,7 @@ async def test_sensor_unavailable_when_control_missing(
assert state.state == "5" assert state.state == "5"
# Device stops reporting controls (e.g., zone removed or API issue) # 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=[] device=MOCK_DEVICE, controls=[]
) )

View File

@@ -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)