From e96cbccc92deb95c857cab33dcd4b8f0093e4ac6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 28 Apr 2021 15:46:41 +0200 Subject: [PATCH] Improve color conversion for RGBWW lights (#49807) --- homeassistant/components/light/__init__.py | 38 ++++++++----- homeassistant/util/color.py | 62 ++++++++++++++++++---- tests/components/light/test_init.py | 42 +++++++++++++-- tests/components/mqtt/test_light_json.py | 12 ++--- 4 files changed, 123 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 2c4598adcdaa..cdb349104928 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -73,7 +73,13 @@ VALID_COLOR_MODES = { COLOR_MODE_RGBWW, } COLOR_MODES_BRIGHTNESS = VALID_COLOR_MODES - {COLOR_MODE_ONOFF} -COLOR_MODES_COLOR = {COLOR_MODE_HS, COLOR_MODE_RGB, COLOR_MODE_RGBW, COLOR_MODE_XY} +COLOR_MODES_COLOR = { + COLOR_MODE_HS, + COLOR_MODE_RGB, + COLOR_MODE_RGBW, + COLOR_MODE_RGBWW, + COLOR_MODE_XY, +} def valid_supported_color_modes(color_modes: Iterable[str]) -> set[str]: @@ -323,10 +329,9 @@ async def async_setup(hass, config): # noqa: C901 rgb_color = color_util.color_hs_to_RGB(*hs_color) params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) elif COLOR_MODE_RGBWW in supported_color_modes: - params[ATTR_RGBWW_COLOR] = ( - *color_util.color_hs_to_RGB(*hs_color), - 0, - 0, + rgb_color = color_util.color_hs_to_RGB(*hs_color) + params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( + *rgb_color, light.min_mireds, light.max_mireds ) elif COLOR_MODE_XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) @@ -335,7 +340,9 @@ async def async_setup(hass, config): # noqa: C901 if COLOR_MODE_RGBW in supported_color_modes: params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) if COLOR_MODE_RGBWW in supported_color_modes: - params[ATTR_RGBWW_COLOR] = (*rgb_color, 0, 0) + params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( + *rgb_color, light.min_mireds, light.max_mireds + ) elif COLOR_MODE_HS in supported_color_modes: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif COLOR_MODE_XY in supported_color_modes: @@ -350,10 +357,9 @@ async def async_setup(hass, config): # noqa: C901 rgb_color = color_util.color_xy_to_RGB(*xy_color) params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) elif COLOR_MODE_RGBWW in supported_color_modes: - params[ATTR_RGBWW_COLOR] = ( - *color_util.color_xy_to_RGB(*xy_color), - 0, - 0, + rgb_color = color_util.color_xy_to_RGB(*xy_color) + params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( + *rgb_color, light.min_mireds, light.max_mireds ) # Remove deprecated white value if the light supports color mode @@ -698,6 +704,15 @@ class LightEntity(ToggleEntity): data[ATTR_RGB_COLOR] = tuple(int(x) for x in rgb_color[0:3]) data[ATTR_RGBW_COLOR] = tuple(int(x) for x in rgbw_color[0:4]) data[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif color_mode == COLOR_MODE_RGBWW and self.rgbww_color: + rgbww_color = self.rgbww_color + rgb_color = color_util.color_rgbww_to_rgb( + *rgbww_color, self.min_mireds, self.max_mireds + ) + data[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) + data[ATTR_RGB_COLOR] = tuple(int(x) for x in rgb_color[0:3]) + data[ATTR_RGBWW_COLOR] = tuple(int(x) for x in rgbww_color[0:5]) + data[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) return data @final @@ -735,9 +750,6 @@ class LightEntity(ToggleEntity): if color_mode in COLOR_MODES_COLOR: data.update(self._light_internal_convert_color(color_mode)) - if color_mode == COLOR_MODE_RGBWW: - data[ATTR_RGBWW_COLOR] = self.rgbww_color - if supported_features & SUPPORT_COLOR_TEMP and not self.supported_color_modes: # Backwards compatibility # Add warning in 2021.6, remove in 2021.10 diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 4b5c7a11cbc6..47144f0e7822 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -417,7 +417,7 @@ def color_rgb_to_rgbw(r: int, g: int, b: int) -> tuple[int, int, int, int]: def color_rgbw_to_rgb(r: int, g: int, b: int, w: int) -> tuple[int, int, int]: """Convert an rgbw color to an rgb representation.""" - # Add the white channel back into the rgb channels. + # Add the white channel to the rgb channels. rgb = (r + w, g + w, b + w) # Match the output maximum value to the input. This ensures the @@ -425,6 +425,51 @@ def color_rgbw_to_rgb(r: int, g: int, b: int, w: int) -> tuple[int, int, int]: return _match_max_scale((r, g, b, w), rgb) # type: ignore +def color_rgb_to_rgbww( + r: int, g: int, b: int, min_mireds: int, max_mireds: int +) -> tuple[int, int, int, int, int]: + """Convert an rgb color to an rgbww representation.""" + # Find the color temperature when both white channels have equal brightness + mired_range = max_mireds - min_mireds + mired_midpoint = min_mireds + mired_range / 2 + color_temp_kelvin = color_temperature_mired_to_kelvin(mired_midpoint) + w_r, w_g, w_b = color_temperature_to_rgb(color_temp_kelvin) + + # Find the ratio of the midpoint white in the input rgb channels + white_level = min(r / w_r, g / w_g, b / w_b) + + # Subtract the white portion from the rgb channels. + rgb = (r - w_r * white_level, g - w_g * white_level, b - w_b * white_level) + rgbww = (*rgb, round(white_level * 255), round(white_level * 255)) + + # Match the output maximum value to the input. This ensures the full + # channel range is used. + return _match_max_scale((r, g, b), rgbww) # type: ignore + + +def color_rgbww_to_rgb( + r: int, g: int, b: int, cw: int, ww: int, min_mireds: int, max_mireds: int +) -> tuple[int, int, int]: + """Convert an rgbww color to an rgb representation.""" + # Calculate color temperature of the white channels + mired_range = max_mireds - min_mireds + try: + ct_ratio = ww / (cw + ww) + except ZeroDivisionError: + ct_ratio = 0.5 + color_temp_mired = min_mireds + ct_ratio * mired_range + color_temp_kelvin = color_temperature_mired_to_kelvin(color_temp_mired) + w_r, w_g, w_b = color_temperature_to_rgb(color_temp_kelvin) + white_level = max(cw, ww) / 255 + + # Add the white channels to the rgb channels. + rgb = (r + w_r * white_level, g + w_g * white_level, b + w_b * white_level) + + # Match the output maximum value to the input. This ensures the + # output doesn't overflow. + return _match_max_scale((r, g, b, cw, ww), rgb) # type: ignore + + def color_rgb_to_hex(r: int, g: int, b: int) -> str: """Return a RGB color from a hex color string.""" return f"{round(r):02x}{round(g):02x}{round(b):02x}" @@ -469,13 +514,12 @@ def color_temperature_to_rgb( return red, green, blue -def _bound(color_component: float, minimum: float = 0, maximum: float = 255) -> float: +def _clamp(color_component: float, minimum: float = 0, maximum: float = 255) -> float: """ - Bound the given color component value between the given min and max values. + Clamp the given color component value between the given min and max values. - The minimum and maximum values will be included in the valid output. - i.e. Given a color_component of 0 and a minimum of 10, the returned value - will be 10. + The range defined by the minimum and maximum values is inclusive, i.e. given a + color_component of 0 and a minimum of 10, the returned value is 10. """ color_component_out = max(color_component, minimum) return min(color_component_out, maximum) @@ -486,7 +530,7 @@ def _get_red(temperature: float) -> float: if temperature <= 66: return 255 tmp_red = 329.698727446 * math.pow(temperature - 60, -0.1332047592) - return _bound(tmp_red) + return _clamp(tmp_red) def _get_green(temperature: float) -> float: @@ -495,7 +539,7 @@ def _get_green(temperature: float) -> float: green = 99.4708025861 * math.log(temperature) - 161.1195681661 else: green = 288.1221695283 * math.pow(temperature - 60, -0.0755148492) - return _bound(green) + return _clamp(green) def _get_blue(temperature: float) -> float: @@ -505,7 +549,7 @@ def _get_blue(temperature: float) -> float: if temperature <= 19: return 0 blue = 138.5177312231 * math.log(temperature - 10) - 305.0447927307 - return _bound(blue) + return _clamp(blue) def color_temperature_mired_to_kelvin(mired_temperature: float) -> int: diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 0f2a8a73e9e0..752de8675426 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1202,6 +1202,39 @@ async def test_light_state_rgbw(hass): } +async def test_light_state_rgbww(hass): + """Test rgbww color conversion in state updates.""" + platform = getattr(hass.components, "test.light") + platform.init(empty=True) + + platform.ENTITIES.append(platform.MockLight("Test_rgbww", STATE_ON)) + + entity0 = platform.ENTITIES[0] + entity0.supported_color_modes = {light.COLOR_MODE_RGBWW} + entity0.color_mode = light.COLOR_MODE_RGBWW + entity0.hs_color = "Invalid" # Should be ignored + entity0.rgb_color = "Invalid" # Should be ignored + entity0.rgbw_color = "Invalid" # Should be ignored + entity0.rgbww_color = (1, 2, 3, 4, 5) + entity0.white_value = "Invalid" # Should be ignored + entity0.xy_color = "Invalid" # Should be ignored + + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert dict(state.attributes) == { + "color_mode": light.COLOR_MODE_RGBWW, + "friendly_name": "Test_rgbww", + "supported_color_modes": [light.COLOR_MODE_RGBWW], + "supported_features": 0, + "hs_color": (60.0, 20.0), + "rgb_color": (5, 5, 4), + "rgbww_color": (1, 2, 3, 4, 5), + "xy_color": (0.339, 0.354), + } + + async def test_light_service_call_color_conversion(hass): """Test color conversion in service calls.""" platform = getattr(hass.components, "test.light") @@ -1332,7 +1365,8 @@ async def test_light_service_call_color_conversion(hass): _, data = entity5.last_call("turn_on") assert data == {"brightness": 255, "rgbw_color": (0, 0, 0, 255)} _, data = entity6.last_call("turn_on") - assert data == {"brightness": 255, "rgbww_color": (255, 255, 255, 0, 0)} + # The midpoint the the white channels is warm, compensated by adding green + blue + assert data == {"brightness": 255, "rgbww_color": (0, 76, 141, 255, 255)} await hass.services.async_call( "light", @@ -1398,7 +1432,8 @@ async def test_light_service_call_color_conversion(hass): _, data = entity5.last_call("turn_on") assert data == {"brightness": 128, "rgbw_color": (0, 0, 0, 255)} _, data = entity6.last_call("turn_on") - assert data == {"brightness": 128, "rgbww_color": (255, 255, 255, 0, 0)} + # The midpoint the the white channels is warm, compensated by adding green + blue + assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} await hass.services.async_call( "light", @@ -1464,7 +1499,8 @@ async def test_light_service_call_color_conversion(hass): _, data = entity5.last_call("turn_on") assert data == {"brightness": 128, "rgbw_color": (1, 0, 0, 255)} _, data = entity6.last_call("turn_on") - assert data == {"brightness": 128, "rgbww_color": (255, 254, 254, 0, 0)} + # The midpoint the the white channels is warm, compensated by adding green + blue + assert data == {"brightness": 128, "rgbww_color": (0, 75, 140, 255, 255)} async def test_light_state_color_conversion(hass): diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 432c17cda254..f892b6a3bbda 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -480,12 +480,12 @@ async def test_controlling_state_via_topic2(hass, mqtt_mock, caplog): assert state.attributes.get("color_mode") == "rgbww" assert state.attributes.get("color_temp") is None assert state.attributes.get("effect") == "colorloop" - assert state.attributes.get("hs_color") is None - assert state.attributes.get("rgb_color") is None + assert state.attributes.get("hs_color") == (20.552, 70.98) + assert state.attributes.get("rgb_color") == (255, 136, 74) assert state.attributes.get("rgbw_color") is None assert state.attributes.get("rgbww_color") == (255, 128, 64, 32, 16) assert state.attributes.get("white_value") is None - assert state.attributes.get("xy_color") is None + assert state.attributes.get("xy_color") == (0.571, 0.361) # Light turned off async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"OFF"}') @@ -892,11 +892,11 @@ async def test_sending_mqtt_commands_and_optimistic2(hass, mqtt_mock): assert state.attributes["brightness"] == 75 assert state.attributes["color_mode"] == "rgbww" assert state.attributes["rgbww_color"] == (255, 128, 0, 45, 32) - assert "hs_color" not in state.attributes - assert "rgb_color" not in state.attributes + assert state.attributes["hs_color"] == (29.872, 92.157) + assert state.attributes["rgb_color"] == (255, 137, 20) assert "rgbw_color" not in state.attributes assert "white_value" not in state.attributes - assert "xy_color" not in state.attributes + assert state.attributes["xy_color"] == (0.596, 0.382) mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", JsonValidator(