Improve color conversion for RGBWW lights (#49807)
This commit is contained in:
parent
9e1042d9e0
commit
e96cbccc92
4 changed files with 123 additions and 31 deletions
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue