From 890eaf840c12e12cd40359b703d07ac38352e302 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 27 Jan 2021 00:35:13 -0800 Subject: [PATCH] Add advanced Hyperion entities (#45410) Co-authored-by: Paulus Schoutsen --- homeassistant/components/hyperion/__init__.py | 158 +++++- homeassistant/components/hyperion/const.py | 36 +- homeassistant/components/hyperion/light.py | 394 +++++++++------ homeassistant/components/hyperion/switch.py | 210 ++++++++ tests/components/hyperion/__init__.py | 15 +- tests/components/hyperion/test_config_flow.py | 3 +- tests/components/hyperion/test_light.py | 476 ++++++++++++++++-- tests/components/hyperion/test_switch.py | 140 ++++++ 8 files changed, 1213 insertions(+), 219 deletions(-) create mode 100644 homeassistant/components/hyperion/switch.py create mode 100644 tests/components/hyperion/test_switch.py diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 870d1e1bc3e..aeac922826d 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -2,29 +2,40 @@ import asyncio import logging -from typing import Any, Optional +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, cast from hyperion import client, const as hyperion_const from pkg_resources import parse_version from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SOURCE, CONF_TOKEN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity_registry import ( + async_entries_for_config_entry, + async_get_registry, +) from homeassistant.helpers.typing import ConfigType, HomeAssistantType from .const import ( + CONF_INSTANCE_CLIENTS, CONF_ON_UNLOAD, CONF_ROOT_CLIENT, + DEFAULT_NAME, DOMAIN, HYPERION_RELEASES_URL, HYPERION_VERSION_WARN_CUTOFF, - SIGNAL_INSTANCES_UPDATED, + SIGNAL_INSTANCE_ADD, + SIGNAL_INSTANCE_REMOVE, ) -PLATFORMS = [LIGHT_DOMAIN] +PLATFORMS = [LIGHT_DOMAIN, SWITCH_DOMAIN] _LOGGER = logging.getLogger(__name__) @@ -59,6 +70,17 @@ def get_hyperion_unique_id(server_id: str, instance: int, name: str) -> str: return f"{server_id}_{instance}_{name}" +def split_hyperion_unique_id(unique_id: str) -> Optional[Tuple[str, int, str]]: + """Split a unique_id into a (server_id, instance, type) tuple.""" + data = tuple(unique_id.split("_", 2)) + if len(data) != 3: + return None + try: + return (data[0], int(data[1]), data[2]) + except ValueError: + return None + + def create_hyperion_client( *args: Any, **kwargs: Any, @@ -96,6 +118,31 @@ async def _create_reauth_flow( ) +@callback +def listen_for_instance_updates( + hass: HomeAssistant, + config_entry: ConfigEntry, + add_func: Callable, + remove_func: Callable, +) -> None: + """Listen for instance additions/removals.""" + + hass.data[DOMAIN][config_entry.entry_id][CONF_ON_UNLOAD].extend( + [ + async_dispatcher_connect( + hass, + SIGNAL_INSTANCE_ADD.format(config_entry.entry_id), + add_func, + ), + async_dispatcher_connect( + hass, + SIGNAL_INSTANCE_REMOVE.format(config_entry.entry_id), + remove_func, + ), + ] + ) + + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Hyperion from a config entry.""" host = config_entry.data[CONF_HOST] @@ -151,23 +198,86 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await hyperion_client.async_client_disconnect() raise ConfigEntryNotReady - hyperion_client.set_callbacks( - { - f"{hyperion_const.KEY_INSTANCE}-{hyperion_const.KEY_UPDATE}": lambda json: ( - async_dispatcher_send( - hass, - SIGNAL_INSTANCES_UPDATED.format(config_entry.entry_id), - json, - ) - ) - } - ) - + # We need 1 root client (to manage instances being removed/added) and then 1 client + # per Hyperion server instance which is shared for all entities associated with + # that instance. hass.data[DOMAIN][config_entry.entry_id] = { CONF_ROOT_CLIENT: hyperion_client, + CONF_INSTANCE_CLIENTS: {}, CONF_ON_UNLOAD: [], } + async def async_instances_to_clients(response: Dict[str, Any]) -> None: + """Convert instances to Hyperion clients.""" + if not response or hyperion_const.KEY_DATA not in response: + return + await async_instances_to_clients_raw(response[hyperion_const.KEY_DATA]) + + async def async_instances_to_clients_raw(instances: List[Dict[str, Any]]) -> None: + """Convert instances to Hyperion clients.""" + registry = await async_get_registry(hass) + running_instances: Set[int] = set() + stopped_instances: Set[int] = set() + existing_instances = hass.data[DOMAIN][config_entry.entry_id][ + CONF_INSTANCE_CLIENTS + ] + server_id = cast(str, config_entry.unique_id) + + # In practice, an instance can be in 3 states as seen by this function: + # + # * Exists, and is running: Should be present in HASS/registry. + # * Exists, but is not running: Cannot add it yet, but entity may have be + # registered from a previous time it was running. + # * No longer exists at all: Should not be present in HASS/registry. + + # Add instances that are missing. + for instance in instances: + instance_num = instance.get(hyperion_const.KEY_INSTANCE) + if instance_num is None: + continue + if not instance.get(hyperion_const.KEY_RUNNING, False): + stopped_instances.add(instance_num) + continue + running_instances.add(instance_num) + if instance_num in existing_instances: + continue + hyperion_client = await async_create_connect_hyperion_client( + host, port, instance=instance_num, token=token + ) + if not hyperion_client: + continue + existing_instances[instance_num] = hyperion_client + instance_name = instance.get(hyperion_const.KEY_FRIENDLY_NAME, DEFAULT_NAME) + async_dispatcher_send( + hass, + SIGNAL_INSTANCE_ADD.format(config_entry.entry_id), + instance_num, + instance_name, + ) + + # Remove entities that are are not running instances on Hyperion. + for instance_num in set(existing_instances) - running_instances: + del existing_instances[instance_num] + async_dispatcher_send( + hass, SIGNAL_INSTANCE_REMOVE.format(config_entry.entry_id), instance_num + ) + + # Deregister entities that belong to removed instances. + for entry in async_entries_for_config_entry(registry, config_entry.entry_id): + data = split_hyperion_unique_id(entry.unique_id) + if not data: + continue + if data[0] == server_id and ( + data[1] not in running_instances and data[1] not in stopped_instances + ): + registry.async_remove(entry.entity_id) + + hyperion_client.set_callbacks( + { + f"{hyperion_const.KEY_INSTANCE}-{hyperion_const.KEY_UPDATE}": async_instances_to_clients, + } + ) + # Must only listen for option updates after the setup is complete, as otherwise # the YAML->ConfigEntry migration code triggers an options update, which causes a # reload -- which clashes with the initial load (causing entity_id / unique_id @@ -179,6 +289,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b for component in PLATFORMS ] ) + assert hyperion_client + await async_instances_to_clients_raw(hyperion_client.instances) hass.data[DOMAIN][config_entry.entry_id][CONF_ON_UNLOAD].append( config_entry.add_update_listener(_async_entry_updated) ) @@ -210,6 +322,18 @@ async def async_unload_entry( config_data = hass.data[DOMAIN].pop(config_entry.entry_id) for func in config_data[CONF_ON_UNLOAD]: func() + + # Disconnect the shared instance clients. + await asyncio.gather( + *[ + config_data[CONF_INSTANCE_CLIENTS][ + instance_num + ].async_client_disconnect() + for instance_num in config_data[CONF_INSTANCE_CLIENTS] + ] + ) + + # Disconnect the root client. root_client = config_data[CONF_ROOT_CLIENT] await root_client.async_client_disconnect() return unload_ok diff --git a/homeassistant/components/hyperion/const.py b/homeassistant/components/hyperion/const.py index 2bb9ec241e5..64c2f20052b 100644 --- a/homeassistant/components/hyperion/const.py +++ b/homeassistant/components/hyperion/const.py @@ -1,8 +1,33 @@ """Constants for Hyperion integration.""" +from hyperion.const import ( + KEY_COMPONENTID_ALL, + KEY_COMPONENTID_BLACKBORDER, + KEY_COMPONENTID_BOBLIGHTSERVER, + KEY_COMPONENTID_FORWARDER, + KEY_COMPONENTID_GRABBER, + KEY_COMPONENTID_LEDDEVICE, + KEY_COMPONENTID_SMOOTHING, + KEY_COMPONENTID_V4L, +) + +# Maps between Hyperion API component names to Hyperion UI names. This allows Home +# Assistant to use names that match what Hyperion users may expect from the Hyperion UI. +COMPONENT_TO_NAME = { + KEY_COMPONENTID_ALL: "All", + KEY_COMPONENTID_SMOOTHING: "Smoothing", + KEY_COMPONENTID_BLACKBORDER: "Blackbar Detection", + KEY_COMPONENTID_FORWARDER: "Forwarder", + KEY_COMPONENTID_BOBLIGHTSERVER: "Boblight Server", + KEY_COMPONENTID_GRABBER: "Platform Capture", + KEY_COMPONENTID_LEDDEVICE: "LED Device", + KEY_COMPONENTID_V4L: "USB Capture", +} + CONF_AUTH_ID = "auth_id" CONF_CREATE_TOKEN = "create_token" CONF_INSTANCE = "instance" +CONF_INSTANCE_CLIENTS = "INSTANCE_CLIENTS" CONF_ON_UNLOAD = "ON_UNLOAD" CONF_PRIORITY = "priority" CONF_ROOT_CLIENT = "ROOT_CLIENT" @@ -16,7 +41,14 @@ DOMAIN = "hyperion" HYPERION_RELEASES_URL = "https://github.com/hyperion-project/hyperion.ng/releases" HYPERION_VERSION_WARN_CUTOFF = "2.0.0-alpha.9" -SIGNAL_INSTANCES_UPDATED = f"{DOMAIN}_instances_updated_signal." "{}" -SIGNAL_INSTANCE_REMOVED = f"{DOMAIN}_instance_removed_signal." "{}" +NAME_SUFFIX_HYPERION_LIGHT = "" +NAME_SUFFIX_HYPERION_PRIORITY_LIGHT = "Priority" +NAME_SUFFIX_HYPERION_COMPONENT_SWITCH = "Component" + +SIGNAL_INSTANCE_ADD = f"{DOMAIN}_instance_add_signal." "{}" +SIGNAL_INSTANCE_REMOVE = f"{DOMAIN}_instance_remove_signal." "{}" +SIGNAL_ENTITY_REMOVE = f"{DOMAIN}_entity_remove_signal." "{}" TYPE_HYPERION_LIGHT = "hyperion_light" +TYPE_HYPERION_PRIORITY_LIGHT = "hyperion_priority_light" +TYPE_HYPERION_COMPONENT_SWITCH_BASE = "hyperion_component_switch" diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index 634cf2f0afd..a329ee5c20e 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging import re from types import MappingProxyType -from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, cast +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple from hyperion import client, const import voluptuous as vol @@ -22,17 +22,15 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TOKEN +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import callback from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_registry import ( - async_entries_for_config_entry, - async_get_registry, -) +from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.helpers.typing import ( ConfigType, DiscoveryInfoType, @@ -41,24 +39,27 @@ from homeassistant.helpers.typing import ( import homeassistant.util.color as color_util from . import ( - async_create_connect_hyperion_client, create_hyperion_client, get_hyperion_unique_id, + listen_for_instance_updates, ) from .const import ( - CONF_ON_UNLOAD, + CONF_INSTANCE_CLIENTS, CONF_PRIORITY, - CONF_ROOT_CLIENT, DEFAULT_ORIGIN, DEFAULT_PRIORITY, DOMAIN, - SIGNAL_INSTANCE_REMOVED, - SIGNAL_INSTANCES_UPDATED, + NAME_SUFFIX_HYPERION_LIGHT, + NAME_SUFFIX_HYPERION_PRIORITY_LIGHT, + SIGNAL_ENTITY_REMOVE, TYPE_HYPERION_LIGHT, + TYPE_HYPERION_PRIORITY_LIGHT, ) _LOGGER = logging.getLogger(__name__) +COLOR_BLACK = color_util.COLORS["black"] + CONF_DEFAULT_COLOR = "default_color" CONF_HDMI_PRIORITY = "hdmi_priority" CONF_EFFECT_LIST = "effect_list" @@ -226,90 +227,53 @@ async def async_setup_entry( hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable ) -> bool: """Set up a Hyperion platform from config entry.""" - host = config_entry.data[CONF_HOST] - port = config_entry.data[CONF_PORT] - token = config_entry.data.get(CONF_TOKEN) - async def async_instances_to_entities(response: Dict[str, Any]) -> None: - if not response or const.KEY_DATA not in response: - return - await async_instances_to_entities_raw(response[const.KEY_DATA]) + entry_data = hass.data[DOMAIN][config_entry.entry_id] + server_id = config_entry.unique_id - async def async_instances_to_entities_raw(instances: List[Dict[str, Any]]) -> None: - registry = await async_get_registry(hass) - entities_to_add: List[HyperionLight] = [] - running_unique_ids: Set[str] = set() - stopped_unique_ids: Set[str] = set() - server_id = cast(str, config_entry.unique_id) - - # In practice, an instance can be in 3 states as seen by this function: - # - # * Exists, and is running: Should be present in HASS/registry. - # * Exists, but is not running: Cannot add it yet, but entity may have be - # registered from a previous time it was running. - # * No longer exists at all: Should not be present in HASS/registry. - - # Add instances that are missing. - for instance in instances: - instance_id = instance.get(const.KEY_INSTANCE) - if instance_id is None: - continue - unique_id = get_hyperion_unique_id( - server_id, instance_id, TYPE_HYPERION_LIGHT - ) - if not instance.get(const.KEY_RUNNING, False): - stopped_unique_ids.add(unique_id) - continue - running_unique_ids.add(unique_id) - - if unique_id in live_entities: - continue - hyperion_client = await async_create_connect_hyperion_client( - host, port, instance=instance_id, token=token - ) - if not hyperion_client: - continue - live_entities.add(unique_id) - entities_to_add.append( + @callback + def instance_add(instance_num: int, instance_name: str) -> None: + """Add entities for a new Hyperion instance.""" + assert server_id + async_add_entities( + [ HyperionLight( - unique_id, - instance.get(const.KEY_FRIENDLY_NAME, DEFAULT_NAME), + get_hyperion_unique_id( + server_id, instance_num, TYPE_HYPERION_LIGHT + ), + f"{instance_name} {NAME_SUFFIX_HYPERION_LIGHT}", config_entry.options, - hyperion_client, - ) + entry_data[CONF_INSTANCE_CLIENTS][instance_num], + ), + HyperionPriorityLight( + get_hyperion_unique_id( + server_id, instance_num, TYPE_HYPERION_PRIORITY_LIGHT + ), + f"{instance_name} {NAME_SUFFIX_HYPERION_PRIORITY_LIGHT}", + config_entry.options, + entry_data[CONF_INSTANCE_CLIENTS][instance_num], + ), + ] + ) + + @callback + def instance_remove(instance_num: int) -> None: + """Remove entities for an old Hyperion instance.""" + assert server_id + for light_type in LIGHT_TYPES: + async_dispatcher_send( + hass, + SIGNAL_ENTITY_REMOVE.format( + get_hyperion_unique_id(server_id, instance_num, light_type) + ), ) - # Remove entities that are are not running instances on Hyperion: - for unique_id in live_entities - running_unique_ids: - live_entities.remove(unique_id) - async_dispatcher_send(hass, SIGNAL_INSTANCE_REMOVED.format(unique_id)) - - # Deregister instances that are no longer present on this server. - for entry in async_entries_for_config_entry(registry, config_entry.entry_id): - if entry.unique_id not in running_unique_ids.union(stopped_unique_ids): - registry.async_remove(entry.entity_id) - - async_add_entities(entities_to_add) - - # Readability note: This variable is kept alive in the context of the callback to - # async_instances_to_entities below. - live_entities: Set[str] = set() - - await async_instances_to_entities_raw( - hass.data[DOMAIN][config_entry.entry_id][CONF_ROOT_CLIENT].instances, - ) - hass.data[DOMAIN][config_entry.entry_id][CONF_ON_UNLOAD].append( - async_dispatcher_connect( - hass, - SIGNAL_INSTANCES_UPDATED.format(config_entry.entry_id), - async_instances_to_entities, - ) - ) + listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) return True -class HyperionLight(LightEntity): - """Representation of a Hyperion remote.""" +class HyperionBaseLight(LightEntity): + """A Hyperion light base class.""" def __init__( self, @@ -329,7 +293,23 @@ class HyperionLight(LightEntity): self._rgb_color: Sequence[int] = DEFAULT_COLOR self._effect: str = KEY_EFFECT_SOLID - self._effect_list: List[str] = [] + self._static_effect_list: List[str] = [KEY_EFFECT_SOLID] + if self._support_external_effects: + self._static_effect_list += list(const.KEY_COMPONENTID_EXTERNAL_SOURCES) + self._effect_list: List[str] = self._static_effect_list[:] + + self._client_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, + } + + @property + def entity_registry_enabled_default(self) -> bool: + """Whether or not the entity is enabled by default.""" + return True @property def should_poll(self) -> bool: @@ -351,11 +331,6 @@ class HyperionLight(LightEntity): """Return last color value set.""" return color_util.color_RGB_to_hs(*self._rgb_color) - @property - def is_on(self) -> bool: - """Return true if not black.""" - return bool(self._client.is_on()) and self._client.visible_priority is not None - @property def icon(self) -> str: """Return state specific icon.""" @@ -374,11 +349,7 @@ class HyperionLight(LightEntity): @property def effect_list(self) -> List[str]: """Return the list of supported effects.""" - return ( - self._effect_list - + list(const.KEY_COMPONENTID_EXTERNAL_SOURCES) - + [KEY_EFFECT_SOLID] - ) + return self._effect_list @property def supported_features(self) -> int: @@ -401,33 +372,7 @@ class HyperionLight(LightEntity): return self._options.get(key, defaults[key]) async def async_turn_on(self, **kwargs: Any) -> None: - """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 - + """Turn on the light.""" # == Get key parameters == if ATTR_EFFECT not in kwargs and ATTR_HS_COLOR in kwargs: effect = KEY_EFFECT_SOLID @@ -457,7 +402,11 @@ class HyperionLight(LightEntity): return # == Set an external source - if effect and effect in const.KEY_COMPONENTID_EXTERNAL_SOURCES: + if ( + effect + and self._support_external_effects + and effect in const.KEY_COMPONENTID_EXTERNAL_SOURCES + ): # Clear any color/effect. if not await self._client.async_send_clear( @@ -505,18 +454,6 @@ class HyperionLight(LightEntity): ): return - async def async_turn_off(self, **kwargs: Any) -> None: - """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: Optional[int] = None, @@ -531,10 +468,12 @@ class HyperionLight(LightEntity): if effect is not None: self._effect = effect + @callback def _update_components(self, _: Optional[Dict[str, Any]] = None) -> None: """Update Hyperion components.""" self.async_write_ha_state() + @callback def _update_adjustment(self, _: Optional[Dict[str, Any]] = None) -> None: """Update Hyperion adjustments.""" if self._client.adjustment: @@ -548,26 +487,31 @@ class HyperionLight(LightEntity): ) self.async_write_ha_state() + @callback def _update_priorities(self, _: Optional[Dict[str, Any]] = None) -> 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: + priority = self._get_priority_entry_that_dictates_state() + if priority and self._allow_priority_update(priority): + componentid = priority.get(const.KEY_COMPONENTID) + if ( + self._support_external_effects + and 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] + rgb_color=DEFAULT_COLOR, effect=priority[const.KEY_OWNER] ) elif componentid == const.KEY_COMPONENTID_COLOR: self._set_internal_state( - rgb_color=visible_priority[const.KEY_VALUE][const.KEY_RGB], + rgb_color=priority[const.KEY_VALUE][const.KEY_RGB], effect=KEY_EFFECT_SOLID, ) self.async_write_ha_state() + @callback def _update_effect_list(self, _: Optional[Dict[str, Any]] = None) -> None: """Update Hyperion effects.""" if not self._client.effects: @@ -577,9 +521,10 @@ class HyperionLight(LightEntity): if const.KEY_NAME in effect: effect_list.append(effect[const.KEY_NAME]) if effect_list: - self._effect_list = effect_list + self._effect_list = self._static_effect_list + effect_list self.async_write_ha_state() + @callback def _update_full_state(self) -> None: """Update full Hyperion state.""" self._update_adjustment() @@ -596,6 +541,7 @@ class HyperionLight(LightEntity): self._rgb_color, ) + @callback def _update_client(self, _: Optional[Dict[str, Any]] = None) -> None: """Update client connection state.""" self.async_write_ha_state() @@ -606,24 +552,160 @@ class HyperionLight(LightEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_INSTANCE_REMOVED.format(self._unique_id), + SIGNAL_ENTITY_REMOVE.format(self._unique_id), self.async_remove, ) ) - 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, - } - ) + self._client.add_callbacks(self._client_callbacks) # Load initial state. self._update_full_state() async def async_will_remove_from_hass(self) -> None: - """Disconnect from server.""" - await self._client.async_client_disconnect() + """Cleanup prior to hass removal.""" + self._client.remove_callbacks(self._client_callbacks) + + @property + def _support_external_effects(self) -> bool: + """Whether or not to support setting external effects from the light entity.""" + return True + + def _get_priority_entry_that_dictates_state(self) -> Optional[Dict[str, Any]]: + """Get the relevant Hyperion priority entry to consider.""" + # Return the visible priority (whether or not it is the HA priority). + return self._client.visible_priority # type: ignore[no-any-return] + + # pylint: disable=no-self-use + def _allow_priority_update(self, priority: Optional[Dict[str, Any]] = None) -> bool: + """Determine whether to allow a priority to update internal state.""" + return True + + +class HyperionLight(HyperionBaseLight): + """A Hyperion light that acts in absolute (vs priority) manner. + + Light state is the absolute Hyperion component state (e.g. LED device on/off) rather + than color based at a particular priority, and the 'winning' priority determines + shown state rather than exclusively the HA priority. + """ + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return ( + bool(self._client.is_on()) + and self._get_priority_entry_that_dictates_state() is not None + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + # == 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 bool(self._client.is_on()): + for component in [ + const.KEY_COMPONENTID_ALL, + const.KEY_COMPONENTID_LEDDEVICE, + ]: + if not await self._client.async_send_set_component( + **{ + const.KEY_COMPONENTSTATE: { + const.KEY_COMPONENT: component, + const.KEY_STATE: True, + } + } + ): + return + + # Turn on the relevant Hyperion priority as usual. + await super().async_turn_on(**kwargs) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + if not await self._client.async_send_set_component( + **{ + const.KEY_COMPONENTSTATE: { + const.KEY_COMPONENT: const.KEY_COMPONENTID_LEDDEVICE, + const.KEY_STATE: False, + } + } + ): + return + + +class HyperionPriorityLight(HyperionBaseLight): + """A Hyperion light that only acts on a single Hyperion priority.""" + + @property + def entity_registry_enabled_default(self) -> bool: + """Whether or not the entity is enabled by default.""" + return False + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + priority = self._get_priority_entry_that_dictates_state() + return ( + priority is not None + and not HyperionPriorityLight._is_priority_entry_black(priority) + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + if not await self._client.async_send_clear( + **{const.KEY_PRIORITY: self._get_option(CONF_PRIORITY)} + ): + return + await self._client.async_send_set_color( + **{ + const.KEY_PRIORITY: self._get_option(CONF_PRIORITY), + const.KEY_COLOR: COLOR_BLACK, + const.KEY_ORIGIN: DEFAULT_ORIGIN, + } + ) + + @property + def _support_external_effects(self) -> bool: + """Whether or not to support setting external effects from the light entity.""" + return False + + def _get_priority_entry_that_dictates_state(self) -> Optional[Dict[str, Any]]: + """Get the relevant Hyperion priority entry to consider.""" + # Return the active priority (if any) at the configured HA priority. + for candidate in self._client.priorities or []: + if const.KEY_PRIORITY not in candidate: + continue + if candidate[const.KEY_PRIORITY] == self._get_option( + CONF_PRIORITY + ) and candidate.get(const.KEY_ACTIVE, False): + return candidate # type: ignore[no-any-return] + return None + + @classmethod + def _is_priority_entry_black(cls, priority: Optional[Dict[str, Any]]) -> bool: + """Determine if a given priority entry is the color black.""" + if not priority: + return False + if priority.get(const.KEY_COMPONENTID) == const.KEY_COMPONENTID_COLOR: + rgb_color = priority.get(const.KEY_VALUE, {}).get(const.KEY_RGB) + if rgb_color is not None and tuple(rgb_color) == COLOR_BLACK: + return True + return False + + # pylint: disable=no-self-use + def _allow_priority_update(self, priority: Optional[Dict[str, Any]] = None) -> bool: + """Determine whether to allow a Hyperion priority to update entity attributes.""" + # Black is treated as 'off' (and Home Assistant does not support selecting black + # from the color selector). Do not set our internal attributes if the priority is + # 'off' (i.e. if black is active). Do this to ensure it seamlessly turns back on + # at the correct prior color on the next 'on' call. + return not HyperionPriorityLight._is_priority_entry_black(priority) + + +LIGHT_TYPES = { + TYPE_HYPERION_LIGHT: HyperionLight, + TYPE_HYPERION_PRIORITY_LIGHT: HyperionPriorityLight, +} diff --git a/homeassistant/components/hyperion/switch.py b/homeassistant/components/hyperion/switch.py new file mode 100644 index 00000000000..372e9876c35 --- /dev/null +++ b/homeassistant/components/hyperion/switch.py @@ -0,0 +1,210 @@ +"""Switch platform for Hyperion.""" + +from typing import Any, Callable, Dict, Optional + +from hyperion import client +from hyperion.const import ( + KEY_COMPONENT, + KEY_COMPONENTID_ALL, + KEY_COMPONENTID_BLACKBORDER, + KEY_COMPONENTID_BOBLIGHTSERVER, + KEY_COMPONENTID_FORWARDER, + KEY_COMPONENTID_GRABBER, + KEY_COMPONENTID_LEDDEVICE, + KEY_COMPONENTID_SMOOTHING, + KEY_COMPONENTID_V4L, + KEY_COMPONENTS, + KEY_COMPONENTSTATE, + KEY_ENABLED, + KEY_NAME, + KEY_STATE, + KEY_UPDATE, +) + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import slugify + +from . import get_hyperion_unique_id, listen_for_instance_updates +from .const import ( + COMPONENT_TO_NAME, + CONF_INSTANCE_CLIENTS, + DOMAIN, + NAME_SUFFIX_HYPERION_COMPONENT_SWITCH, + SIGNAL_ENTITY_REMOVE, + TYPE_HYPERION_COMPONENT_SWITCH_BASE, +) + +COMPONENT_SWITCHES = [ + KEY_COMPONENTID_ALL, + KEY_COMPONENTID_SMOOTHING, + KEY_COMPONENTID_BLACKBORDER, + KEY_COMPONENTID_FORWARDER, + KEY_COMPONENTID_BOBLIGHTSERVER, + KEY_COMPONENTID_GRABBER, + KEY_COMPONENTID_LEDDEVICE, + KEY_COMPONENTID_V4L, +] + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable +) -> bool: + """Set up a Hyperion platform from config entry.""" + entry_data = hass.data[DOMAIN][config_entry.entry_id] + server_id = config_entry.unique_id + + def component_to_switch_type(component: str) -> str: + """Convert a component to a switch type string.""" + return slugify( + f"{TYPE_HYPERION_COMPONENT_SWITCH_BASE} {COMPONENT_TO_NAME[component]}" + ) + + def component_to_unique_id(component: str, instance_num: int) -> str: + """Convert a component to a unique_id.""" + assert server_id + return get_hyperion_unique_id( + server_id, instance_num, component_to_switch_type(component) + ) + + def component_to_switch_name(component: str, instance_name: str) -> str: + """Convert a component to a switch name.""" + return ( + f"{instance_name} " + f"{NAME_SUFFIX_HYPERION_COMPONENT_SWITCH} " + f"{COMPONENT_TO_NAME.get(component, component.capitalize())}" + ) + + @callback + def instance_add(instance_num: int, instance_name: str) -> None: + """Add entities for a new Hyperion instance.""" + assert server_id + switches = [] + for component in COMPONENT_SWITCHES: + switches.append( + HyperionComponentSwitch( + component_to_unique_id(component, instance_num), + component_to_switch_name(component, instance_name), + component, + entry_data[CONF_INSTANCE_CLIENTS][instance_num], + ), + ) + async_add_entities(switches) + + @callback + def instance_remove(instance_num: int) -> None: + """Remove entities for an old Hyperion instance.""" + assert server_id + for component in COMPONENT_SWITCHES: + async_dispatcher_send( + hass, + SIGNAL_ENTITY_REMOVE.format( + component_to_unique_id(component, instance_num), + ), + ) + + listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) + return True + + +class HyperionComponentSwitch(SwitchEntity): + """ComponentBinarySwitch switch class.""" + + def __init__( + self, + unique_id: str, + name: str, + component_name: str, + hyperion_client: client.HyperionClient, + ) -> None: + """Initialize the switch.""" + self._unique_id = unique_id + self._name = name + self._component_name = component_name + self._client = hyperion_client + self._client_callbacks = { + f"{KEY_COMPONENTS}-{KEY_UPDATE}": self._update_components + } + + @property + def should_poll(self) -> bool: + """Return whether or not this entity should be polled.""" + return False + + @property + def entity_registry_enabled_default(self) -> bool: + """Whether or not the entity is enabled by default.""" + # These component controls are for advanced users and are disabled by default. + return False + + @property + def unique_id(self) -> str: + """Return a unique id for this instance.""" + return self._unique_id + + @property + def name(self) -> str: + """Return the name of the switch.""" + return self._name + + @property + def is_on(self) -> bool: + """Return true if the switch is on.""" + for component in self._client.components: + if component[KEY_NAME] == self._component_name: + return bool(component.setdefault(KEY_ENABLED, False)) + return False + + @property + def available(self) -> bool: + """Return server availability.""" + return bool(self._client.has_loaded_state) + + async def _async_send_set_component(self, value: bool) -> None: + """Send a component control request.""" + await self._client.async_send_set_component( + **{ + KEY_COMPONENTSTATE: { + KEY_COMPONENT: self._component_name, + KEY_STATE: value, + } + } + ) + + # pylint: disable=unused-argument + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + await self._async_send_set_component(True) + + # pylint: disable=unused-argument + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + await self._async_send_set_component(False) + + @callback + def _update_components(self, _: Optional[Dict[str, Any]] = None) -> None: + """Update Hyperion components.""" + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks when entity added to hass.""" + assert self.hass + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_ENTITY_REMOVE.format(self._unique_id), + self.async_remove, + ) + ) + + self._client.add_callbacks(self._client_callbacks) + + async def async_will_remove_from_hass(self) -> None: + """Cleanup prior to hass removal.""" + self._client.remove_callbacks(self._client_callbacks) diff --git a/tests/components/hyperion/__init__.py b/tests/components/hyperion/__init__.py index 37e95baf1dd..e427cf46a83 100644 --- a/tests/components/hyperion/__init__.py +++ b/tests/components/hyperion/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from types import TracebackType from typing import Any, Dict, Optional, Type -from unittest.mock import AsyncMock, Mock, patch # type: ignore[attr-defined] +from unittest.mock import AsyncMock, Mock, patch from hyperion import const @@ -29,6 +29,7 @@ TEST_YAML_ENTITY_ID = f"{LIGHT_DOMAIN}.{TEST_YAML_NAME}" TEST_ENTITY_ID_1 = "light.test_instance_1" TEST_ENTITY_ID_2 = "light.test_instance_2" TEST_ENTITY_ID_3 = "light.test_instance_3" +TEST_PRIORITY_LIGHT_ENTITY_ID_1 = "light.test_instance_1_priority" TEST_TITLE = f"{TEST_HOST}:{TEST_PORT}" TEST_TOKEN = "sekr1t" @@ -68,7 +69,7 @@ TEST_AUTH_NOT_REQUIRED_RESP = { _LOGGER = logging.getLogger(__name__) -class AsyncContextManagerMock(Mock): # type: ignore[misc] +class AsyncContextManagerMock(Mock): """An async context manager mock for Hyperion.""" async def __aenter__(self) -> Optional[AsyncContextManagerMock]: @@ -112,6 +113,7 @@ def create_mock_client() -> Mock: } ) + mock_client.priorities = [] mock_client.adjustment = None mock_client.effects = None mock_client.instances = [ @@ -160,3 +162,12 @@ async def setup_test_config_entry( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return config_entry + + +def call_registered_callback( + client: AsyncMock, key: str, *args: Any, **kwargs: Any +) -> None: + """Call Hyperion entity callbacks that were registered with the client.""" + for call in client.add_callbacks.call_args_list: + if key in call[0][0]: + call[0][0][key](*args, **kwargs) diff --git a/tests/components/hyperion/test_config_flow.py b/tests/components/hyperion/test_config_flow.py index c420fe2fe17..776d5b3b25b 100644 --- a/tests/components/hyperion/test_config_flow.py +++ b/tests/components/hyperion/test_config_flow.py @@ -2,7 +2,7 @@ import logging from typing import Any, Dict, Optional -from unittest.mock import AsyncMock, patch # type: ignore[attr-defined] +from unittest.mock import AsyncMock, patch from hyperion import const @@ -689,6 +689,7 @@ async def test_options(hass: HomeAssistantType) -> None: {ATTR_ENTITY_ID: TEST_ENTITY_ID_1}, blocking=True, ) + # pylint: disable=unsubscriptable-object assert client.async_send_set_color.call_args[1][CONF_PRIORITY] == new_priority diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py index 8d14555c42b..7559af4d3c7 100644 --- a/tests/components/hyperion/test_light.py +++ b/tests/components/hyperion/test_light.py @@ -1,8 +1,8 @@ """Tests for the Hyperion integration.""" import logging from types import MappingProxyType -from typing import Any, Optional -from unittest.mock import AsyncMock, call, patch # type: ignore[attr-defined] +from typing import Optional +from unittest.mock import AsyncMock, Mock, call, patch from hyperion import const @@ -11,7 +11,11 @@ from homeassistant.components.hyperion import ( get_hyperion_unique_id, light as hyperion_light, ) -from homeassistant.components.hyperion.const import DOMAIN, TYPE_HYPERION_LIGHT +from homeassistant.components.hyperion.const import ( + DEFAULT_ORIGIN, + DOMAIN, + TYPE_HYPERION_LIGHT, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_EFFECT, @@ -34,6 +38,7 @@ from homeassistant.const import ( ) from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.helpers.typing import HomeAssistantType +import homeassistant.util.color as color_util from . import ( TEST_AUTH_NOT_REQUIRED_RESP, @@ -49,22 +54,19 @@ from . import ( TEST_INSTANCE_3, TEST_PORT, TEST_PRIORITY, + TEST_PRIORITY_LIGHT_ENTITY_ID_1, TEST_SYSINFO_ID, TEST_YAML_ENTITY_ID, TEST_YAML_NAME, add_test_config_entry, + call_registered_callback, create_mock_client, setup_test_config_entry, ) _LOGGER = logging.getLogger(__name__) - -def _call_registered_callback( - client: AsyncMock, key: str, *args: Any, **kwargs: Any -) -> None: - """Call a Hyperion entity callback that was registered with the client.""" - client.set_callbacks.call_args[0][0][key](*args, **kwargs) +COLOR_BLACK = color_util.COLORS["black"] async def _setup_entity_yaml(hass: HomeAssistantType, client: AsyncMock = None) -> None: @@ -264,7 +266,7 @@ async def test_setup_config_entry_not_ready_load_state_fail( async def test_setup_config_entry_dynamic_instances(hass: HomeAssistantType) -> None: - """Test dynamic changes in the omstamce configuration.""" + """Test dynamic changes in the instance configuration.""" registry = await async_get_registry(hass) config_entry = add_test_config_entry(hass) @@ -291,11 +293,12 @@ async def test_setup_config_entry_dynamic_instances(hass: HomeAssistantType) -> instance_callback = master_client.set_callbacks.call_args[0][0][ f"{const.KEY_INSTANCE}-{const.KEY_UPDATE}" ] + with patch( "homeassistant.components.hyperion.client.HyperionClient", return_value=entity_client, ): - instance_callback( + await instance_callback( { const.KEY_SUCCESS: True, const.KEY_DATA: [ @@ -323,7 +326,7 @@ async def test_setup_config_entry_dynamic_instances(hass: HomeAssistantType) -> "homeassistant.components.hyperion.client.HyperionClient", return_value=entity_client, ): - instance_callback( + await instance_callback( { const.KEY_SUCCESS: True, const.KEY_DATA: [TEST_INSTANCE_2, TEST_INSTANCE_3], @@ -343,7 +346,7 @@ async def test_setup_config_entry_dynamic_instances(hass: HomeAssistantType) -> "homeassistant.components.hyperion.client.HyperionClient", return_value=entity_client, ): - instance_callback( + await instance_callback( { const.KEY_SUCCESS: True, const.KEY_DATA: [ @@ -364,7 +367,7 @@ async def test_setup_config_entry_dynamic_instances(hass: HomeAssistantType) -> "homeassistant.components.hyperion.client.HyperionClient", return_value=entity_client, ): - instance_callback( + await instance_callback( { const.KEY_SUCCESS: True, const.KEY_DATA: [TEST_INSTANCE_1, TEST_INSTANCE_2, TEST_INSTANCE_3], @@ -413,7 +416,7 @@ async def test_light_async_turn_on(hass: HomeAssistantType) -> None: **{ const.KEY_PRIORITY: TEST_PRIORITY, const.KEY_COLOR: [255, 255, 255], - const.KEY_ORIGIN: hyperion_light.DEFAULT_ORIGIN, + const.KEY_ORIGIN: DEFAULT_ORIGIN, } ) @@ -437,7 +440,7 @@ async def test_light_async_turn_on(hass: HomeAssistantType) -> None: **{ const.KEY_PRIORITY: TEST_PRIORITY, const.KEY_COLOR: [255, 255, 255], - const.KEY_ORIGIN: hyperion_light.DEFAULT_ORIGIN, + const.KEY_ORIGIN: DEFAULT_ORIGIN, } ) @@ -453,7 +456,7 @@ async def test_light_async_turn_on(hass: HomeAssistantType) -> None: # Simulate a state callback from Hyperion. client.adjustment = [{const.KEY_BRIGHTNESS: 50}] - _call_registered_callback(client, "adjustment-update") + call_registered_callback(client, "adjustment-update") entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.state == "on" @@ -473,7 +476,7 @@ async def test_light_async_turn_on(hass: HomeAssistantType) -> None: **{ const.KEY_PRIORITY: TEST_PRIORITY, const.KEY_COLOR: (0, 255, 255), - const.KEY_ORIGIN: hyperion_light.DEFAULT_ORIGIN, + const.KEY_ORIGIN: DEFAULT_ORIGIN, } ) @@ -483,7 +486,7 @@ async def test_light_async_turn_on(hass: HomeAssistantType) -> None: const.KEY_VALUE: {const.KEY_RGB: (0, 255, 255)}, } - _call_registered_callback(client, "priorities-update") + call_registered_callback(client, "priorities-update") entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.attributes["hs_color"] == hs_color @@ -509,11 +512,11 @@ async def test_light_async_turn_on(hass: HomeAssistantType) -> None: **{ const.KEY_PRIORITY: TEST_PRIORITY, const.KEY_COLOR: (0, 255, 255), - const.KEY_ORIGIN: hyperion_light.DEFAULT_ORIGIN, + const.KEY_ORIGIN: DEFAULT_ORIGIN, } ) client.adjustment = [{const.KEY_BRIGHTNESS: 100}] - _call_registered_callback(client, "adjustment-update") + call_registered_callback(client, "adjustment-update") entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.attributes["brightness"] == brightness @@ -559,7 +562,7 @@ async def test_light_async_turn_on(hass: HomeAssistantType) -> None: ), ] client.visible_priority = {const.KEY_COMPONENTID: effect} - _call_registered_callback(client, "priorities-update") + call_registered_callback(client, "priorities-update") entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.attributes["icon"] == hyperion_light.ICON_EXTERNAL_SOURCE @@ -584,14 +587,14 @@ async def test_light_async_turn_on(hass: HomeAssistantType) -> None: **{ const.KEY_PRIORITY: TEST_PRIORITY, const.KEY_EFFECT: {const.KEY_NAME: effect}, - const.KEY_ORIGIN: hyperion_light.DEFAULT_ORIGIN, + const.KEY_ORIGIN: DEFAULT_ORIGIN, } ) client.visible_priority = { const.KEY_COMPONENTID: const.KEY_COMPONENTID_EFFECT, const.KEY_OWNER: effect, } - _call_registered_callback(client, "priorities-update") + call_registered_callback(client, "priorities-update") entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.attributes["icon"] == hyperion_light.ICON_EFFECT @@ -612,7 +615,7 @@ async def test_light_async_turn_on(hass: HomeAssistantType) -> None: **{ const.KEY_PRIORITY: TEST_PRIORITY, const.KEY_COLOR: (0, 0, 255), - const.KEY_ORIGIN: hyperion_light.DEFAULT_ORIGIN, + const.KEY_ORIGIN: DEFAULT_ORIGIN, } ) # Simulate a state callback from Hyperion. @@ -620,7 +623,7 @@ async def test_light_async_turn_on(hass: HomeAssistantType) -> None: const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR, const.KEY_VALUE: {const.KEY_RGB: (0, 0, 255)}, } - _call_registered_callback(client, "priorities-update") + call_registered_callback(client, "priorities-update") entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.attributes["hs_color"] == hs_color @@ -629,7 +632,7 @@ async def test_light_async_turn_on(hass: HomeAssistantType) -> None: # No calls if disconnected. client.has_loaded_state = False - _call_registered_callback(client, "client-update", {"loaded-state": False}) + call_registered_callback(client, "client-update", {"loaded-state": False}) client.async_send_clear = AsyncMock(return_value=True) client.async_send_set_effect = AsyncMock(return_value=True) @@ -641,6 +644,51 @@ async def test_light_async_turn_on(hass: HomeAssistantType) -> None: assert not client.async_send_set_effect.called +async def test_light_async_turn_on_error_conditions(hass: HomeAssistantType) -> None: + """Test error conditions when turning the light on.""" + client = create_mock_client() + client.async_send_set_component = AsyncMock(return_value=False) + client.is_on = Mock(return_value=False) + await setup_test_config_entry(hass, hyperion_client=client) + + # On (=), 100% (=), solid (=), [255,255,255] (=) + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: TEST_ENTITY_ID_1}, blocking=True + ) + + assert client.async_send_set_component.call_args == call( + **{ + const.KEY_COMPONENTSTATE: { + const.KEY_COMPONENT: const.KEY_COMPONENTID_ALL, + const.KEY_STATE: True, + } + } + ) + + +async def test_light_async_turn_off_error_conditions(hass: HomeAssistantType) -> None: + """Test error conditions when turning the light off.""" + client = create_mock_client() + client.async_send_set_component = AsyncMock(return_value=False) + await setup_test_config_entry(hass, hyperion_client=client) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_ENTITY_ID_1}, + 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, + } + } + ) + + async def test_light_async_turn_off(hass: HomeAssistantType) -> None: """Test turning the light off.""" client = create_mock_client() @@ -663,7 +711,7 @@ async def test_light_async_turn_off(hass: HomeAssistantType) -> None: } ) - _call_registered_callback(client, "components-update") + call_registered_callback(client, "components-update") entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.attributes["icon"] == hyperion_light.ICON_LIGHTBULB @@ -671,7 +719,7 @@ async def test_light_async_turn_off(hass: HomeAssistantType) -> None: # No calls if no state loaded. client.has_loaded_state = False client.async_send_set_component = AsyncMock(return_value=True) - _call_registered_callback(client, "client-update", {"loaded-state": False}) + call_registered_callback(client, "client-update", {"loaded-state": False}) await hass.services.async_call( LIGHT_DOMAIN, @@ -693,7 +741,7 @@ async def test_light_async_updates_from_hyperion_client( # Bright change gets accepted. brightness = 10 client.adjustment = [{const.KEY_BRIGHTNESS: brightness}] - _call_registered_callback(client, "adjustment-update") + call_registered_callback(client, "adjustment-update") entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.attributes["brightness"] == round(255 * (brightness / 100.0)) @@ -701,20 +749,20 @@ async def test_light_async_updates_from_hyperion_client( # Broken brightness value is ignored. bad_brightness = -200 client.adjustment = [{const.KEY_BRIGHTNESS: bad_brightness}] - _call_registered_callback(client, "adjustment-update") + call_registered_callback(client, "adjustment-update") entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.attributes["brightness"] == round(255 * (brightness / 100.0)) # Update components. client.is_on.return_value = True - _call_registered_callback(client, "components-update") + call_registered_callback(client, "components-update") entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.state == "on" client.is_on.return_value = False - _call_registered_callback(client, "components-update") + call_registered_callback(client, "components-update") entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.state == "off" @@ -722,7 +770,7 @@ async def test_light_async_updates_from_hyperion_client( # 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") + call_registered_callback(client, "priorities-update") entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.attributes["icon"] == hyperion_light.ICON_EXTERNAL_SOURCE @@ -736,7 +784,7 @@ async def test_light_async_updates_from_hyperion_client( const.KEY_OWNER: effect, } - _call_registered_callback(client, "priorities-update") + call_registered_callback(client, "priorities-update") entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.attributes["effect"] == effect @@ -750,7 +798,7 @@ async def test_light_async_updates_from_hyperion_client( const.KEY_VALUE: {const.KEY_RGB: rgb}, } - _call_registered_callback(client, "priorities-update") + call_registered_callback(client, "priorities-update") entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.attributes["effect"] == hyperion_light.KEY_EFFECT_SOLID @@ -760,7 +808,7 @@ async def test_light_async_updates_from_hyperion_client( # Update priorities (None) client.visible_priority = None - _call_registered_callback(client, "priorities-update") + call_registered_callback(client, "priorities-update") entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.state == "off" @@ -768,18 +816,20 @@ async def test_light_async_updates_from_hyperion_client( # Update effect list effects = [{const.KEY_NAME: "One"}, {const.KEY_NAME: "Two"}] client.effects = effects - _call_registered_callback(client, "effects-update") + call_registered_callback(client, "effects-update") entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.attributes["effect_list"] == [ + hyperion_light.KEY_EFFECT_SOLID + ] + const.KEY_COMPONENTID_EXTERNAL_SOURCES + [ 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}) + call_registered_callback(client, "client-update", {"loaded-state": False}) entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.state == "unavailable" @@ -790,7 +840,7 @@ async def test_light_async_updates_from_hyperion_client( const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR, const.KEY_VALUE: {const.KEY_RGB: rgb}, } - _call_registered_callback(client, "client-update", {"loaded-state": True}) + call_registered_callback(client, "client-update", {"loaded-state": True}) entity_state = hass.states.get(TEST_ENTITY_ID_1) assert entity_state assert entity_state.state == "on" @@ -892,3 +942,347 @@ async def test_setup_entry_bad_token_reauth(hass: HomeAssistantType) -> None: data=config_entry.data, ) assert config_entry.state == ENTRY_STATE_SETUP_ERROR + + +async def test_priority_light_async_updates( + hass: HomeAssistantType, +) -> None: + """Test receiving a variety of Hyperion client callbacks to a HyperionPriorityLight.""" + priority_template = { + const.KEY_ACTIVE: True, + const.KEY_VISIBLE: True, + const.KEY_PRIORITY: TEST_PRIORITY, + const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR, + const.KEY_VALUE: {const.KEY_RGB: (100, 100, 100)}, + } + + client = create_mock_client() + client.priorities = [{**priority_template}] + + with patch( + "homeassistant.components.hyperion.light.HyperionPriorityLight.entity_registry_enabled_default" + ) as enabled_by_default_mock: + enabled_by_default_mock.return_value = True + await setup_test_config_entry(hass, hyperion_client=client) + + # == Scenario: Color at HA priority will show light as on. + entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) + assert entity_state + assert entity_state.state == "on" + assert entity_state.attributes["hs_color"] == (0.0, 0.0) + + # == Scenario: Color going to black shows the light as off. + client.priorities = [ + { + **priority_template, + const.KEY_VALUE: {const.KEY_RGB: COLOR_BLACK}, + } + ] + client.visible_priority = client.priorities[0] + + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) + assert entity_state + assert entity_state.state == "off" + + # == Scenario: Lower priority than HA priority should have no impact on what HA + # shows when the HA priority is present. + client.priorities = [ + {**priority_template, const.KEY_PRIORITY: TEST_PRIORITY - 1}, + { + **priority_template, + const.KEY_VALUE: {const.KEY_RGB: COLOR_BLACK}, + }, + ] + client.visible_priority = client.priorities[0] + + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) + assert entity_state + assert entity_state.state == "off" + + # == Scenario: Fresh color at HA priority should turn HA entity on (even though + # there's a lower priority enabled/visible in Hyperion). + client.priorities = [ + {**priority_template, const.KEY_PRIORITY: TEST_PRIORITY - 1}, + { + **priority_template, + const.KEY_PRIORITY: TEST_PRIORITY, + const.KEY_VALUE: {const.KEY_RGB: (100, 100, 150)}, + }, + ] + client.visible_priority = client.priorities[0] + + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) + assert entity_state + assert entity_state.state == "on" + assert entity_state.attributes["hs_color"] == (240.0, 33.333) + + # == Scenario: V4L at a higher priority, with no other HA priority at all, should + # have no effect. + + # Emulate HA turning the light off with black at the HA priority. + client.priorities = [] + client.visible_priority = None + + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) + assert entity_state + assert entity_state.state == "off" + + # Emulate V4L turning on. + client.priorities = [ + { + **priority_template, + const.KEY_PRIORITY: 240, + const.KEY_COMPONENTID: const.KEY_COMPONENTID_V4L, + const.KEY_VALUE: {const.KEY_RGB: (100, 100, 150)}, + }, + ] + client.visible_priority = client.priorities[0] + + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) + assert entity_state + assert entity_state.state == "off" + + # == Scenario: A lower priority input (lower priority than HA) should have no effect. + + client.priorities = [ + { + **priority_template, + const.KEY_VISIBLE: True, + const.KEY_PRIORITY: TEST_PRIORITY - 1, + const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR, + const.KEY_VALUE: {const.KEY_RGB: (255, 0, 0)}, + }, + { + **priority_template, + const.KEY_PRIORITY: 240, + const.KEY_COMPONENTID: const.KEY_COMPONENTID_V4L, + const.KEY_VALUE: {const.KEY_RGB: (100, 100, 150)}, + const.KEY_VISIBLE: False, + }, + ] + + client.visible_priority = client.priorities[0] + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) + assert entity_state + assert entity_state.state == "off" + + # == Scenario: A non-active priority is ignored. + client.priorities = [ + { + const.KEY_ACTIVE: False, + const.KEY_VISIBLE: False, + const.KEY_PRIORITY: TEST_PRIORITY, + const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR, + const.KEY_VALUE: {const.KEY_RGB: (100, 100, 100)}, + } + ] + client.visible_priority = None + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) + assert entity_state + assert entity_state.state == "off" + + # == Scenario: A priority with no ... priority ... is ignored. + client.priorities = [ + { + const.KEY_ACTIVE: True, + const.KEY_VISIBLE: True, + const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR, + const.KEY_VALUE: {const.KEY_RGB: (100, 100, 100)}, + } + ] + client.visible_priority = None + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) + assert entity_state + assert entity_state.state == "off" + + +async def test_priority_light_async_updates_off_sets_black( + hass: HomeAssistantType, +) -> None: + """Test turning the HyperionPriorityLight off.""" + client = create_mock_client() + client.priorities = [ + { + const.KEY_ACTIVE: True, + const.KEY_VISIBLE: True, + const.KEY_PRIORITY: TEST_PRIORITY, + const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR, + const.KEY_VALUE: {const.KEY_RGB: (100, 100, 100)}, + } + ] + + with patch( + "homeassistant.components.hyperion.light.HyperionPriorityLight.entity_registry_enabled_default" + ) as enabled_by_default_mock: + enabled_by_default_mock.return_value = True + await setup_test_config_entry(hass, hyperion_client=client) + + client.async_send_clear = AsyncMock(return_value=True) + client.async_send_set_color = AsyncMock(return_value=True) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_PRIORITY_LIGHT_ENTITY_ID_1}, + blocking=True, + ) + + assert client.async_send_clear.call_args == call( + **{ + const.KEY_PRIORITY: TEST_PRIORITY, + } + ) + + assert client.async_send_set_color.call_args == call( + **{ + const.KEY_PRIORITY: TEST_PRIORITY, + const.KEY_COLOR: COLOR_BLACK, + const.KEY_ORIGIN: DEFAULT_ORIGIN, + } + ) + + +async def test_priority_light_prior_color_preserved_after_black( + hass: HomeAssistantType, +) -> None: + """Test that color is preserved in an on->off->on cycle for a HyperionPriorityLight. + + For a HyperionPriorityLight the color black is used to indicate off. This test + ensures that a cycle through 'off' will preserve the original color. + """ + priority_template = { + const.KEY_ACTIVE: True, + const.KEY_VISIBLE: True, + const.KEY_PRIORITY: TEST_PRIORITY, + const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR, + } + + client = create_mock_client() + client.async_send_set_color = AsyncMock(return_value=True) + client.async_send_clear = AsyncMock(return_value=True) + client.priorities = [] + client.visible_priority = None + + with patch( + "homeassistant.components.hyperion.light.HyperionPriorityLight.entity_registry_enabled_default" + ) as enabled_by_default_mock: + enabled_by_default_mock.return_value = True + await setup_test_config_entry(hass, hyperion_client=client) + + # Turn the light on full green... + # On (=), 100% (=), solid (=), [0,0,255] (=) + hs_color = (240.0, 100.0) + rgb_color = (0, 0, 255) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_PRIORITY_LIGHT_ENTITY_ID_1, ATTR_HS_COLOR: hs_color}, + blocking=True, + ) + + assert client.async_send_set_color.call_args == call( + **{ + const.KEY_PRIORITY: TEST_PRIORITY, + const.KEY_COLOR: rgb_color, + const.KEY_ORIGIN: DEFAULT_ORIGIN, + } + ) + + client.priorities = [ + { + **priority_template, + const.KEY_VALUE: {const.KEY_RGB: rgb_color}, + } + ] + client.visible_priority = client.priorities[0] + call_registered_callback(client, "priorities-update") + + entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) + assert entity_state + assert entity_state.state == "on" + assert entity_state.attributes["hs_color"] == hs_color + + # Then turn it off. + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_PRIORITY_LIGHT_ENTITY_ID_1}, + blocking=True, + ) + + assert client.async_send_set_color.call_args == call( + **{ + const.KEY_PRIORITY: TEST_PRIORITY, + const.KEY_COLOR: COLOR_BLACK, + const.KEY_ORIGIN: DEFAULT_ORIGIN, + } + ) + + client.priorities = [ + { + **priority_template, + const.KEY_VALUE: {const.KEY_RGB: COLOR_BLACK}, + } + ] + client.visible_priority = client.priorities[0] + call_registered_callback(client, "priorities-update") + + entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) + assert entity_state + assert entity_state.state == "off" + + # Then turn it back on and ensure it's still green. + # On (=), 100% (=), solid (=), [0,0,255] (=) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_PRIORITY_LIGHT_ENTITY_ID_1}, + blocking=True, + ) + + assert client.async_send_set_color.call_args == call( + **{ + const.KEY_PRIORITY: TEST_PRIORITY, + const.KEY_COLOR: rgb_color, + const.KEY_ORIGIN: DEFAULT_ORIGIN, + } + ) + + client.priorities = [ + { + **priority_template, + const.KEY_VALUE: {const.KEY_RGB: rgb_color}, + } + ] + client.visible_priority = client.priorities[0] + call_registered_callback(client, "priorities-update") + + entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) + assert entity_state + assert entity_state.state == "on" + assert entity_state.attributes["hs_color"] == hs_color + + +async def test_priority_light_has_no_external_sources(hass: HomeAssistantType) -> None: + """Ensure a HyperionPriorityLight does not list external sources.""" + client = create_mock_client() + client.priorities = [] + + with patch( + "homeassistant.components.hyperion.light.HyperionPriorityLight.entity_registry_enabled_default" + ) as enabled_by_default_mock: + enabled_by_default_mock.return_value = True + await setup_test_config_entry(hass, hyperion_client=client) + + entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) + assert entity_state + assert entity_state.attributes["effect_list"] == [hyperion_light.KEY_EFFECT_SOLID] diff --git a/tests/components/hyperion/test_switch.py b/tests/components/hyperion/test_switch.py new file mode 100644 index 00000000000..dcfba9662bf --- /dev/null +++ b/tests/components/hyperion/test_switch.py @@ -0,0 +1,140 @@ +"""Tests for the Hyperion integration.""" +import logging +from unittest.mock import AsyncMock, call, patch + +from hyperion.const import ( + KEY_COMPONENT, + KEY_COMPONENTID_ALL, + KEY_COMPONENTID_BLACKBORDER, + KEY_COMPONENTID_BOBLIGHTSERVER, + KEY_COMPONENTID_FORWARDER, + KEY_COMPONENTID_GRABBER, + KEY_COMPONENTID_LEDDEVICE, + KEY_COMPONENTID_SMOOTHING, + KEY_COMPONENTID_V4L, + KEY_COMPONENTSTATE, + KEY_STATE, +) + +from homeassistant.components.hyperion.const import COMPONENT_TO_NAME +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import slugify + +from . import call_registered_callback, create_mock_client, setup_test_config_entry + +TEST_COMPONENTS = [ + {"enabled": True, "name": "ALL"}, + {"enabled": True, "name": "SMOOTHING"}, + {"enabled": True, "name": "BLACKBORDER"}, + {"enabled": False, "name": "FORWARDER"}, + {"enabled": False, "name": "BOBLIGHTSERVER"}, + {"enabled": False, "name": "GRABBER"}, + {"enabled": False, "name": "V4L"}, + {"enabled": True, "name": "LEDDEVICE"}, +] + +_LOGGER = logging.getLogger(__name__) +TEST_SWITCH_COMPONENT_BASE_ENTITY_ID = "switch.test_instance_1_component" +TEST_SWITCH_COMPONENT_ALL_ENTITY_ID = f"{TEST_SWITCH_COMPONENT_BASE_ENTITY_ID}_all" + + +async def test_switch_turn_on_off(hass: HomeAssistantType) -> None: + """Test turning the light on.""" + client = create_mock_client() + client.async_send_set_component = AsyncMock(return_value=True) + client.components = TEST_COMPONENTS + + # Setup component switch. + with patch( + "homeassistant.components.hyperion.switch.HyperionComponentSwitch.entity_registry_enabled_default" + ) as enabled_by_default_mock: + enabled_by_default_mock.return_value = True + await setup_test_config_entry(hass, hyperion_client=client) + + # Verify switch is on (as per TEST_COMPONENTS above). + entity_state = hass.states.get(TEST_SWITCH_COMPONENT_ALL_ENTITY_ID) + assert entity_state + assert entity_state.state == "on" + + # Turn switch off. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_SWITCH_COMPONENT_ALL_ENTITY_ID}, + blocking=True, + ) + + # Verify correct parameters are passed to the library. + assert client.async_send_set_component.call_args == call( + **{KEY_COMPONENTSTATE: {KEY_COMPONENT: KEY_COMPONENTID_ALL, KEY_STATE: False}} + ) + + client.components[0] = { + "enabled": False, + "name": "ALL", + } + call_registered_callback(client, "components-update") + + # Verify the switch turns off. + entity_state = hass.states.get(TEST_SWITCH_COMPONENT_ALL_ENTITY_ID) + assert entity_state + assert entity_state.state == "off" + + # Turn switch on. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_SWITCH_COMPONENT_ALL_ENTITY_ID}, + blocking=True, + ) + + # Verify correct parameters are passed to the library. + assert client.async_send_set_component.call_args == call( + **{KEY_COMPONENTSTATE: {KEY_COMPONENT: KEY_COMPONENTID_ALL, KEY_STATE: True}} + ) + + client.components[0] = { + "enabled": True, + "name": "ALL", + } + call_registered_callback(client, "components-update") + + # Verify the switch turns on. + entity_state = hass.states.get(TEST_SWITCH_COMPONENT_ALL_ENTITY_ID) + assert entity_state + assert entity_state.state == "on" + + +async def test_switch_has_correct_entities(hass: HomeAssistantType) -> None: + """Test that the correct switch entities are created.""" + client = create_mock_client() + client.components = TEST_COMPONENTS + + # Setup component switch. + with patch( + "homeassistant.components.hyperion.switch.HyperionComponentSwitch.entity_registry_enabled_default" + ) as enabled_by_default_mock: + enabled_by_default_mock.return_value = True + await setup_test_config_entry(hass, hyperion_client=client) + + entity_state = hass.states.get(TEST_SWITCH_COMPONENT_ALL_ENTITY_ID) + + for component in ( + KEY_COMPONENTID_ALL, + KEY_COMPONENTID_SMOOTHING, + KEY_COMPONENTID_BLACKBORDER, + KEY_COMPONENTID_FORWARDER, + KEY_COMPONENTID_BOBLIGHTSERVER, + KEY_COMPONENTID_GRABBER, + KEY_COMPONENTID_LEDDEVICE, + KEY_COMPONENTID_V4L, + ): + entity_id = ( + TEST_SWITCH_COMPONENT_BASE_ENTITY_ID + + "_" + + slugify(COMPONENT_TO_NAME[component]) + ) + entity_state = hass.states.get(entity_id) + assert entity_state, f"Couldn't find entity: {entity_id}"