1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-17 15:44:52 +01:00

Add Presentation light to Liebherr (#166154)

This commit is contained in:
mettolen
2026-03-25 09:12:46 +02:00
committed by GitHub
parent f299b009fa
commit 171b8dfa89
7 changed files with 480 additions and 0 deletions

View File

@@ -26,6 +26,7 @@ from .coordinator import LiebherrConfigEntry, LiebherrCoordinator, LiebherrData
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
Platform.LIGHT,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,

View File

@@ -0,0 +1,132 @@
"""Light platform for Liebherr integration."""
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any
from pyliebherrhomeapi import PresentationLightControl
from pyliebherrhomeapi.const import CONTROL_PRESENTATION_LIGHT
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
from .entity import LiebherrEntity
DEFAULT_MAX_BRIGHTNESS_LEVEL = 5
PARALLEL_UPDATES = 1
def _create_light_entities(
coordinators: list[LiebherrCoordinator],
) -> list[LiebherrPresentationLight]:
"""Create light entities for the given coordinators."""
return [
LiebherrPresentationLight(coordinator=coordinator)
for coordinator in coordinators
for control in coordinator.data.controls
if isinstance(control, PresentationLightControl)
and control.name == CONTROL_PRESENTATION_LIGHT
]
async def async_setup_entry(
hass: HomeAssistant,
entry: LiebherrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Liebherr light entities."""
async_add_entities(
_create_light_entities(list(entry.runtime_data.coordinators.values()))
)
@callback
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
"""Add light entities for new devices."""
async_add_entities(_create_light_entities(coordinators))
entry.async_on_unload(
async_dispatcher_connect(
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
)
)
class LiebherrPresentationLight(LiebherrEntity, LightEntity):
"""Representation of a Liebherr presentation light."""
_attr_translation_key = "presentation_light"
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
def __init__(
self,
coordinator: LiebherrCoordinator,
) -> None:
"""Initialize the presentation light entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.device_id}_presentation_light"
@property
def _light_control(self) -> PresentationLightControl | None:
"""Get the presentation light control."""
controls = self.coordinator.data.get_presentation_light_controls()
return controls.get(CONTROL_PRESENTATION_LIGHT)
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._light_control is not None
@property
def is_on(self) -> bool | None:
"""Return true if the light is on."""
control = self._light_control
if TYPE_CHECKING:
assert control is not None
if control.value is None:
return None
return control.value > 0
@property
def brightness(self) -> int | None:
"""Return the brightness of the light (0-255)."""
control = self._light_control
if TYPE_CHECKING:
assert control is not None
if control.value is None or control.max is None or control.max == 0:
return None
return math.ceil(control.value * 255 / control.max)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
control = self._light_control
if TYPE_CHECKING:
assert control is not None
max_level = control.max or DEFAULT_MAX_BRIGHTNESS_LEVEL
if ATTR_BRIGHTNESS in kwargs:
target = max(1, round(kwargs[ATTR_BRIGHTNESS] * max_level / 255))
else:
target = max_level
await self._async_send_command(
self.coordinator.client.set_presentation_light(
device_id=self.coordinator.device_id,
target=target,
)
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self._async_send_command(
self.coordinator.client.set_presentation_light(
device_id=self.coordinator.device_id,
target=0,
)
)

View File

@@ -33,6 +33,11 @@
}
},
"entity": {
"light": {
"presentation_light": {
"name": "Presentation light"
}
},
"number": {
"setpoint_temperature": {
"name": "Setpoint"

View File

@@ -15,6 +15,7 @@ from pyliebherrhomeapi import (
HydroBreezeMode,
IceMakerControl,
IceMakerMode,
PresentationLightControl,
TemperatureControl,
TemperatureUnit,
ToggleControl,
@@ -115,6 +116,12 @@ MOCK_DEVICE_STATE = DeviceState(
BioFreshPlusMode.MINUS_TWO_ZERO,
],
),
PresentationLightControl(
name="presentationlight",
type="PresentationLightControl",
value=3,
max=5,
),
],
)
@@ -175,6 +182,7 @@ def mock_liebherr_client() -> Generator[MagicMock]:
client.set_ice_maker = AsyncMock()
client.set_hydro_breeze = AsyncMock()
client.set_bio_fresh_plus = AsyncMock()
client.set_presentation_light = AsyncMock()
yield client

View File

@@ -87,6 +87,12 @@
'type': 'BioFreshPlusControl',
'zone_id': 1,
}),
dict({
'max': 5,
'name': 'presentationlight',
'type': 'PresentationLightControl',
'value': 3,
}),
]),
'device': dict({
'device_id': 'test_device_id',

View File

@@ -0,0 +1,61 @@
# serializer version: 1
# name: test_lights[light.test_fridge_presentation_light-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': None,
'entity_id': 'light.test_fridge_presentation_light',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Presentation light',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Presentation light',
'platform': 'liebherr',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'presentation_light',
'unique_id': 'test_device_id_presentation_light',
'unit_of_measurement': None,
})
# ---
# name: test_lights[light.test_fridge_presentation_light-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'brightness': 153,
'color_mode': <ColorMode.BRIGHTNESS: 'brightness'>,
'friendly_name': 'Test Fridge Presentation light',
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
'supported_features': <LightEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'light.test_fridge_presentation_light',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@@ -0,0 +1,267 @@
"""Test the Liebherr light 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, PresentationLightControl
from pyliebherrhomeapi.exceptions import LiebherrConnectionError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.liebherr.const import DOMAIN
from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
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
pytestmark = pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return [Platform.LIGHT]
@pytest.mark.usefixtures("init_integration")
async def test_lights(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test all light entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("init_integration")
async def test_light_state(
hass: HomeAssistant,
) -> None:
"""Test light entity reports correct state."""
entity_id = "light.test_fridge_presentation_light"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_ON
# value=3, max=5 → brightness = ceil(3 * 255 / 5) = 153
assert state.attributes[ATTR_BRIGHTNESS] == 153
@pytest.mark.parametrize(
("service", "service_data", "expected_target"),
[
(SERVICE_TURN_ON, {}, 5),
(SERVICE_TURN_ON, {ATTR_BRIGHTNESS: 255}, 5),
(SERVICE_TURN_ON, {ATTR_BRIGHTNESS: 128}, 3),
(SERVICE_TURN_ON, {ATTR_BRIGHTNESS: 51}, 1),
(SERVICE_TURN_ON, {ATTR_BRIGHTNESS: 1}, 1),
(SERVICE_TURN_OFF, {}, 0),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_light_service_calls(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
service: str,
service_data: dict[str, Any],
expected_target: int,
) -> None:
"""Test light turn on/off service calls."""
entity_id = "light.test_fridge_presentation_light"
initial_call_count = mock_liebherr_client.get_device_state.call_count
await hass.services.async_call(
LIGHT_DOMAIN,
service,
{ATTR_ENTITY_ID: entity_id, **service_data},
blocking=True,
)
mock_liebherr_client.set_presentation_light.assert_called_once_with(
device_id="test_device_id",
target=expected_target,
)
# Verify coordinator refresh was triggered
assert mock_liebherr_client.get_device_state.call_count > initial_call_count
@pytest.mark.usefixtures("init_integration")
async def test_light_failure(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
) -> None:
"""Test light fails gracefully on connection error."""
entity_id = "light.test_fridge_presentation_light"
mock_liebherr_client.set_presentation_light.side_effect = LiebherrConnectionError(
"Connection failed"
)
with pytest.raises(
HomeAssistantError,
match="An error occurred while communicating with the device",
):
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
@pytest.mark.usefixtures("init_integration")
async def test_light_when_control_missing(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test light entity behavior when control is removed."""
entity_id = "light.test_fridge_presentation_light"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_ON
# Device stops reporting presentation light control
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
@pytest.mark.parametrize(
("value", "max_value", "expected_state", "expected_brightness"),
[
(0, 5, STATE_OFF, None),
(None, 5, STATE_UNKNOWN, None),
(1, 0, STATE_ON, None),
],
ids=["off", "null_value", "zero_max"],
)
@pytest.mark.usefixtures("init_integration")
async def test_light_state_updates(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
freezer: FrozenDateTimeFactory,
value: int | None,
max_value: int,
expected_state: str,
expected_brightness: int | None,
) -> None:
"""Test light entity state after coordinator update."""
entity_id = "light.test_fridge_presentation_light"
mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: DeviceState(
device=MOCK_DEVICE,
controls=[
PresentationLightControl(
name="presentationlight",
type="PresentationLightControl",
value=value,
max=max_value,
),
],
)
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 == expected_state
assert state.attributes.get(ATTR_BRIGHTNESS) == expected_brightness
async def test_no_light_entity_without_control(
hass: HomeAssistant,
mock_liebherr_client: MagicMock,
mock_config_entry: MockConfigEntry,
platforms: list[Platform],
) -> None:
"""Test no light entity created when device has no presentation light control."""
mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: DeviceState(
device=MOCK_DEVICE, controls=[]
)
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()
assert hass.states.get("light.test_fridge_presentation_light") is None
async def test_dynamic_device_discovery_light(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_liebherr_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test new devices with presentation light are automatically discovered."""
mock_config_entry.add_to_hass(hass)
with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", [Platform.LIGHT]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("light.test_fridge_presentation_light") is not None
assert hass.states.get("light.new_fridge_presentation_light") is None
new_device = Device(
device_id="new_device_id",
nickname="New Fridge",
device_type=DeviceType.FRIDGE,
device_name="K2601",
)
new_device_state = DeviceState(
device=new_device,
controls=[
PresentationLightControl(
name="presentationlight",
type="PresentationLightControl",
value=2,
max=5,
),
],
)
mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE, new_device]
mock_liebherr_client.get_device_state.side_effect = lambda device_id, **kw: (
copy.deepcopy(
new_device_state if device_id == "new_device_id" else MOCK_DEVICE_STATE
)
)
freezer.tick(timedelta(minutes=5, seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("light.new_fridge_presentation_light")
assert state is not None
assert state.state == STATE_ON