From 4efb6b9b5609b424cece280313a0ca4e143fda70 Mon Sep 17 00:00:00 2001 From: MoonDevLT <107535193+MoonDevLT@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:22:44 +0200 Subject: [PATCH] Add color modes to Lunatone light entity (#167574) --- homeassistant/components/lunatone/light.py | 61 +++++- tests/components/lunatone/__init__.py | 42 +++- tests/components/lunatone/conftest.py | 24 +++ .../lunatone/snapshots/test_diagnostics.ambr | 191 +++++++++++++++++ .../lunatone/snapshots/test_light.ambr | 195 ++++++++++++++++++ tests/components/lunatone/test_light.py | 131 +++++++++++- 6 files changed, 636 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py index fa2a9c1873fc..bfba1f303fad 100644 --- a/homeassistant/components/lunatone/light.py +++ b/homeassistant/components/lunatone/light.py @@ -9,6 +9,9 @@ from lunatone_rest_api_client.models import LineStatus from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, ColorMode, LightEntity, brightness_supported, @@ -72,6 +75,8 @@ class LunatoneLight( _attr_has_entity_name = True _attr_name = None _attr_should_poll = False + _attr_min_color_temp_kelvin = 1000 + _attr_max_color_temp_kelvin = 10000 def __init__( self, @@ -121,7 +126,13 @@ class LunatoneLight( @property def color_mode(self) -> ColorMode: """Return the color mode of the light.""" - if self._device is not None and self._device.brightness is not None: + if self._device.rgbw_color is not None: + return ColorMode.RGBW + if self._device.rgb_color is not None: + return ColorMode.RGB + if self._device.color_temperature is not None: + return ColorMode.COLOR_TEMP + if self._device.brightness is not None: return ColorMode.BRIGHTNESS return ColorMode.ONOFF @@ -130,6 +141,32 @@ class LunatoneLight( """Return the supported color modes.""" return {self.color_mode} + @property + def color_temp_kelvin(self) -> int | None: + """Return the color temp of this light in kelvin.""" + return self._device.color_temperature + + @property + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the RGB color of this light.""" + rgb_color = self._device.rgb_color + return rgb_color and ( + round(rgb_color[0] * 255), + round(rgb_color[1] * 255), + round(rgb_color[2] * 255), + ) + + @property + def rgbw_color(self) -> tuple[int, int, int, int] | None: + """Return the RGBW color of this light.""" + rgbw_color = self._device.rgbw_color + return rgbw_color and ( + round(rgbw_color[0] * 255), + round(rgbw_color[1] * 255), + round(rgbw_color[2] * 255), + round(rgbw_color[3] * 255), + ) + @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" @@ -139,12 +176,24 @@ class LunatoneLight( async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" if brightness_supported(self.supported_color_modes): - await self._device.fade_to_brightness( - brightness_to_value( - self.BRIGHTNESS_SCALE, - kwargs.get(ATTR_BRIGHTNESS, self._last_brightness), + if ATTR_COLOR_TEMP_KELVIN in kwargs: + await self._device.fade_to_color_temperature( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) + if ATTR_RGB_COLOR in kwargs: + await self._device.fade_to_rgbw_color( + tuple(color / 255 for color in kwargs[ATTR_RGB_COLOR]) + ) + if ATTR_RGBW_COLOR in kwargs: + rgbw_color = tuple(color / 255 for color in kwargs[ATTR_RGBW_COLOR]) + await self._device.fade_to_rgbw_color(rgbw_color[:-1], rgbw_color[-1]) + if ATTR_BRIGHTNESS in kwargs or not self.is_on: + await self._device.fade_to_brightness( + brightness_to_value( + self.BRIGHTNESS_SCALE, + kwargs.get(ATTR_BRIGHTNESS, self._last_brightness), + ) ) - ) else: await self._device.switch_on() await self.coordinator.async_refresh() diff --git a/tests/components/lunatone/__init__.py b/tests/components/lunatone/__init__.py index 12fd58b2a80a..0c2580b5ce40 100644 --- a/tests/components/lunatone/__init__.py +++ b/tests/components/lunatone/__init__.py @@ -11,7 +11,7 @@ from lunatone_rest_api_client.models import ( InfoData, LineStatus, ) -from lunatone_rest_api_client.models.common import Status +from lunatone_rest_api_client.models.common import ColorRGBData, ColorWAFData, Status from lunatone_rest_api_client.models.devices import DeviceStatus from homeassistant.core import HomeAssistant @@ -126,6 +126,46 @@ def build_device_data_list() -> list[DeviceData]: address=1, line=0, ), + DeviceData( + id=3, + name="Device 3", + available=True, + status=DeviceStatus(), + features=FeaturesStatus( + switchable=Status[bool](status=False), + dimmable=Status[float](status=0.0), + colorKelvin=Status[int](status=1000), + ), + address=2, + line=0, + ), + DeviceData( + id=4, + name="Device 4", + available=True, + status=DeviceStatus(), + features=FeaturesStatus( + switchable=Status[bool](status=False), + dimmable=Status[float](status=0.0), + colorRGB=Status[ColorRGBData](status=ColorRGBData(r=0, g=0, b=0)), + ), + address=3, + line=0, + ), + DeviceData( + id=5, + name="Device 5", + available=True, + status=DeviceStatus(), + features=FeaturesStatus( + switchable=Status[bool](status=False), + dimmable=Status[float](status=0.0), + colorRGB=Status[ColorRGBData](status=ColorRGBData(r=0, g=0, b=0)), + colorWAF=Status[ColorWAFData](status=ColorWAFData(w=0, a=0, f=0)), + ), + address=4, + line=0, + ), ] diff --git a/tests/components/lunatone/conftest.py b/tests/components/lunatone/conftest.py index 8d279bcb80a8..574d07e38fe9 100644 --- a/tests/components/lunatone/conftest.py +++ b/tests/components/lunatone/conftest.py @@ -45,6 +45,30 @@ def mock_lunatone_devices() -> Generator[AsyncMock]: if device.data.features.dimmable else None ) + device.color_temperature = ( + device.data.features.color_kelvin.status + if device.data.features.color_kelvin + else None + ) + device.rgb_color = ( + ( + device.data.features.color_rgb.status.red, + device.data.features.color_rgb.status.green, + device.data.features.color_rgb.status.blue, + ) + if device.data.features.color_rgb + else None + ) + device.rgbw_color = ( + ( + device.data.features.color_rgb.status.red, + device.data.features.color_rgb.status.green, + device.data.features.color_rgb.status.blue, + device.data.features.color_waf.status.white, + ) + if device.data.features.color_rgb and device.data.features.color_waf + else None + ) device_list.append(device) return device_list diff --git a/tests/components/lunatone/snapshots/test_diagnostics.ambr b/tests/components/lunatone/snapshots/test_diagnostics.ambr index ca291b9726fc..d5f6d6136391 100644 --- a/tests/components/lunatone/snapshots/test_diagnostics.ambr +++ b/tests/components/lunatone/snapshots/test_diagnostics.ambr @@ -114,6 +114,197 @@ 'time_signature': None, 'type': 'default', }), + dict({ + 'address': 2, + 'available': True, + 'dali_types': list([ + ]), + 'features': dict({ + 'color_kelvin': dict({ + 'status': 1000.0, + }), + 'color_kelvin_with_fade': None, + 'color_rgb': None, + 'color_rgb_with_fade': None, + 'color_waf': None, + 'color_waf_with_fade': None, + 'color_xy': None, + 'color_xy_with_fade': None, + 'dali_cmd16': None, + 'dim_down': None, + 'dim_up': None, + 'dimmable': dict({ + 'status': 0.0, + }), + 'dimmable_kelvin': None, + 'dimmable_rgb': None, + 'dimmable_waf': None, + 'dimmable_with_fade': None, + 'dimmable_xy': None, + 'fade_rate': None, + 'fade_time': None, + 'goto_last_active': None, + 'goto_last_active_with_fade': None, + 'save_to_scene': None, + 'scene': None, + 'scene_with_fade': None, + 'switchable': dict({ + 'status': False, + }), + }), + 'groups': list([ + ]), + 'id': 3, + 'line': 0, + 'name': 'Device 3', + 'scenes': list([ + ]), + 'status': dict({ + 'control_gear_failure': False, + 'fade_running': False, + 'is_unaddressed': False, + 'lamp_failure': False, + 'lamp_on': False, + 'limit_error': False, + 'power_cycle_see': False, + 'raw': 0, + 'reset_state': False, + }), + 'time_signature': None, + 'type': 'default', + }), + dict({ + 'address': 3, + 'available': True, + 'dali_types': list([ + ]), + 'features': dict({ + 'color_kelvin': None, + 'color_kelvin_with_fade': None, + 'color_rgb': dict({ + 'status': dict({ + 'blue': 0.0, + 'green': 0.0, + 'red': 0.0, + }), + }), + 'color_rgb_with_fade': None, + 'color_waf': None, + 'color_waf_with_fade': None, + 'color_xy': None, + 'color_xy_with_fade': None, + 'dali_cmd16': None, + 'dim_down': None, + 'dim_up': None, + 'dimmable': dict({ + 'status': 0.0, + }), + 'dimmable_kelvin': None, + 'dimmable_rgb': None, + 'dimmable_waf': None, + 'dimmable_with_fade': None, + 'dimmable_xy': None, + 'fade_rate': None, + 'fade_time': None, + 'goto_last_active': None, + 'goto_last_active_with_fade': None, + 'save_to_scene': None, + 'scene': None, + 'scene_with_fade': None, + 'switchable': dict({ + 'status': False, + }), + }), + 'groups': list([ + ]), + 'id': 4, + 'line': 0, + 'name': 'Device 4', + 'scenes': list([ + ]), + 'status': dict({ + 'control_gear_failure': False, + 'fade_running': False, + 'is_unaddressed': False, + 'lamp_failure': False, + 'lamp_on': False, + 'limit_error': False, + 'power_cycle_see': False, + 'raw': 0, + 'reset_state': False, + }), + 'time_signature': None, + 'type': 'default', + }), + dict({ + 'address': 4, + 'available': True, + 'dali_types': list([ + ]), + 'features': dict({ + 'color_kelvin': None, + 'color_kelvin_with_fade': None, + 'color_rgb': dict({ + 'status': dict({ + 'blue': 0.0, + 'green': 0.0, + 'red': 0.0, + }), + }), + 'color_rgb_with_fade': None, + 'color_waf': dict({ + 'status': dict({ + 'amber': 0.0, + 'free_color': 0.0, + 'white': 0.0, + }), + }), + 'color_waf_with_fade': None, + 'color_xy': None, + 'color_xy_with_fade': None, + 'dali_cmd16': None, + 'dim_down': None, + 'dim_up': None, + 'dimmable': dict({ + 'status': 0.0, + }), + 'dimmable_kelvin': None, + 'dimmable_rgb': None, + 'dimmable_waf': None, + 'dimmable_with_fade': None, + 'dimmable_xy': None, + 'fade_rate': None, + 'fade_time': None, + 'goto_last_active': None, + 'goto_last_active_with_fade': None, + 'save_to_scene': None, + 'scene': None, + 'scene_with_fade': None, + 'switchable': dict({ + 'status': False, + }), + }), + 'groups': list([ + ]), + 'id': 5, + 'line': 0, + 'name': 'Device 5', + 'scenes': list([ + ]), + 'status': dict({ + 'control_gear_failure': False, + 'fade_running': False, + 'is_unaddressed': False, + 'lamp_failure': False, + 'lamp_on': False, + 'limit_error': False, + 'power_cycle_see': False, + 'raw': 0, + 'reset_state': False, + }), + 'time_signature': None, + 'type': 'default', + }), ]), 'info': dict({ 'descriptor': dict({ diff --git a/tests/components/lunatone/snapshots/test_light.ambr b/tests/components/lunatone/snapshots/test_light.ambr index a429bbf1de66..8fd5c20d1bd5 100644 --- a/tests/components/lunatone/snapshots/test_light.ambr +++ b/tests/components/lunatone/snapshots/test_light.ambr @@ -240,3 +240,198 @@ 'state': 'off', }) # --- +# name: test_setup[light.device_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 10000, + 'min_color_temp_kelvin': 1000, + '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.device_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'lunatone', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-device3', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.device_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Device 3', + 'hs_color': None, + 'max_color_temp_kelvin': 10000, + 'min_color_temp_kelvin': 1000, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.device_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup[light.device_4-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.device_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'lunatone', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-device4', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.device_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Device 4', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.device_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup[light.device_5-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.device_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'lunatone', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-device5', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.device_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Device 5', + 'hs_color': None, + 'rgb_color': None, + 'rgbw_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.device_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lunatone/test_light.py b/tests/components/lunatone/test_light.py index 00fd952f7611..5e8bab55ff60 100644 --- a/tests/components/lunatone/test_light.py +++ b/tests/components/lunatone/test_light.py @@ -4,9 +4,16 @@ import copy from unittest.mock import AsyncMock from lunatone_rest_api_client.models import LineStatus +import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + DOMAIN as LIGHT_DOMAIN, +) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -230,3 +237,125 @@ async def test_line_broadcast_line_present( await setup_integration(hass, mock_config_entry) assert not hass.states.async_entity_ids("light") + + +@pytest.mark.parametrize( + "color_temp_kelvin", + [10000, 5000, 1000], +) +async def test_turn_on_with_color_temperature( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_config_entry: MockConfigEntry, + color_temp_kelvin: int, +) -> None: + """Test the color temperature of the light can be set.""" + device_id = 3 + entity_id = f"light.device_{device_id}" + + await setup_integration(hass, mock_config_entry) + + async def fake_update(): + device = mock_lunatone_devices.data.devices[device_id - 1] + device.features.switchable.status = True + device.features.color_kelvin.status = float(color_temp_kelvin) + + mock_lunatone_devices.async_update.side_effect = fake_update + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: entity_id, + ATTR_COLOR_TEMP_KELVIN: color_temp_kelvin, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == color_temp_kelvin + + +@pytest.mark.parametrize( + "rgb_color", + [(255, 128, 0), (0, 255, 128), (128, 0, 255)], +) +async def test_turn_on_with_rgb_color( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_config_entry: MockConfigEntry, + rgb_color: tuple[int, int, int], +) -> None: + """Test the RGB color of the light can be set.""" + device_id = 4 + entity_id = f"light.device_{device_id}" + + await setup_integration(hass, mock_config_entry) + + async def fake_update(): + device = mock_lunatone_devices.data.devices[device_id - 1] + device.features.switchable.status = True + device.features.color_rgb.status.red = rgb_color[0] / 255 + device.features.color_rgb.status.green = rgb_color[1] / 255 + device.features.color_rgb.status.blue = rgb_color[2] / 255 + + mock_lunatone_devices.async_update.side_effect = fake_update + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: entity_id, + ATTR_RGB_COLOR: rgb_color, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_RGB_COLOR] == rgb_color + + +@pytest.mark.parametrize( + "rgbw_color", + [(255, 128, 0, 255), (0, 255, 128, 128), (128, 0, 255, 0)], +) +async def test_turn_on_with_rgbw_color( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_config_entry: MockConfigEntry, + rgbw_color: tuple[int, int, int, int], +) -> None: + """Test the RGBW color of the light can be set.""" + device_id = 5 + entity_id = f"light.device_{device_id}" + + await setup_integration(hass, mock_config_entry) + + async def fake_update(): + device = mock_lunatone_devices.data.devices[device_id - 1] + device.features.switchable.status = True + device.features.color_rgb.status.red = rgbw_color[0] / 255 + device.features.color_rgb.status.green = rgbw_color[1] / 255 + device.features.color_rgb.status.blue = rgbw_color[2] / 255 + device.features.color_waf.status.white = rgbw_color[3] / 255 + + mock_lunatone_devices.async_update.side_effect = fake_update + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: entity_id, + ATTR_RGBW_COLOR: rgbw_color, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_RGBW_COLOR] == rgbw_color