From 0232c8dcb06108be351e316e1cb51938ad63f0b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Maggioni?= Date: Fri, 1 Dec 2023 08:26:07 +0100 Subject: [PATCH] Add temperature to the light color mode parameter fallbacks (#86026) * Add color temperature to the color mode fallbacks * Manually add ATTR_COLOR_TEMP since ATTR_COLOR_TEMP_KELVIN is pre-parsed * Include the legacy ATTR_COLOR_TEMP attribute in the tests * Apply suggestions from code review Co-authored-by: Erik Montnemery * Add citation for McCamy's approximation formula If still existing, also see page 3 of https://www.st.com/resource/en/application_note/an5638-how-correlated-color-temperature-is-calculated-by-vd6283-stmicroelectronics.pdf * Update homeassistant/util/color.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/light/__init__.py | 39 ++++++++++++++++++++++ homeassistant/util/color.py | 12 +++++++ tests/components/light/test_init.py | 37 ++++++++++++++++++++ tests/util/test_color.py | 9 +++++ 4 files changed, 97 insertions(+) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 78cccde5890..3bb3797c284 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -500,6 +500,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_hs_to_xy(*hs_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes: assert (rgb_color := params.pop(ATTR_RGB_COLOR)) is not None if ColorMode.RGBW in supported_color_modes: @@ -515,6 +523,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes: xy_color = params.pop(ATTR_XY_COLOR) if ColorMode.HS in supported_color_modes: @@ -529,6 +545,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin ) + elif ColorMode.COLOR_TEMP in supported_color_modes: + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes: rgbw_color = params.pop(ATTR_RGBW_COLOR) rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color) @@ -542,6 +565,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ( ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes ): @@ -558,6 +589,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) # If white is set to True, set it to the light's brightness # Add a warning in Home Assistant Core 2023.5 if the brightness is set to an diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 8e7fc3dc155..4520a62a5d8 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -576,6 +576,18 @@ def _white_levels_to_color_temperature( ), min(255, round(brightness * 255)) +def color_xy_to_temperature(x: float, y: float) -> int: + """Convert an xy color to a color temperature in Kelvin. + + Uses McCamy's approximation (https://doi.org/10.1002/col.5080170211), + close enough for uses between 2000 K and 10000 K. + """ + n = (x - 0.3320) / (0.1858 - y) + CCT = 437 * (n**3) + 3601 * (n**2) + 6861 * n + 5517 + + return int(CCT) + + def _clamp(color_component: float, minimum: float = 0, maximum: float = 255) -> float: """Clamp the given color component value between the given min and max values. diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 675057899b0..962c5500f06 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1444,6 +1444,7 @@ async def test_light_service_call_color_conversion( platform.ENTITIES.append(platform.MockLight("Test_legacy", STATE_ON)) platform.ENTITIES.append(platform.MockLight("Test_rgbw", STATE_ON)) platform.ENTITIES.append(platform.MockLight("Test_rgbww", STATE_ON)) + platform.ENTITIES.append(platform.MockLight("Test_temperature", STATE_ON)) entity0 = platform.ENTITIES[0] entity0.supported_color_modes = {light.ColorMode.HS} @@ -1470,6 +1471,9 @@ async def test_light_service_call_color_conversion( entity6 = platform.ENTITIES[6] entity6.supported_color_modes = {light.ColorMode.RGBWW} + entity7 = platform.ENTITIES[7] + entity7.supported_color_modes = {light.ColorMode.COLOR_TEMP} + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1498,6 +1502,9 @@ async def test_light_service_call_color_conversion( state = hass.states.get(entity6.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW] + state = hass.states.get(entity7.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] + await hass.services.async_call( "light", "turn_on", @@ -1510,6 +1517,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 100), @@ -1530,6 +1538,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 255, "rgbw_color": (0, 0, 255, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 255, "rgbww_color": (0, 0, 255, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 255, "color_temp_kelvin": 1739, "color_temp": 575} await hass.services.async_call( "light", @@ -1543,6 +1553,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 0), @@ -1564,6 +1575,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint of the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 255, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 255, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1577,6 +1590,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (128, 0, 0), @@ -1597,6 +1611,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 6279, "color_temp": 159} await hass.services.async_call( "light", @@ -1610,6 +1626,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (255, 255, 255), @@ -1631,6 +1648,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1644,6 +1663,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.1, 0.8), @@ -1664,6 +1684,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (0, 255, 22, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (0, 255, 22, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 8645, "color_temp": 115} await hass.services.async_call( "light", @@ -1677,6 +1699,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.323, 0.329), @@ -1698,6 +1721,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 75, 140, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1711,6 +1736,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (128, 0, 0, 64), @@ -1732,6 +1758,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (128, 0, 30, 117, 117)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3011, "color_temp": 332} await hass.services.async_call( "light", @@ -1745,6 +1773,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (255, 255, 255, 255), @@ -1766,6 +1795,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1779,6 +1810,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (128, 0, 0, 64, 32), @@ -1799,6 +1831,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (128, 9, 0, 33)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 64, 32)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3845, "color_temp": 260} await hass.services.async_call( "light", @@ -1812,6 +1846,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (255, 255, 255, 255, 255), @@ -1833,6 +1868,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (96, 44, 0, 255)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (255, 255, 255, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3451, "color_temp": 289} async def test_light_service_call_color_conversion_named_tuple( diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 7c5e959aabc..a7e6ba9ab46 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -270,6 +270,15 @@ def test_color_rgbw_to_rgb() -> None: assert color_util.color_rgbw_to_rgb(0, 0, 0, 127) == (127, 127, 127) +def test_color_xy_to_temperature() -> None: + """Test color_xy_to_temperature.""" + assert color_util.color_xy_to_temperature(0.5119, 0.4147) == 2136 + assert color_util.color_xy_to_temperature(0.368, 0.3686) == 4302 + assert color_util.color_xy_to_temperature(0.4448, 0.4066) == 2893 + assert color_util.color_xy_to_temperature(0.1, 0.8) == 8645 + assert color_util.color_xy_to_temperature(0.5, 0.4) == 2140 + + def test_color_rgb_to_hex() -> None: """Test color_rgb_to_hex.""" assert color_util.color_rgb_to_hex(255, 255, 255) == "ffffff"