diff --git a/homeassistant/components/liebherr/__init__.py b/homeassistant/components/liebherr/__init__.py index 647fa90b133..79be6ec8175 100644 --- a/homeassistant/components/liebherr/__init__.py +++ b/homeassistant/components/liebherr/__init__.py @@ -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, diff --git a/homeassistant/components/liebherr/light.py b/homeassistant/components/liebherr/light.py new file mode 100644 index 00000000000..f952e04c7aa --- /dev/null +++ b/homeassistant/components/liebherr/light.py @@ -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, + ) + ) diff --git a/homeassistant/components/liebherr/strings.json b/homeassistant/components/liebherr/strings.json index 9ddcfab2dfc..06d557f1eac 100644 --- a/homeassistant/components/liebherr/strings.json +++ b/homeassistant/components/liebherr/strings.json @@ -33,6 +33,11 @@ } }, "entity": { + "light": { + "presentation_light": { + "name": "Presentation light" + } + }, "number": { "setpoint_temperature": { "name": "Setpoint" diff --git a/tests/components/liebherr/conftest.py b/tests/components/liebherr/conftest.py index 8f19032a56c..f6ca8f45808 100644 --- a/tests/components/liebherr/conftest.py +++ b/tests/components/liebherr/conftest.py @@ -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 diff --git a/tests/components/liebherr/snapshots/test_diagnostics.ambr b/tests/components/liebherr/snapshots/test_diagnostics.ambr index 67dbfad119a..d912f096e71 100644 --- a/tests/components/liebherr/snapshots/test_diagnostics.ambr +++ b/tests/components/liebherr/snapshots/test_diagnostics.ambr @@ -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', diff --git a/tests/components/liebherr/snapshots/test_light.ambr b/tests/components/liebherr/snapshots/test_light.ambr new file mode 100644 index 00000000000..9b1379e41b9 --- /dev/null +++ b/tests/components/liebherr/snapshots/test_light.ambr @@ -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([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + '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': , + 'friendly_name': 'Test Fridge Presentation light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.test_fridge_presentation_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/liebherr/test_light.py b/tests/components/liebherr/test_light.py new file mode 100644 index 00000000000..9b916da9697 --- /dev/null +++ b/tests/components/liebherr/test_light.py @@ -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