From e64598e7f51fae417826f3735a2c3ab207c86fb7 Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Mon, 24 Nov 2025 13:02:46 +0200 Subject: [PATCH] Add light entity to Saunum integration (#157081) Co-authored-by: Josef Zweck --- homeassistant/components/saunum/__init__.py | 10 +- homeassistant/components/saunum/climate.py | 3 - homeassistant/components/saunum/const.py | 7 -- homeassistant/components/saunum/light.py | 71 +++++++++++ .../components/saunum/quality_scale.yaml | 2 +- homeassistant/components/saunum/strings.json | 13 ++ tests/components/saunum/conftest.py | 14 ++- .../saunum/snapshots/test_light.ambr | 58 +++++++++ tests/components/saunum/test_climate.py | 13 +- tests/components/saunum/test_init.py | 3 +- tests/components/saunum/test_light.py | 116 ++++++++++++++++++ 11 files changed, 289 insertions(+), 21 deletions(-) create mode 100644 homeassistant/components/saunum/light.py create mode 100644 tests/components/saunum/snapshots/test_light.ambr create mode 100644 tests/components/saunum/test_light.py diff --git a/homeassistant/components/saunum/__init__.py b/homeassistant/components/saunum/__init__.py index e9bd9fb4020..32ccc7bd6e2 100644 --- a/homeassistant/components/saunum/__init__.py +++ b/homeassistant/components/saunum/__init__.py @@ -2,19 +2,19 @@ from __future__ import annotations -import logging - from pysaunum import SaunumClient, SaunumConnectionError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import PLATFORMS from .coordinator import LeilSaunaCoordinator -_LOGGER = logging.getLogger(__name__) +PLATFORMS: list[Platform] = [ + Platform.CLIMATE, + Platform.LIGHT, +] type LeilSaunaConfigEntry = ConfigEntry[LeilSaunaCoordinator] diff --git a/homeassistant/components/saunum/climate.py b/homeassistant/components/saunum/climate.py index 4ba2608a29a..2f10cef9f97 100644 --- a/homeassistant/components/saunum/climate.py +++ b/homeassistant/components/saunum/climate.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import logging from typing import Any from pysaunum import MAX_TEMPERATURE, MIN_TEMPERATURE, SaunumException @@ -27,8 +26,6 @@ from . import LeilSaunaConfigEntry from .const import DELAYED_REFRESH_SECONDS, DOMAIN from .entity import LeilSaunaEntity -_LOGGER = logging.getLogger(__name__) - PARALLEL_UPDATES = 1 # Map Saunum fan speed (0-3) to Home Assistant fan modes diff --git a/homeassistant/components/saunum/const.py b/homeassistant/components/saunum/const.py index 70ab3a988fd..0c841313ad2 100644 --- a/homeassistant/components/saunum/const.py +++ b/homeassistant/components/saunum/const.py @@ -3,14 +3,7 @@ from datetime import timedelta from typing import Final -from homeassistant.const import Platform - DOMAIN: Final = "saunum" -# Platforms -PLATFORMS: list[Platform] = [ - Platform.CLIMATE, -] - DEFAULT_SCAN_INTERVAL: Final = timedelta(seconds=60) DELAYED_REFRESH_SECONDS: Final = timedelta(seconds=3) diff --git a/homeassistant/components/saunum/light.py b/homeassistant/components/saunum/light.py new file mode 100644 index 00000000000..179672b4737 --- /dev/null +++ b/homeassistant/components/saunum/light.py @@ -0,0 +1,71 @@ +"""Light platform for Saunum Leil Sauna Control Unit.""" + +from __future__ import annotations + +from typing import Any + +from pysaunum import SaunumException + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LeilSaunaConfigEntry +from .const import DOMAIN +from .entity import LeilSaunaEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LeilSaunaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Saunum Leil Sauna light entity.""" + coordinator = entry.runtime_data + async_add_entities([LeilSaunaLight(coordinator)]) + + +class LeilSaunaLight(LeilSaunaEntity, LightEntity): + """Representation of a Saunum Leil Sauna light entity.""" + + _attr_translation_key = "light" + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + + def __init__(self, coordinator) -> None: + """Initialize the light entity.""" + super().__init__(coordinator) + # Override unique_id to differentiate from climate entity + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_light" + + @property + def is_on(self) -> bool | None: + """Return True if light is on.""" + return self.coordinator.data.light_on + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + try: + await self.coordinator.client.async_set_light_control(True) + except SaunumException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_light_on_failed", + ) from err + + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + try: + await self.coordinator.client.async_set_light_control(False) + except SaunumException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_light_off_failed", + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/saunum/quality_scale.yaml b/homeassistant/components/saunum/quality_scale.yaml index 10ae41d71ca..95aaebae039 100644 --- a/homeassistant/components/saunum/quality_scale.yaml +++ b/homeassistant/components/saunum/quality_scale.yaml @@ -59,7 +59,7 @@ rules: entity-category: done entity-device-class: done entity-disabled-by-default: todo - entity-translations: todo + entity-translations: done exception-translations: done icon-translations: todo reconfiguration-flow: todo diff --git a/homeassistant/components/saunum/strings.json b/homeassistant/components/saunum/strings.json index 0d9b727a2f3..9b70fc45e9a 100644 --- a/homeassistant/components/saunum/strings.json +++ b/homeassistant/components/saunum/strings.json @@ -19,6 +19,13 @@ } } }, + "entity": { + "light": { + "light": { + "name": "[%key:component::light::title%]" + } + } + }, "exceptions": { "communication_error": { "message": "Communication error: {error}" @@ -29,6 +36,12 @@ "set_hvac_mode_failed": { "message": "Failed to set HVAC mode to {hvac_mode}" }, + "set_light_off_failed": { + "message": "Failed to turn off light" + }, + "set_light_on_failed": { + "message": "Failed to turn on light" + }, "set_temperature_failed": { "message": "Failed to set temperature to {temperature}" } diff --git a/tests/components/saunum/conftest.py b/tests/components/saunum/conftest.py index e93cb66dc6d..7c51962dbf6 100644 --- a/tests/components/saunum/conftest.py +++ b/tests/components/saunum/conftest.py @@ -8,7 +8,7 @@ from pysaunum import SaunumData import pytest from homeassistant.components.saunum.const import DOMAIN -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -24,6 +24,12 @@ def patch_delayed_refresh_seconds() -> Generator[None]: yield +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.CLIMATE, Platform.LIGHT] + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -78,11 +84,13 @@ async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_saunum_client: MagicMock, + platforms: list[Platform], ) -> MockConfigEntry: """Set up the integration for testing.""" mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + with patch("homeassistant.components.saunum.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() return mock_config_entry diff --git a/tests/components/saunum/snapshots/test_light.ambr b/tests/components/saunum/snapshots/test_light.ambr new file mode 100644 index 00000000000..bc4481fca03 --- /dev/null +++ b/tests/components/saunum/snapshots/test_light.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_entities[light.saunum_leil_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.saunum_leil_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'saunum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.saunum_leil_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Saunum Leil Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.saunum_leil_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/saunum/test_climate.py b/tests/components/saunum/test_climate.py index e2b1f6f8fcd..a7942ee8f86 100644 --- a/tests/components/saunum/test_climate.py +++ b/tests/components/saunum/test_climate.py @@ -25,7 +25,12 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -33,6 +38,12 @@ from homeassistant.helpers import entity_registry as er 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.CLIMATE] + + @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") async def test_entities( hass: HomeAssistant, diff --git a/tests/components/saunum/test_init.py b/tests/components/saunum/test_init.py index fe50e182f84..13efaf70cda 100644 --- a/tests/components/saunum/test_init.py +++ b/tests/components/saunum/test_init.py @@ -46,11 +46,12 @@ async def test_async_setup_entry_connection_failed( async def test_device_entry( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test device registry entry.""" assert ( device_entry := device_registry.async_get_device( - identifiers={(DOMAIN, "01K98T2T85R5GN0ZHYV25VFMMA")} + identifiers={(DOMAIN, mock_config_entry.entry_id)} ) ) assert device_entry == snapshot diff --git a/tests/components/saunum/test_light.py b/tests/components/saunum/test_light.py new file mode 100644 index 00000000000..54112df1582 --- /dev/null +++ b/tests/components/saunum/test_light.py @@ -0,0 +1,116 @@ +"""Test the Saunum light platform.""" + +from __future__ import annotations + +from dataclasses import replace + +from pysaunum import SaunumException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.LIGHT] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("service", "expected_state", "client_method", "expected_args"), + [ + (SERVICE_TURN_ON, STATE_ON, "async_set_light_control", (True,)), + (SERVICE_TURN_OFF, STATE_OFF, "async_set_light_control", (False,)), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_light_service_calls( + hass: HomeAssistant, + mock_saunum_client, + service: str, + expected_state: str, + client_method: str, + expected_args: tuple, +) -> None: + """Test light service calls.""" + entity_id = "light.saunum_leil_light" + + # Mock the client method to update the coordinator data + async def update_light_state(*args): + """Update the light state in mock data.""" + current_data = mock_saunum_client.async_get_data.return_value + mock_saunum_client.async_get_data.return_value = replace( + current_data, light_on=(expected_state == STATE_ON) + ) + + getattr(mock_saunum_client, client_method).side_effect = update_light_state + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + getattr(mock_saunum_client, client_method).assert_called_once_with(*expected_args) + + # Verify state updated + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("service", "expected_error"), + [ + (SERVICE_TURN_ON, "Failed to turn on light"), + (SERVICE_TURN_OFF, "Failed to turn off light"), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_light_service_call_failure( + hass: HomeAssistant, + mock_saunum_client, + service: str, + expected_error: str, +) -> None: + """Test handling of light service call failures.""" + entity_id = "light.saunum_leil_light" + + # Make the client method raise an exception + mock_saunum_client.async_set_light_control.side_effect = SaunumException( + "Connection lost" + ) + + with pytest.raises(HomeAssistantError, match=expected_error): + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + )