Add color modes to Lunatone light entity (#167574)

This commit is contained in:
MoonDevLT
2026-04-09 14:22:44 +02:00
committed by GitHub
parent a9f0cd203c
commit 4efb6b9b56
6 changed files with 636 additions and 8 deletions
+55 -6
View File
@@ -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()
+41 -1
View File
@@ -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,
),
]
+24
View File
@@ -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
@@ -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({
@@ -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([
<ColorMode.COLOR_TEMP: 'color_temp'>,
]),
}),
'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.device_3',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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([
<ColorMode.COLOR_TEMP: 'color_temp'>,
]),
'supported_features': <LightEntityFeature: 0>,
'xy_color': None,
}),
'context': <ANY>,
'entity_id': 'light.device_3',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_setup[light.device_4-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'supported_color_modes': list([
<ColorMode.RGB: 'rgb'>,
]),
}),
'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.device_4',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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([
<ColorMode.RGB: 'rgb'>,
]),
'supported_features': <LightEntityFeature: 0>,
'xy_color': None,
}),
'context': <ANY>,
'entity_id': 'light.device_4',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_setup[light.device_5-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'supported_color_modes': list([
<ColorMode.RGBW: 'rgbw'>,
]),
}),
'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.device_5',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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([
<ColorMode.RGBW: 'rgbw'>,
]),
'supported_features': <LightEntityFeature: 0>,
'xy_color': None,
}),
'context': <ANY>,
'entity_id': 'light.device_5',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
+130 -1
View File
@@ -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