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
pull/20143/head
starkillerOG 2019-01-15 20:30:50 +01:00 committed by Paulus Schoutsen
parent a3f0d55737
commit 11602c1da0
7 changed files with 342 additions and 34 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
@ -642,7 +737,10 @@ def test_hs_color():
'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,
@ -655,7 +753,10 @@ def test_hs_color():
'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,
@ -669,10 +770,13 @@ def test_hs_color():
'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)

View File

@ -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] == \