Add advanced Hyperion entities (#45410)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
Dermot Duffy 2021-01-27 00:35:13 -08:00 committed by GitHub
parent 06ade6129c
commit 890eaf840c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 1213 additions and 219 deletions

View file

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

View file

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

View file

@ -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],
),
]
)
# 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(
@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_INSTANCES_UPDATED.format(config_entry.entry_id),
async_instances_to_entities,
)
SIGNAL_ENTITY_REMOVE.format(
get_hyperion_unique_id(server_id, instance_num, light_type)
),
)
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,
}

View file

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

View file

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

View file

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

View file

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

View file

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