Improve color conversion for RGBWW lights (#49807)

This commit is contained in:
Erik Montnemery 2021-04-28 15:46:41 +02:00 committed by GitHub
parent 9e1042d9e0
commit e96cbccc92
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 123 additions and 31 deletions

View File

@ -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

View File

@ -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:

View File

@ -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):

View File

@ -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(