From 11602c1da07cb0d326e0c12237d9b1afa6a52dbf Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 15 Jan 2019 20:30:50 +0100 Subject: [PATCH] Improve Philips Hue color conversion 2 (#20118) * Add gamut capability to color util * Include gamut in hue_test * Improve Philips Hue color conversion * correct import for new location hue.light * include file changes between PR's * update aiohue version * update aiohue version * update aiohue version * fix hue_test Now Idea why it failed compared to the previous time * Include gamut in hue_test * fix hue_test * Try to test hue gamut conversion supply a color that is well outside the color gamut of the light, and see if the response is correctly converted to within the reach of the light. * switch from gamut A to gamut B for the tests. * remove white space in blanck line * Fix gamut hue test * Add Gamut tests for the util.color * fix hue gamut test * fix hue gamut test * Improve Philips Hue color conversion --- homeassistant/components/hue/__init__.py | 2 +- homeassistant/components/hue/light.py | 18 ++- homeassistant/util/color.py | 150 +++++++++++++++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hue/test_light.py | 138 ++++++++++++++++++--- tests/util/test_color.py | 64 ++++++++++ 7 files changed, 342 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index 9c28d08054b..b10e5bb29de 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -19,7 +19,7 @@ from .bridge import HueBridge # Loading the config flow file will register the flow from .config_flow import configured_hosts -REQUIREMENTS = ['aiohue==1.5.0'] +REQUIREMENTS = ['aiohue==1.8.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 28a2d79de13..7a1449e00c6 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -221,9 +221,16 @@ class HueLight(Light): if is_group: self.is_osram = False self.is_philips = False + self.gamut_typ = 'None' + self.gamut = None else: self.is_osram = light.manufacturername == 'OSRAM' self.is_philips = light.manufacturername == 'Philips' + self.gamut_typ = self.light.colorgamuttype + self.gamut = self.light.colorgamut + if not self.gamut: + err_msg = 'Can not get color gamut of light "%s"' + _LOGGER.warning(err_msg, self.name) @property def unique_id(self): @@ -256,7 +263,7 @@ class HueLight(Light): source = self.light.action if self.is_group else self.light.state if mode in ('xy', 'hs') and 'xy' in source: - return color.color_xy_to_hs(*source['xy']) + return color.color_xy_to_hs(*source['xy'], self.gamut) return None @@ -290,6 +297,11 @@ class HueLight(Light): """Flag supported features.""" return SUPPORT_HUE.get(self.light.type, SUPPORT_HUE_EXTENDED) + @property + def effect(self): + """Return the current effect.""" + return self.light.state.get('effect', None) + @property def effect_list(self): """Return the list of supported effects.""" @@ -331,7 +343,9 @@ class HueLight(Light): # Philips hue bulb models respond differently to hue/sat # requests, so we convert to XY first to ensure a consistent # color. - command['xy'] = color.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) + xy_color = color.color_hs_to_xy(*kwargs[ATTR_HS_COLOR], + self.gamut) + command['xy'] = xy_color elif ATTR_COLOR_TEMP in kwargs: temp = kwargs[ATTR_COLOR_TEMP] command['ct'] = max(self.min_mireds, min(temp, self.max_mireds)) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 0538bfbf369..5a32d89a793 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -2,7 +2,8 @@ import math import colorsys -from typing import Tuple, List +from typing import Tuple, List, Optional +import attr # Official CSS3 colors from w3.org: # https://www.w3.org/TR/2010/PR-css3-color-20101028/#html4 @@ -162,6 +163,24 @@ COLORS = { } +@attr.s() +class XYPoint: + """Represents a CIE 1931 XY coordinate pair.""" + + x = attr.ib(type=float) + y = attr.ib(type=float) + + +@attr.s() +class GamutType: + """Represents the Gamut of a light.""" + + # ColorGamut = gamut(xypoint(xR,yR),xypoint(xG,yG),xypoint(xB,yB)) + red = attr.ib(type=XYPoint) + green = attr.ib(type=XYPoint) + blue = attr.ib(type=XYPoint) + + def color_name_to_rgb(color_name: str) -> Tuple[int, int, int]: """Convert color name to RGB hex value.""" # COLORS map has no spaces in it, so make the color_name have no @@ -174,9 +193,10 @@ def color_name_to_rgb(color_name: str) -> Tuple[int, int, int]: # pylint: disable=invalid-name -def color_RGB_to_xy(iR: int, iG: int, iB: int) -> Tuple[float, float]: +def color_RGB_to_xy(iR: int, iG: int, iB: int, + Gamut: Optional[GamutType] = None) -> Tuple[float, float]: """Convert from RGB color to XY color.""" - return color_RGB_to_xy_brightness(iR, iG, iB)[:2] + return color_RGB_to_xy_brightness(iR, iG, iB, Gamut)[:2] # Taken from: @@ -184,7 +204,8 @@ def color_RGB_to_xy(iR: int, iG: int, iB: int) -> Tuple[float, float]: # License: Code is given as is. Use at your own risk and discretion. # pylint: disable=invalid-name def color_RGB_to_xy_brightness( - iR: int, iG: int, iB: int) -> Tuple[float, float, int]: + iR: int, iG: int, iB: int, + Gamut: Optional[GamutType] = None) -> Tuple[float, float, int]: """Convert from RGB color to XY color.""" if iR + iG + iB == 0: return 0.0, 0.0, 0 @@ -214,19 +235,36 @@ def color_RGB_to_xy_brightness( Y = 1 if Y > 1 else Y brightness = round(Y * 255) + # Check if the given xy value is within the color-reach of the lamp. + if Gamut: + in_reach = check_point_in_lamps_reach((x, y), Gamut) + if not in_reach: + xy_closest = get_closest_point_to_point((x, y), Gamut) + x = xy_closest[0] + y = xy_closest[1] + return round(x, 3), round(y, 3), brightness -def color_xy_to_RGB(vX: float, vY: float) -> Tuple[int, int, int]: +def color_xy_to_RGB( + vX: float, vY: float, + Gamut: Optional[GamutType] = None) -> Tuple[int, int, int]: """Convert from XY to a normalized RGB.""" - return color_xy_brightness_to_RGB(vX, vY, 255) + return color_xy_brightness_to_RGB(vX, vY, 255, Gamut) # Converted to Python from Obj-C, original source from: # http://www.developers.meethue.com/documentation/color-conversions-rgb-xy -def color_xy_brightness_to_RGB(vX: float, vY: float, - ibrightness: int) -> Tuple[int, int, int]: +def color_xy_brightness_to_RGB( + vX: float, vY: float, ibrightness: int, + Gamut: Optional[GamutType] = None) -> Tuple[int, int, int]: """Convert from XYZ to RGB.""" + if Gamut: + if not check_point_in_lamps_reach((vX, vY), Gamut): + xy_closest = get_closest_point_to_point((vX, vY), Gamut) + vX = xy_closest[0] + vY = xy_closest[1] + brightness = ibrightness / 255. if brightness == 0: return (0, 0, 0) @@ -338,15 +376,17 @@ def color_hs_to_RGB(iH: float, iS: float) -> Tuple[int, int, int]: return color_hsv_to_RGB(iH, iS, 100) -def color_xy_to_hs(vX: float, vY: float) -> Tuple[float, float]: +def color_xy_to_hs(vX: float, vY: float, + Gamut: Optional[GamutType] = None) -> Tuple[float, float]: """Convert an xy color to its hs representation.""" - h, s, _ = color_RGB_to_hsv(*color_xy_to_RGB(vX, vY)) + h, s, _ = color_RGB_to_hsv(*color_xy_to_RGB(vX, vY, Gamut)) return h, s -def color_hs_to_xy(iH: float, iS: float) -> Tuple[float, float]: +def color_hs_to_xy(iH: float, iS: float, + Gamut: Optional[GamutType] = None) -> Tuple[float, float]: """Convert an hs color to its xy representation.""" - return color_RGB_to_xy(*color_hs_to_RGB(iH, iS)) + return color_RGB_to_xy(*color_hs_to_RGB(iH, iS), Gamut) def _match_max_scale(input_colors: Tuple, output_colors: Tuple) -> Tuple: @@ -474,3 +514,89 @@ def color_temperature_mired_to_kelvin(mired_temperature: float) -> float: def color_temperature_kelvin_to_mired(kelvin_temperature: float) -> float: """Convert degrees kelvin to mired shift.""" return math.floor(1000000 / kelvin_temperature) + + +# The following 5 functions are adapted from rgbxy provided by Benjamin Knight +# License: The MIT License (MIT), 2014. +# https://github.com/benknight/hue-python-rgb-converter +def cross_product(p1: XYPoint, p2: XYPoint) -> float: + """Calculate the cross product of two XYPoints.""" + return float(p1.x * p2.y - p1.y * p2.x) + + +def get_distance_between_two_points(one: XYPoint, two: XYPoint) -> float: + """Calculate the distance between two XYPoints.""" + dx = one.x - two.x + dy = one.y - two.y + return math.sqrt(dx * dx + dy * dy) + + +def get_closest_point_to_line(A: XYPoint, B: XYPoint, P: XYPoint) -> XYPoint: + """ + Find the closest point from P to a line defined by A and B. + + This point will be reproducible by the lamp + as it is on the edge of the gamut. + """ + AP = XYPoint(P.x - A.x, P.y - A.y) + AB = XYPoint(B.x - A.x, B.y - A.y) + ab2 = AB.x * AB.x + AB.y * AB.y + ap_ab = AP.x * AB.x + AP.y * AB.y + t = ap_ab / ab2 + + if t < 0.0: + t = 0.0 + elif t > 1.0: + t = 1.0 + + return XYPoint(A.x + AB.x * t, A.y + AB.y * t) + + +def get_closest_point_to_point(xy_tuple: Tuple[float, float], + Gamut: GamutType) -> Tuple[float, float]: + """ + Get the closest matching color within the gamut of the light. + + Should only be used if the supplied color is outside of the color gamut. + """ + xy_point = XYPoint(xy_tuple[0], xy_tuple[1]) + + # find the closest point on each line in the CIE 1931 'triangle'. + pAB = get_closest_point_to_line(Gamut.red, Gamut.green, xy_point) + pAC = get_closest_point_to_line(Gamut.blue, Gamut.red, xy_point) + pBC = get_closest_point_to_line(Gamut.green, Gamut.blue, xy_point) + + # Get the distances per point and see which point is closer to our Point. + dAB = get_distance_between_two_points(xy_point, pAB) + dAC = get_distance_between_two_points(xy_point, pAC) + dBC = get_distance_between_two_points(xy_point, pBC) + + lowest = dAB + closest_point = pAB + + if dAC < lowest: + lowest = dAC + closest_point = pAC + + if dBC < lowest: + lowest = dBC + closest_point = pBC + + # Change the xy value to a value which is within the reach of the lamp. + cx = closest_point.x + cy = closest_point.y + + return (cx, cy) + + +def check_point_in_lamps_reach(p: Tuple[float, float], + Gamut: GamutType) -> bool: + """Check if the provided XYPoint can be recreated by a Hue lamp.""" + v1 = XYPoint(Gamut.green.x - Gamut.red.x, Gamut.green.y - Gamut.red.y) + v2 = XYPoint(Gamut.blue.x - Gamut.red.x, Gamut.blue.y - Gamut.red.y) + + q = XYPoint(p[0] - Gamut.red.x, p[1] - Gamut.red.y) + s = cross_product(q, v2) / cross_product(v1, v2) + t = cross_product(v1, q) / cross_product(v1, v2) + + return (s >= 0.0) and (t >= 0.0) and (s + t <= 1.0) diff --git a/requirements_all.txt b/requirements_all.txt index aed3f5cbb37..001dc4e40f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -112,7 +112,7 @@ aioharmony==0.1.2 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==1.5.0 +aiohue==1.8.0 # homeassistant.components.sensor.imap aioimaplib==0.7.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3b5eabfaf6e..a2d8edb4e9a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -38,7 +38,7 @@ aioautomatic==0.6.5 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==1.5.0 +aiohue==1.8.0 # homeassistant.components.unifi aiounifi==4 diff --git a/tests/components/hue/test_light.py b/tests/components/hue/test_light.py index 023a3416968..f7865fcf4f8 100644 --- a/tests/components/hue/test_light.py +++ b/tests/components/hue/test_light.py @@ -85,6 +85,16 @@ LIGHT_1_ON = { "colormode": "xy", "reachable": True }, + "capabilities": { + "control": { + "colorgamuttype": "A", + "colorgamut": [ + [0.704, 0.296], + [0.2151, 0.7106], + [0.138, 0.08] + ] + } + }, "type": "Extended color light", "name": "Hue Lamp 1", "modelid": "LCT001", @@ -105,6 +115,16 @@ LIGHT_1_OFF = { "colormode": "xy", "reachable": True }, + "capabilities": { + "control": { + "colorgamuttype": "A", + "colorgamut": [ + [0.704, 0.296], + [0.2151, 0.7106], + [0.138, 0.08] + ] + } + }, "type": "Extended color light", "name": "Hue Lamp 1", "modelid": "LCT001", @@ -125,6 +145,16 @@ LIGHT_2_OFF = { "colormode": "hs", "reachable": True }, + "capabilities": { + "control": { + "colorgamuttype": "A", + "colorgamut": [ + [0.704, 0.296], + [0.2151, 0.7106], + [0.138, 0.08] + ] + } + }, "type": "Extended color light", "name": "Hue Lamp 2", "modelid": "LCT001", @@ -145,6 +175,16 @@ LIGHT_2_ON = { "colormode": "hs", "reachable": True }, + "capabilities": { + "control": { + "colorgamuttype": "A", + "colorgamut": [ + [0.704, 0.296], + [0.2151, 0.7106], + [0.138, 0.08] + ] + } + }, "type": "Extended color light", "name": "Hue Lamp 2 new", "modelid": "LCT001", @@ -156,6 +196,23 @@ LIGHT_RESPONSE = { "1": LIGHT_1_ON, "2": LIGHT_2_OFF, } +LIGHT_RAW = { + "capabilities": { + "control": { + "colorgamuttype": "A", + "colorgamut": [ + [0.704, 0.296], + [0.2151, 0.7106], + [0.138, 0.08] + ] + } + }, + "swversion": "66009461", +} +LIGHT_GAMUT = color.GamutType(color.XYPoint(0.704, 0.296), + color.XYPoint(0.2151, 0.7106), + color.XYPoint(0.138, 0.08)) +LIGHT_GAMUT_TYPE = 'A' @pytest.fixture @@ -380,6 +437,16 @@ async def test_new_light_discovered(hass, mock_bridge): "colormode": "hs", "reachable": True }, + "capabilities": { + "control": { + "colorgamuttype": "A", + "colorgamut": [ + [0.704, 0.296], + [0.2151, 0.7106], + [0.138, 0.08] + ] + } + }, "type": "Extended color light", "name": "Hue Lamp 3", "modelid": "LCT001", @@ -493,6 +560,16 @@ async def test_other_light_update(hass, mock_bridge): "colormode": "hs", "reachable": True }, + "capabilities": { + "control": { + "colorgamuttype": "A", + "colorgamut": [ + [0.704, 0.296], + [0.2151, 0.7106], + [0.138, 0.08] + ] + } + }, "type": "Extended color light", "name": "Hue Lamp 2 new", "modelid": "LCT001", @@ -573,6 +650,21 @@ async def test_light_turn_on_service(hass, mock_bridge): assert light is not None assert light.state == 'on' + # test hue gamut in turn_on service + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.hue_lamp_2', + 'rgb_color': [0, 0, 255], + }, blocking=True) + + assert len(mock_bridge.mock_requests) == 5 + + assert mock_bridge.mock_requests[3]['json'] == { + 'on': True, + 'xy': (0.138, 0.08), + 'effect': 'none', + 'alert': 'none', + } + async def test_light_turn_off_service(hass, mock_bridge): """Test calling the turn on service on a light.""" @@ -608,7 +700,8 @@ async def test_light_turn_off_service(hass, mock_bridge): def test_available(): """Test available property.""" light = hue_light.HueLight( - light=Mock(state={'reachable': False}), + light=Mock(state={'reachable': False}, + raw=LIGHT_RAW), request_bridge_update=None, bridge=Mock(allow_unreachable=False), is_group=False, @@ -617,7 +710,8 @@ def test_available(): assert light.available is False light = hue_light.HueLight( - light=Mock(state={'reachable': False}), + light=Mock(state={'reachable': False}, + raw=LIGHT_RAW), request_bridge_update=None, bridge=Mock(allow_unreachable=True), is_group=False, @@ -626,7 +720,8 @@ def test_available(): assert light.available is True light = hue_light.HueLight( - light=Mock(state={'reachable': False}), + light=Mock(state={'reachable': False}, + raw=LIGHT_RAW), request_bridge_update=None, bridge=Mock(allow_unreachable=False), is_group=True, @@ -639,10 +734,13 @@ def test_hs_color(): """Test hs_color property.""" light = hue_light.HueLight( light=Mock(state={ - 'colormode': 'ct', - 'hue': 1234, - 'sat': 123, - }), + 'colormode': 'ct', + 'hue': 1234, + 'sat': 123, + }, + raw=LIGHT_RAW, + colorgamuttype=LIGHT_GAMUT_TYPE, + colorgamut=LIGHT_GAMUT), request_bridge_update=None, bridge=Mock(), is_group=False, @@ -652,10 +750,13 @@ def test_hs_color(): light = hue_light.HueLight( light=Mock(state={ - 'colormode': 'hs', - 'hue': 1234, - 'sat': 123, - }), + 'colormode': 'hs', + 'hue': 1234, + 'sat': 123, + }, + raw=LIGHT_RAW, + colorgamuttype=LIGHT_GAMUT_TYPE, + colorgamut=LIGHT_GAMUT), request_bridge_update=None, bridge=Mock(), is_group=False, @@ -665,14 +766,17 @@ def test_hs_color(): light = hue_light.HueLight( light=Mock(state={ - 'colormode': 'xy', - 'hue': 1234, - 'sat': 123, - 'xy': [0.4, 0.5] - }), + 'colormode': 'xy', + 'hue': 1234, + 'sat': 123, + 'xy': [0.4, 0.5] + }, + raw=LIGHT_RAW, + colorgamuttype=LIGHT_GAMUT_TYPE, + colorgamut=LIGHT_GAMUT), request_bridge_update=None, bridge=Mock(), is_group=False, ) - assert light.hs_color == color.color_xy_to_hs(0.4, 0.5) + assert light.hs_color == color.color_xy_to_hs(0.4, 0.5, LIGHT_GAMUT) diff --git a/tests/util/test_color.py b/tests/util/test_color.py index b7802d3dc09..b54b2bc5776 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -5,6 +5,10 @@ import homeassistant.util.color as color_util import pytest import voluptuous as vol +GAMUT = color_util.GamutType(color_util.XYPoint(0.704, 0.296), + color_util.XYPoint(0.2151, 0.7106), + color_util.XYPoint(0.138, 0.08)) + class TestColorUtil(unittest.TestCase): """Test color util methods.""" @@ -29,6 +33,15 @@ class TestColorUtil(unittest.TestCase): assert (0.701, 0.299, 16) == \ color_util.color_RGB_to_xy_brightness(128, 0, 0) + assert (0.7, 0.299, 72) == \ + color_util.color_RGB_to_xy_brightness(255, 0, 0, GAMUT) + + assert (0.215, 0.711, 170) == \ + color_util.color_RGB_to_xy_brightness(0, 255, 0, GAMUT) + + assert (0.138, 0.08, 12) == \ + color_util.color_RGB_to_xy_brightness(0, 0, 255, GAMUT) + def test_color_RGB_to_xy(self): """Test color_RGB_to_xy.""" assert (0, 0) == \ @@ -48,6 +61,15 @@ class TestColorUtil(unittest.TestCase): assert (0.701, 0.299) == \ color_util.color_RGB_to_xy(128, 0, 0) + assert (0.138, 0.08) == \ + color_util.color_RGB_to_xy(0, 0, 255, GAMUT) + + assert (0.215, 0.711) == \ + color_util.color_RGB_to_xy(0, 255, 0, GAMUT) + + assert (0.7, 0.299) == \ + color_util.color_RGB_to_xy(255, 0, 0, GAMUT) + def test_color_xy_brightness_to_RGB(self): """Test color_xy_brightness_to_RGB.""" assert (0, 0, 0) == \ @@ -68,6 +90,15 @@ class TestColorUtil(unittest.TestCase): assert (0, 63, 255) == \ color_util.color_xy_brightness_to_RGB(0, 0, 255) + assert (255, 0, 3) == \ + color_util.color_xy_brightness_to_RGB(1, 0, 255, GAMUT) + + assert (82, 255, 0) == \ + color_util.color_xy_brightness_to_RGB(0, 1, 255, GAMUT) + + assert (9, 85, 255) == \ + color_util.color_xy_brightness_to_RGB(0, 0, 255, GAMUT) + def test_color_xy_to_RGB(self): """Test color_xy_to_RGB.""" assert (255, 243, 222) == \ @@ -82,6 +113,15 @@ class TestColorUtil(unittest.TestCase): assert (0, 63, 255) == \ color_util.color_xy_to_RGB(0, 0) + assert (255, 0, 3) == \ + color_util.color_xy_to_RGB(1, 0, GAMUT) + + assert (82, 255, 0) == \ + color_util.color_xy_to_RGB(0, 1, GAMUT) + + assert (9, 85, 255) == \ + color_util.color_xy_to_RGB(0, 0, GAMUT) + def test_color_RGB_to_hsv(self): """Test color_RGB_to_hsv.""" assert (0, 0, 0) == \ @@ -150,6 +190,15 @@ class TestColorUtil(unittest.TestCase): assert (225.176, 100) == \ color_util.color_xy_to_hs(0, 0) + assert (359.294, 100) == \ + color_util.color_xy_to_hs(1, 0, GAMUT) + + assert (100.706, 100) == \ + color_util.color_xy_to_hs(0, 1, GAMUT) + + assert (221.463, 96.471) == \ + color_util.color_xy_to_hs(0, 0, GAMUT) + def test_color_hs_to_xy(self): """Test color_hs_to_xy.""" assert (0.151, 0.343) == \ @@ -167,6 +216,21 @@ class TestColorUtil(unittest.TestCase): assert (0.323, 0.329) == \ color_util.color_hs_to_xy(360, 0) + assert (0.7, 0.299) == \ + color_util.color_hs_to_xy(0, 100, GAMUT) + + assert (0.215, 0.711) == \ + color_util.color_hs_to_xy(120, 100, GAMUT) + + assert (0.17, 0.34) == \ + color_util.color_hs_to_xy(180, 100, GAMUT) + + assert (0.138, 0.08) == \ + color_util.color_hs_to_xy(240, 100, GAMUT) + + assert (0.7, 0.299) == \ + color_util.color_hs_to_xy(360, 100, GAMUT) + def test_rgb_hex_to_rgb_list(self): """Test rgb_hex_to_rgb_list.""" assert [255, 255, 255] == \