From 0a656f13eb665fddd11c7049a43ab4f0cedb911d Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 24 Sep 2020 12:37:34 -0700 Subject: [PATCH] Fix/Refactor Hyperion Integration (#39738) --- CODEOWNERS | 1 + homeassistant/components/hyperion/light.py | 479 ++++++++++-------- .../components/hyperion/manifest.json | 3 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/hyperion/__init__.py | 1 + tests/components/hyperion/test_light.py | 430 ++++++++++++++++ 7 files changed, 711 insertions(+), 209 deletions(-) create mode 100644 tests/components/hyperion/__init__.py create mode 100644 tests/components/hyperion/test_light.py diff --git a/CODEOWNERS b/CODEOWNERS index 3fa8a7a366d..40584db7479 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -193,6 +193,7 @@ homeassistant/components/humidifier/* @home-assistant/core @Shulyaka homeassistant/components/hunterdouglas_powerview/* @bdraco homeassistant/components/hvv_departures/* @vigonotion homeassistant/components/hydrawise/* @ptcryan +homeassistant/components/hyperion/* @dermotduffy homeassistant/components/iammeter/* @lewei50 homeassistant/components/iaqualink/* @flz homeassistant/components/icloud/* @Quentame diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index d1baec315bf..db34a21dada 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -1,8 +1,7 @@ -"""Support for Hyperion remotes.""" -import json +"""Support for Hyperion-NG remotes.""" import logging -import socket +from hyperion import client, const import voluptuous as vol from homeassistant.components.light import ( @@ -16,6 +15,7 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util @@ -26,103 +26,91 @@ CONF_PRIORITY = "priority" CONF_HDMI_PRIORITY = "hdmi_priority" CONF_EFFECT_LIST = "effect_list" +# As we want to preserve brightness control for effects (e.g. to reduce the +# brightness for V4L), we need to persist the effect that is in flight, so +# subsequent calls to turn_on will know the keep the effect enabled. +# Unfortunately the Home Assistant UI does not easily expose a way to remove a +# selected effect (there is no 'No Effect' option by default). Instead, we +# create a new fake effect ("Solid") that is always selected by default for +# showing a solid color. This is the same method used by WLED. +KEY_EFFECT_SOLID = "Solid" + DEFAULT_COLOR = [255, 255, 255] +DEFAULT_BRIGHTNESS = 255 +DEFAULT_EFFECT = KEY_EFFECT_SOLID DEFAULT_NAME = "Hyperion" +DEFAULT_ORIGIN = "Home Assistant" DEFAULT_PORT = 19444 DEFAULT_PRIORITY = 128 DEFAULT_HDMI_PRIORITY = 880 -DEFAULT_EFFECT_LIST = [ - "HDMI", - "Cinema brighten lights", - "Cinema dim lights", - "Knight rider", - "Blue mood blobs", - "Cold mood blobs", - "Full color mood blobs", - "Green mood blobs", - "Red mood blobs", - "Warm mood blobs", - "Police Lights Single", - "Police Lights Solid", - "Rainbow mood", - "Rainbow swirl fast", - "Rainbow swirl", - "Random", - "Running dots", - "System Shutdown", - "Snake", - "Sparks Color", - "Sparks", - "Strobe blue", - "Strobe Raspbmc", - "Strobe white", - "Color traces", - "UDP multicast listener", - "UDP listener", - "X-Mas", -] +DEFAULT_EFFECT_LIST = [] SUPPORT_HYPERION = SUPPORT_COLOR | SUPPORT_BRIGHTNESS | SUPPORT_EFFECT -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_DEFAULT_COLOR, default=DEFAULT_COLOR): vol.All( - list, - vol.Length(min=3, max=3), - [vol.All(vol.Coerce(int), vol.Range(min=0, max=255))], - ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PRIORITY, default=DEFAULT_PRIORITY): cv.positive_int, - vol.Optional( - CONF_HDMI_PRIORITY, default=DEFAULT_HDMI_PRIORITY - ): cv.positive_int, - vol.Optional(CONF_EFFECT_LIST, default=DEFAULT_EFFECT_LIST): vol.All( - cv.ensure_list, [cv.string] - ), - } +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_HDMI_PRIORITY, invalidation_version="0.118"), + cv.deprecated(CONF_DEFAULT_COLOR, invalidation_version="0.118"), + cv.deprecated(CONF_EFFECT_LIST, invalidation_version="0.118"), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_DEFAULT_COLOR, default=DEFAULT_COLOR): vol.All( + list, + vol.Length(min=3, max=3), + [vol.All(vol.Coerce(int), vol.Range(min=0, max=255))], + ), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PRIORITY, default=DEFAULT_PRIORITY): cv.positive_int, + vol.Optional( + CONF_HDMI_PRIORITY, default=DEFAULT_HDMI_PRIORITY + ): cv.positive_int, + vol.Optional(CONF_EFFECT_LIST, default=DEFAULT_EFFECT_LIST): vol.All( + cv.ensure_list, [cv.string] + ), + } + ), ) +ICON_LIGHTBULB = "mdi:lightbulb" +ICON_EFFECT = "mdi:lava-lamp" +ICON_EXTERNAL_SOURCE = "mdi:video-input-hdmi" -def setup_platform(hass, config, add_entities, discovery_info=None): + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up a Hyperion server remote.""" name = config[CONF_NAME] host = config[CONF_HOST] port = config[CONF_PORT] priority = config[CONF_PRIORITY] - hdmi_priority = config[CONF_HDMI_PRIORITY] - default_color = config[CONF_DEFAULT_COLOR] - effect_list = config[CONF_EFFECT_LIST] - device = Hyperion( - name, host, port, priority, default_color, hdmi_priority, effect_list - ) + hyperion_client = client.HyperionClient(host, port) - if device.setup(): - add_entities([device]) + if not await hyperion_client.async_client_connect(): + raise PlatformNotReady + + async_add_entities([Hyperion(name, priority, hyperion_client)]) class Hyperion(LightEntity): """Representation of a Hyperion remote.""" - def __init__( - self, name, host, port, priority, default_color, hdmi_priority, effect_list - ): + def __init__(self, name, priority, hyperion_client): """Initialize the light.""" - self._host = host - self._port = port self._name = name self._priority = priority - self._hdmi_priority = hdmi_priority - self._default_color = default_color - self._rgb_color = [0, 0, 0] - self._rgb_mem = [0, 0, 0] - self._brightness = 255 - self._icon = "mdi:lightbulb" - self._effect_list = effect_list - self._effect = None - self._skip_update = False + self._client = hyperion_client + + # Active state representing the Hyperion instance. + self._set_internal_state( + brightness=255, rgb_color=DEFAULT_COLOR, effect=KEY_EFFECT_SOLID + ) + self._effect_list = [] + + @property + def should_poll(self): + """Return whether or not this entity should be polled.""" + return False @property def name(self): @@ -142,7 +130,7 @@ class Hyperion(LightEntity): @property def is_on(self): """Return true if not black.""" - return self._rgb_color != [0, 0, 0] + return self._client.is_on() @property def icon(self): @@ -157,158 +145,233 @@ class Hyperion(LightEntity): @property def effect_list(self): """Return the list of supported effects.""" - return self._effect_list + return ( + self._effect_list + + const.KEY_COMPONENTID_EXTERNAL_SOURCES + + [KEY_EFFECT_SOLID] + ) @property def supported_features(self): """Flag supported features.""" return SUPPORT_HYPERION - def turn_on(self, **kwargs): + @property + def available(self): + """Return server availability.""" + return self._client.has_loaded_state + + @property + def unique_id(self): + """Return a unique id for this instance.""" + return self._client.id + + async def async_turn_on(self, **kwargs): """Turn the lights on.""" + # == Turn device on == + # Turn on both ALL (Hyperion itself) and LEDDEVICE. It would be + # preferable to enable LEDDEVICE after the settings (e.g. brightness, + # color, effect), but this is not possible due to: + # https://github.com/hyperion-project/hyperion.ng/issues/967 + if not self.is_on: + if not await self._client.async_send_set_component( + **{ + const.KEY_COMPONENTSTATE: { + const.KEY_COMPONENT: const.KEY_COMPONENTID_ALL, + const.KEY_STATE: True, + } + } + ): + return + + if not await self._client.async_send_set_component( + **{ + const.KEY_COMPONENTSTATE: { + const.KEY_COMPONENT: const.KEY_COMPONENTID_LEDDEVICE, + const.KEY_STATE: True, + } + } + ): + return + + # == Get key parameters == + brightness = kwargs.get(ATTR_BRIGHTNESS, self._brightness) + effect = kwargs.get(ATTR_EFFECT, self._effect) if ATTR_HS_COLOR in kwargs: rgb_color = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) - elif self._rgb_mem == [0, 0, 0]: - rgb_color = self._default_color else: - rgb_color = self._rgb_mem + rgb_color = self._rgb_color - brightness = kwargs.get(ATTR_BRIGHTNESS, self._brightness) - - if ATTR_EFFECT in kwargs: - self._skip_update = True - self._effect = kwargs[ATTR_EFFECT] - if self._effect == "HDMI": - self.json_request({"command": "clearall"}) - self._icon = "mdi:video-input-hdmi" - self._brightness = 255 - self._rgb_color = [125, 125, 125] - else: - self.json_request( - { - "command": "effect", - "priority": self._priority, - "effect": {"name": self._effect}, + # == Set brightness == + if self._brightness != brightness: + if not await self._client.async_send_set_adjustment( + **{ + const.KEY_ADJUSTMENT: { + const.KEY_BRIGHTNESS: int( + round((float(brightness) * 100) / 255) + ) } - ) - self._icon = "mdi:lava-lamp" - self._rgb_color = [175, 0, 255] - return - - cal_color = [int(round(x * float(brightness) / 255)) for x in rgb_color] - self.json_request( - {"command": "color", "priority": self._priority, "color": cal_color} - ) - - def turn_off(self, **kwargs): - """Disconnect all remotes.""" - self.json_request({"command": "clearall"}) - self.json_request( - {"command": "color", "priority": self._priority, "color": [0, 0, 0]} - ) - - def update(self): - """Get the lights status.""" - # postpone the immediate state check for changes that take time - if self._skip_update: - self._skip_update = False - return - response = self.json_request({"command": "serverinfo"}) - if response: - # workaround for outdated Hyperion - if "activeLedColor" not in response["info"]: - self._rgb_color = self._default_color - self._rgb_mem = self._default_color - self._brightness = 255 - self._icon = "mdi:lightbulb" - self._effect = None + } + ): return - # Check if Hyperion is in ambilight mode trough an HDMI grabber - try: - active_priority = response["info"]["priorities"][0]["priority"] - if active_priority == self._hdmi_priority: - self._brightness = 255 - self._rgb_color = [125, 125, 125] - self._icon = "mdi:video-input-hdmi" - self._effect = "HDMI" + + # == Set an external source + if effect and effect in const.KEY_COMPONENTID_EXTERNAL_SOURCES: + + # Clear any color/effect. + if not await self._client.async_send_clear( + **{const.KEY_PRIORITY: self._priority} + ): + return + + # Turn off all external sources, except the intended. + for key in const.KEY_COMPONENTID_EXTERNAL_SOURCES: + if not await self._client.async_send_set_component( + **{ + const.KEY_COMPONENTSTATE: { + const.KEY_COMPONENT: key, + const.KEY_STATE: effect == key, + } + } + ): return - except (KeyError, IndexError): - pass - led_color = response["info"]["activeLedColor"] - if not led_color or led_color[0]["RGB Value"] == [0, 0, 0]: - # Get the active effect - if response["info"].get("activeEffects"): - self._rgb_color = [175, 0, 255] - self._icon = "mdi:lava-lamp" - try: - s_name = response["info"]["activeEffects"][0]["script"] - s_name = s_name.split("/")[-1][:-3].split("-")[0] - self._effect = [ - x for x in self._effect_list if s_name.lower() in x.lower() - ][0] - except (KeyError, IndexError): - self._effect = None - # Bulb off state - else: - self._rgb_color = [0, 0, 0] - self._icon = "mdi:lightbulb" - self._effect = None + # == Set an effect + elif effect and effect != KEY_EFFECT_SOLID: + # This call should not be necessary, but without it there is no priorities-update issued: + # https://github.com/hyperion-project/hyperion.ng/issues/992 + if not await self._client.async_send_clear( + **{const.KEY_PRIORITY: self._priority} + ): + return + + if not await self._client.async_send_set_effect( + **{ + const.KEY_PRIORITY: self._priority, + const.KEY_EFFECT: {const.KEY_NAME: effect}, + const.KEY_ORIGIN: DEFAULT_ORIGIN, + } + ): + return + # == Set a color + else: + if not await self._client.async_send_set_color( + **{ + const.KEY_PRIORITY: self._priority, + const.KEY_COLOR: rgb_color, + const.KEY_ORIGIN: DEFAULT_ORIGIN, + } + ): + return + + async def async_turn_off(self, **kwargs): + """Disable the LED output component.""" + if not await self._client.async_send_set_component( + **{ + const.KEY_COMPONENTSTATE: { + const.KEY_COMPONENT: const.KEY_COMPONENTID_LEDDEVICE, + const.KEY_STATE: False, + } + } + ): + return + + def _set_internal_state(self, brightness=None, rgb_color=None, effect=None): + """Set the internal state.""" + if brightness is not None: + self._brightness = brightness + if rgb_color is not None: + self._rgb_color = rgb_color + if effect is not None: + self._effect = effect + if effect == KEY_EFFECT_SOLID: + self._icon = ICON_LIGHTBULB + elif effect in const.KEY_COMPONENTID_EXTERNAL_SOURCES: + self._icon = ICON_EXTERNAL_SOURCE else: - # Get the RGB color - self._rgb_color = led_color[0]["RGB Value"] - self._brightness = max(self._rgb_color) - self._rgb_mem = [ - int(round(float(x) * 255 / self._brightness)) - for x in self._rgb_color - ] - self._icon = "mdi:lightbulb" - self._effect = None + self._icon = ICON_EFFECT - def setup(self): - """Get the hostname of the remote.""" - response = self.json_request({"command": "serverinfo"}) - if response: - if self._name == self._host: - self._name = response["info"]["hostname"] - return True - return False + def _update_components(self, _=None): + """Update Hyperion components.""" + self.async_write_ha_state() - def json_request(self, request, wait_for_response=False): - """Communicate with the JSON server.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(5) + def _update_adjustment(self, _=None): + """Update Hyperion adjustments.""" + if self._client.adjustment: + brightness_pct = self._client.adjustment[0].get( + const.KEY_BRIGHTNESS, DEFAULT_BRIGHTNESS + ) + if brightness_pct < 0 or brightness_pct > 100: + return + self._set_internal_state( + brightness=int(round((brightness_pct * 255) / float(100))) + ) + self.async_write_ha_state() - try: - sock.connect((self._host, self._port)) - except OSError: - sock.close() - return False + def _update_priorities(self, _=None): + """Update Hyperion priorities.""" + visible_priority = self._client.visible_priority + if visible_priority: + componentid = visible_priority.get(const.KEY_COMPONENTID) + if componentid in const.KEY_COMPONENTID_EXTERNAL_SOURCES: + self._set_internal_state(rgb_color=DEFAULT_COLOR, effect=componentid) + elif componentid == const.KEY_COMPONENTID_EFFECT: + # Owner is the effect name. + # See: https://docs.hyperion-project.org/en/json/ServerInfo.html#priorities + self._set_internal_state( + rgb_color=DEFAULT_COLOR, effect=visible_priority[const.KEY_OWNER] + ) + elif componentid == const.KEY_COMPONENTID_COLOR: + self._set_internal_state( + rgb_color=visible_priority[const.KEY_VALUE][const.KEY_RGB], + effect=KEY_EFFECT_SOLID, + ) + self.async_write_ha_state() - sock.send(bytearray(f"{json.dumps(request)}\n", "utf-8")) - try: - buf = sock.recv(4096) - except socket.timeout: - # Something is wrong, assume it's offline - sock.close() - return False + def _update_effect_list(self, _=None): + """Update Hyperion effects.""" + if not self._client.effects: + return + effect_list = [] + for effect in self._client.effects or []: + if const.KEY_NAME in effect: + effect_list.append(effect[const.KEY_NAME]) + if effect_list: + self._effect_list = effect_list + self.async_write_ha_state() - # Read until a newline or timeout - buffering = True - while buffering: - if "\n" in str(buf, "utf-8"): - response = str(buf, "utf-8").split("\n")[0] - buffering = False - else: - try: - more = sock.recv(4096) - except socket.timeout: - more = None - if not more: - buffering = False - response = str(buf, "utf-8") - else: - buf += more + def _update_full_state(self): + """Update full Hyperion state.""" + self._update_adjustment() + self._update_priorities() + self._update_effect_list() - sock.close() - return json.loads(response) + _LOGGER.debug( + "Hyperion full state update: On=%s,Brightness=%i,Effect=%s " + "(%i effects total),Color=%s", + self.is_on, + self._brightness, + self._effect, + len(self._effect_list), + self._rgb_color, + ) + + def _update_client(self, json): + """Update client connection state.""" + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Register callbacks when entity added to hass.""" + self._client.set_callbacks( + { + f"{const.KEY_ADJUSTMENT}-{const.KEY_UPDATE}": self._update_adjustment, + f"{const.KEY_COMPONENTS}-{const.KEY_UPDATE}": self._update_components, + f"{const.KEY_EFFECTS}-{const.KEY_UPDATE}": self._update_effect_list, + f"{const.KEY_PRIORITIES}-{const.KEY_UPDATE}": self._update_priorities, + f"{const.KEY_CLIENT}-{const.KEY_UPDATE}": self._update_client, + } + ) + + # Load initial state. + self._update_full_state() + return True diff --git a/homeassistant/components/hyperion/manifest.json b/homeassistant/components/hyperion/manifest.json index 6d9d0ae4d9d..4a9bf2ada8c 100644 --- a/homeassistant/components/hyperion/manifest.json +++ b/homeassistant/components/hyperion/manifest.json @@ -2,5 +2,6 @@ "domain": "hyperion", "name": "Hyperion", "documentation": "https://www.home-assistant.io/integrations/hyperion", - "codeowners": [] + "requirements": ["hyperion-py==0.3.0"], + "codeowners": ["@dermotduffy"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5f5e82f43a8..6b7a8a7feee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -774,6 +774,9 @@ huawei-lte-api==1.4.12 # homeassistant.components.hydrawise hydrawiser==0.2 +# homeassistant.components.hyperion +hyperion-py==0.3.0 + # homeassistant.components.bh1750 # homeassistant.components.bme280 # homeassistant.components.htu21d diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd28f3496fd..2846c38d69c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -391,6 +391,9 @@ httplib2==0.10.3 # homeassistant.components.huawei_lte huawei-lte-api==1.4.12 +# homeassistant.components.hyperion +hyperion-py==0.3.0 + # homeassistant.components.iaqualink iaqualink==0.3.4 diff --git a/tests/components/hyperion/__init__.py b/tests/components/hyperion/__init__.py new file mode 100644 index 00000000000..e4c1ee67efa --- /dev/null +++ b/tests/components/hyperion/__init__.py @@ -0,0 +1 @@ +"""Tests for the Hyperion component.""" diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py new file mode 100644 index 00000000000..c400e34db51 --- /dev/null +++ b/tests/components/hyperion/test_light.py @@ -0,0 +1,430 @@ +"""Tests for the Hyperion integration.""" +# from tests.async_mock import AsyncMock, MagicMock, patch +from asynctest import CoroutineMock, Mock, call, patch +from hyperion import const + +from homeassistant.components.hyperion import light as hyperion_light +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_EFFECT, + ATTR_HS_COLOR, + DOMAIN, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.setup import async_setup_component + +TEST_HOST = "test-hyperion-host" +TEST_PORT = const.DEFAULT_PORT +TEST_NAME = "test_hyperion_name" +TEST_PRIORITY = 128 +TEST_ENTITY_ID = f"{DOMAIN}.{TEST_NAME}" + + +def create_mock_client(): + """Create a mock Hyperion client.""" + mock_client = Mock() + mock_client.async_client_connect = CoroutineMock(return_value=True) + mock_client.adjustment = None + mock_client.effects = None + mock_client.id = "%s:%i" % (TEST_HOST, TEST_PORT) + return mock_client + + +def call_registered_callback(client, key, *args, **kwargs): + """Call a Hyperion entity callback that was registered with the client.""" + return client.set_callbacks.call_args[0][0][key](*args, **kwargs) + + +async def setup_entity(hass, client=None): + """Add a test Hyperion entity to hass.""" + client = client or create_mock_client() + with patch("hyperion.client.HyperionClient", return_value=client): + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "platform": "hyperion", + "name": TEST_NAME, + "host": TEST_HOST, + "port": const.DEFAULT_PORT, + "priority": TEST_PRIORITY, + } + }, + ) + await hass.async_block_till_done() + + +async def test_setup_platform(hass): + """Test setting up the platform.""" + client = create_mock_client() + await setup_entity(hass, client=client) + assert hass.states.get(TEST_ENTITY_ID) is not None + + +async def test_setup_platform_not_ready(hass): + """Test the platform not being ready.""" + client = create_mock_client() + client.async_client_connect = CoroutineMock(return_value=False) + + await setup_entity(hass, client=client) + assert hass.states.get(TEST_ENTITY_ID) is None + + +async def test_light_basic_properies(hass): + """Test the basic properties.""" + client = create_mock_client() + await setup_entity(hass, client=client) + + entity_state = hass.states.get(TEST_ENTITY_ID) + assert entity_state.state == "on" + assert entity_state.attributes["brightness"] == 255 + assert entity_state.attributes["hs_color"] == (0.0, 0.0) + assert entity_state.attributes["icon"] == hyperion_light.ICON_LIGHTBULB + assert entity_state.attributes["effect"] == hyperion_light.KEY_EFFECT_SOLID + + # By default the effect list is the 3 external sources + 'Solid'. + assert len(entity_state.attributes["effect_list"]) == 4 + + assert ( + entity_state.attributes["supported_features"] == hyperion_light.SUPPORT_HYPERION + ) + + +async def test_light_async_turn_on(hass): + """Test turning the light on.""" + client = create_mock_client() + await setup_entity(hass, client=client) + + # On (=), 100% (=), solid (=), [255,255,255] (=) + client.async_send_set_color = CoroutineMock(return_value=True) + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True + ) + + assert client.async_send_set_color.call_args == call( + **{ + const.KEY_PRIORITY: TEST_PRIORITY, + const.KEY_COLOR: [255, 255, 255], + const.KEY_ORIGIN: hyperion_light.DEFAULT_ORIGIN, + } + ) + + # On (=), 50% (!), solid (=), [255,255,255] (=) + # === + brightness = 128 + client.async_send_set_color = CoroutineMock(return_value=True) + client.async_send_set_adjustment = CoroutineMock(return_value=True) + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_BRIGHTNESS: brightness}, + blocking=True, + ) + + assert client.async_send_set_adjustment.call_args == call( + **{const.KEY_ADJUSTMENT: {const.KEY_BRIGHTNESS: 50}} + ) + assert client.async_send_set_color.call_args == call( + **{ + const.KEY_PRIORITY: TEST_PRIORITY, + const.KEY_COLOR: [255, 255, 255], + const.KEY_ORIGIN: hyperion_light.DEFAULT_ORIGIN, + } + ) + + # Simulate a state callback from Hyperion. + client.adjustment = [{const.KEY_BRIGHTNESS: 50}] + call_registered_callback(client, "adjustment-update") + entity_state = hass.states.get(TEST_ENTITY_ID) + assert entity_state.state == "on" + assert entity_state.attributes["brightness"] == brightness + + # On (=), 50% (=), solid (=), [0,255,255] (!) + hs_color = (180.0, 100.0) + client.async_send_set_color = CoroutineMock(return_value=True) + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_HS_COLOR: hs_color}, + blocking=True, + ) + + assert client.async_send_set_color.call_args == call( + **{ + const.KEY_PRIORITY: TEST_PRIORITY, + const.KEY_COLOR: (0, 255, 255), + const.KEY_ORIGIN: hyperion_light.DEFAULT_ORIGIN, + } + ) + + # Simulate a state callback from Hyperion. + client.visible_priority = { + const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR, + const.KEY_VALUE: {const.KEY_RGB: (0, 255, 255)}, + } + + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_ENTITY_ID) + assert entity_state.attributes["hs_color"] == hs_color + assert entity_state.attributes["icon"] == hyperion_light.ICON_LIGHTBULB + + # On (=), 100% (!), solid, [0,255,255] (=) + brightness = 255 + client.async_send_set_color = CoroutineMock(return_value=True) + client.async_send_set_adjustment = CoroutineMock(return_value=True) + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_BRIGHTNESS: brightness}, + blocking=True, + ) + + assert client.async_send_set_adjustment.call_args == call( + **{const.KEY_ADJUSTMENT: {const.KEY_BRIGHTNESS: 100}} + ) + assert client.async_send_set_color.call_args == call( + **{ + const.KEY_PRIORITY: TEST_PRIORITY, + const.KEY_COLOR: (0, 255, 255), + const.KEY_ORIGIN: hyperion_light.DEFAULT_ORIGIN, + } + ) + client.adjustment = [{const.KEY_BRIGHTNESS: 100}] + call_registered_callback(client, "adjustment-update") + entity_state = hass.states.get(TEST_ENTITY_ID) + assert entity_state.attributes["brightness"] == brightness + + # On (=), 100% (=), V4L (!), [0,255,255] (=) + effect = const.KEY_COMPONENTID_EXTERNAL_SOURCES[2] # V4L + client.async_send_clear = CoroutineMock(return_value=True) + client.async_send_set_component = CoroutineMock(return_value=True) + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_EFFECT: effect}, + blocking=True, + ) + + assert client.async_send_clear.call_args == call( + **{const.KEY_PRIORITY: TEST_PRIORITY} + ) + assert client.async_send_set_component.call_args_list == [ + call( + **{ + const.KEY_COMPONENTSTATE: { + const.KEY_COMPONENT: const.KEY_COMPONENTID_EXTERNAL_SOURCES[0], + const.KEY_STATE: False, + } + } + ), + call( + **{ + const.KEY_COMPONENTSTATE: { + const.KEY_COMPONENT: const.KEY_COMPONENTID_EXTERNAL_SOURCES[1], + const.KEY_STATE: False, + } + } + ), + call( + **{ + const.KEY_COMPONENTSTATE: { + const.KEY_COMPONENT: const.KEY_COMPONENTID_EXTERNAL_SOURCES[2], + const.KEY_STATE: True, + } + } + ), + ] + client.visible_priority = {const.KEY_COMPONENTID: effect} + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_ENTITY_ID) + assert entity_state.attributes["icon"] == hyperion_light.ICON_EXTERNAL_SOURCE + assert entity_state.attributes["effect"] == effect + + # On (=), 100% (=), "Warm Blobs" (!), [0,255,255] (=) + effect = "Warm Blobs" + client.async_send_clear = CoroutineMock(return_value=True) + client.async_send_set_effect = CoroutineMock(return_value=True) + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_EFFECT: effect}, + blocking=True, + ) + + assert client.async_send_clear.call_args == call( + **{const.KEY_PRIORITY: TEST_PRIORITY} + ) + assert client.async_send_set_effect.call_args == call( + **{ + const.KEY_PRIORITY: TEST_PRIORITY, + const.KEY_EFFECT: {const.KEY_NAME: effect}, + const.KEY_ORIGIN: hyperion_light.DEFAULT_ORIGIN, + } + ) + client.visible_priority = { + const.KEY_COMPONENTID: const.KEY_COMPONENTID_EFFECT, + const.KEY_OWNER: effect, + } + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_ENTITY_ID) + assert entity_state.attributes["icon"] == hyperion_light.ICON_EFFECT + assert entity_state.attributes["effect"] == effect + + # No calls if disconnected. + client.has_loaded_state = False + call_registered_callback(client, "client-update", {"loaded-state": False}) + client.async_send_clear = CoroutineMock(return_value=True) + client.async_send_set_effect = CoroutineMock(return_value=True) + + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True + ) + + assert not client.async_send_clear.called + assert not client.async_send_set_effect.called + + +async def test_light_async_turn_off(hass): + """Test turning the light off.""" + client = create_mock_client() + await setup_entity(hass, client=client) + + client.async_send_set_component = CoroutineMock(return_value=True) + await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True + ) + + assert client.async_send_set_component.call_args == call( + **{ + const.KEY_COMPONENTSTATE: { + const.KEY_COMPONENT: const.KEY_COMPONENTID_LEDDEVICE, + const.KEY_STATE: False, + } + } + ) + + # No calls if no state loaded. + client.has_loaded_state = False + client.async_send_set_component = CoroutineMock(return_value=True) + call_registered_callback(client, "client-update", {"loaded-state": False}) + + await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True + ) + + assert not client.async_send_set_component.called + + +async def test_light_async_updates_from_hyperion_client(hass): + """Test receiving a variety of Hyperion client callbacks.""" + client = create_mock_client() + await setup_entity(hass, client=client) + + # Bright change gets accepted. + brightness = 10 + client.adjustment = [{const.KEY_BRIGHTNESS: brightness}] + call_registered_callback(client, "adjustment-update") + entity_state = hass.states.get(TEST_ENTITY_ID) + assert entity_state.attributes["brightness"] == round(255 * (brightness / 100.0)) + + # Broken brightness value is ignored. + bad_brightness = -200 + client.adjustment = [{const.KEY_BRIGHTNESS: bad_brightness}] + call_registered_callback(client, "adjustment-update") + entity_state = hass.states.get(TEST_ENTITY_ID) + assert entity_state.attributes["brightness"] == round(255 * (brightness / 100.0)) + + # Update components. + client.is_on.return_value = True + call_registered_callback(client, "components-update") + entity_state = hass.states.get(TEST_ENTITY_ID) + assert entity_state.state == "on" + + client.is_on.return_value = False + call_registered_callback(client, "components-update") + entity_state = hass.states.get(TEST_ENTITY_ID) + assert entity_state.state == "off" + + # Update priorities (V4L) + client.is_on.return_value = True + client.visible_priority = {const.KEY_COMPONENTID: const.KEY_COMPONENTID_V4L} + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_ENTITY_ID) + assert entity_state.attributes["icon"] == hyperion_light.ICON_EXTERNAL_SOURCE + assert entity_state.attributes["hs_color"] == (0.0, 0.0) + assert entity_state.attributes["effect"] == const.KEY_COMPONENTID_V4L + + # Update priorities (Effect) + effect = "foo" + client.visible_priority = { + const.KEY_COMPONENTID: const.KEY_COMPONENTID_EFFECT, + const.KEY_OWNER: effect, + } + + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_ENTITY_ID) + assert entity_state.attributes["effect"] == effect + assert entity_state.attributes["icon"] == hyperion_light.ICON_EFFECT + assert entity_state.attributes["hs_color"] == (0.0, 0.0) + + # Update priorities (Color) + rgb = (0, 100, 100) + client.visible_priority = { + const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR, + const.KEY_VALUE: {const.KEY_RGB: rgb}, + } + + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_ENTITY_ID) + assert entity_state.attributes["effect"] == hyperion_light.KEY_EFFECT_SOLID + assert entity_state.attributes["icon"] == hyperion_light.ICON_LIGHTBULB + assert entity_state.attributes["hs_color"] == (180.0, 100.0) + + # Update effect list + effects = [{const.KEY_NAME: "One"}, {const.KEY_NAME: "Two"}] + client.effects = effects + call_registered_callback(client, "effects-update") + entity_state = hass.states.get(TEST_ENTITY_ID) + assert entity_state.attributes["effect_list"] == [ + effect[const.KEY_NAME] for effect in effects + ] + const.KEY_COMPONENTID_EXTERNAL_SOURCES + [hyperion_light.KEY_EFFECT_SOLID] + + # Update connection status (e.g. disconnection). + + # Turn on late, check state, disconnect, ensure it cannot be turned off. + client.has_loaded_state = False + call_registered_callback(client, "client-update", {"loaded-state": False}) + entity_state = hass.states.get(TEST_ENTITY_ID) + assert entity_state.state == "unavailable" + + # Update connection status (e.g. re-connection) + client.has_loaded_state = True + call_registered_callback(client, "client-update", {"loaded-state": True}) + entity_state = hass.states.get(TEST_ENTITY_ID) + assert entity_state.state == "on" + + +async def test_full_state_loaded_on_start(hass): + """Test receiving a variety of Hyperion client callbacks.""" + client = create_mock_client() + + # Update full state (should call all update methods). + brightness = 25 + client.adjustment = [{const.KEY_BRIGHTNESS: brightness}] + client.visible_priority = { + const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR, + const.KEY_VALUE: {const.KEY_RGB: (0, 100, 100)}, + } + client.effects = [{const.KEY_NAME: "One"}, {const.KEY_NAME: "Two"}] + + await setup_entity(hass, client=client) + + entity_state = hass.states.get(TEST_ENTITY_ID) + + assert entity_state.attributes["brightness"] == round(255 * (brightness / 100.0)) + assert entity_state.attributes["effect"] == hyperion_light.KEY_EFFECT_SOLID + assert entity_state.attributes["icon"] == hyperion_light.ICON_LIGHTBULB + assert entity_state.attributes["hs_color"] == (180.0, 100.0)