"""Support for Hyperion-NG remotes.""" from __future__ import annotations from collections.abc import Callable, Mapping, Sequence import functools import logging from types import MappingProxyType from typing import Any from hyperion import client, const from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_EFFECT, ATTR_HS_COLOR, ColorMode, LightEntity, LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util from . import ( get_hyperion_device_id, get_hyperion_unique_id, listen_for_instance_updates, ) from .const import ( CONF_EFFECT_HIDE_LIST, CONF_INSTANCE_CLIENTS, CONF_PRIORITY, DEFAULT_ORIGIN, DEFAULT_PRIORITY, DOMAIN, HYPERION_MANUFACTURER_NAME, HYPERION_MODEL_NAME, SIGNAL_ENTITY_REMOVE, TYPE_HYPERION_LIGHT, ) _LOGGER = logging.getLogger(__name__) CONF_DEFAULT_COLOR = "default_color" CONF_HDMI_PRIORITY = "hdmi_priority" CONF_EFFECT_LIST = "effect_list" # As we want to preserve brightness control for effects (e.g. to reduce the # brightness), we need to persist the effect that is in flight, so # subsequent calls to turn_on will know to keep the effect enabled. # Unfortunately the Home Assistant UI does not easily expose a way to remove a # selected effect (there is no 'No Effect' option by default). Instead, we # create a new fake effect ("Solid") that is always selected by default for # showing a solid color. This is the same method used by WLED. KEY_EFFECT_SOLID = "Solid" DEFAULT_COLOR = [255, 255, 255] DEFAULT_BRIGHTNESS = 255 DEFAULT_EFFECT = KEY_EFFECT_SOLID DEFAULT_NAME = "Hyperion" DEFAULT_PORT = const.DEFAULT_PORT_JSON DEFAULT_HDMI_PRIORITY = 880 DEFAULT_EFFECT_LIST: list[str] = [] ICON_LIGHTBULB = "mdi:lightbulb" ICON_EFFECT = "mdi:lava-lamp" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" entry_data = hass.data[DOMAIN][config_entry.entry_id] server_id = config_entry.unique_id @callback def instance_add(instance_num: int, instance_name: str) -> None: """Add entities for a new Hyperion instance.""" assert server_id args = ( server_id, instance_num, instance_name, config_entry.options, entry_data[CONF_INSTANCE_CLIENTS][instance_num], ) async_add_entities( [ HyperionLight(*args), ] ) @callback def instance_remove(instance_num: int) -> None: """Remove entities for an old Hyperion instance.""" assert server_id async_dispatcher_send( hass, SIGNAL_ENTITY_REMOVE.format( get_hyperion_unique_id(server_id, instance_num, TYPE_HYPERION_LIGHT) ), ) listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) class HyperionLight(LightEntity): """A Hyperion light that acts as a client for the configured priority.""" _attr_color_mode = ColorMode.HS _attr_should_poll = False _attr_supported_color_modes = {ColorMode.HS} _attr_supported_features = LightEntityFeature.EFFECT def __init__( self, server_id: str, instance_num: int, instance_name: str, options: MappingProxyType[str, Any], hyperion_client: client.HyperionClient, ) -> None: """Initialize the light.""" self._unique_id = self._compute_unique_id(server_id, instance_num) self._name = self._compute_name(instance_name) self._device_id = get_hyperion_device_id(server_id, instance_num) self._instance_name = instance_name self._options = options self._client = hyperion_client # Active state representing the Hyperion instance. self._brightness: int = 255 self._rgb_color: Sequence[int] = DEFAULT_COLOR self._effect: str = KEY_EFFECT_SOLID self._static_effect_list: list[str] = [KEY_EFFECT_SOLID] self._effect_list: list[str] = self._static_effect_list[:] self._client_callbacks: Mapping[str, Callable[[dict[str, Any]], None]] = { 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, } def _compute_unique_id(self, server_id: str, instance_num: int) -> str: """Compute a unique id for this instance.""" return get_hyperion_unique_id(server_id, instance_num, TYPE_HYPERION_LIGHT) def _compute_name(self, instance_name: str) -> str: """Compute the name of the light.""" return f"{instance_name}".strip() @property def entity_registry_enabled_default(self) -> bool: """Whether or not the entity is enabled by default.""" return True @property def name(self) -> str: """Return the name of the light.""" return self._name @property def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return self._brightness @property def hs_color(self) -> tuple[float, float]: """Return last color value set.""" return color_util.color_RGB_to_hs(*self._rgb_color) @property def icon(self) -> str: """Return state specific icon.""" if self.is_on: if self.effect != KEY_EFFECT_SOLID: return ICON_EFFECT return ICON_LIGHTBULB @property def effect(self) -> str: """Return the current effect.""" return self._effect @property def effect_list(self) -> list[str]: """Return the list of supported effects.""" return self._effect_list @property def available(self) -> bool: """Return server availability.""" return bool(self._client.has_loaded_state) @property def unique_id(self) -> str: """Return a unique id for this instance.""" return self._unique_id @property def device_info(self) -> DeviceInfo: """Return device information.""" return DeviceInfo( identifiers={(DOMAIN, self._device_id)}, manufacturer=HYPERION_MANUFACTURER_NAME, model=HYPERION_MODEL_NAME, name=self._instance_name, configuration_url=self._client.remote_url, ) def _get_option(self, key: str) -> Any: """Get a value from the provided options.""" defaults = { CONF_PRIORITY: DEFAULT_PRIORITY, CONF_EFFECT_HIDE_LIST: [], } return self._options.get(key, defaults[key]) @property def is_on(self) -> bool: """Return true if light is on. Light is considered on when there is a source at the configured HA priority.""" return self._get_priority_entry_that_dictates_state() is not None async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" # == Get key parameters == if ATTR_EFFECT not in kwargs and ATTR_HS_COLOR in kwargs: effect = KEY_EFFECT_SOLID else: effect = kwargs.get(ATTR_EFFECT, self._effect) rgb_color: Sequence[int] if ATTR_HS_COLOR in kwargs: rgb_color = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) else: rgb_color = self._rgb_color # == Set brightness == if ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] for item in self._client.adjustment or []: if ( const.KEY_ID in item and not await self._client.async_send_set_adjustment( **{ const.KEY_ADJUSTMENT: { const.KEY_BRIGHTNESS: int( round((float(brightness) * 100) / 255) ), const.KEY_ID: item[const.KEY_ID], } } ) ): return # == Set an effect if effect and effect != KEY_EFFECT_SOLID: if not await self._client.async_send_set_effect( **{ const.KEY_PRIORITY: self._get_option(CONF_PRIORITY), const.KEY_EFFECT: {const.KEY_NAME: effect}, const.KEY_ORIGIN: DEFAULT_ORIGIN, } ): return # == Set a color elif not await self._client.async_send_set_color( **{ const.KEY_PRIORITY: self._get_option(CONF_PRIORITY), const.KEY_COLOR: rgb_color, const.KEY_ORIGIN: DEFAULT_ORIGIN, } ): return async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light i.e. clear the configured priority.""" if not await self._client.async_send_clear( **{const.KEY_PRIORITY: self._get_option(CONF_PRIORITY)} ): return def _set_internal_state( self, brightness: int | None = None, rgb_color: Sequence[int] | None = None, effect: str | None = None, ) -> None: """Set the internal state.""" if brightness is not None: self._brightness = brightness if rgb_color is not None: self._rgb_color = rgb_color if effect is not None: self._effect = effect @callback def _update_components(self, _: dict[str, Any] | None = None) -> None: """Update Hyperion components.""" self.async_write_ha_state() @callback def _update_adjustment(self, _: dict[str, Any] | None = None) -> None: """Update Hyperion adjustments.""" if self._client.adjustment: brightness_pct = self._client.adjustment[0].get( const.KEY_BRIGHTNESS, DEFAULT_BRIGHTNESS ) if brightness_pct < 0 or brightness_pct > 100: return self._set_internal_state( brightness=int(round((brightness_pct * 255) / float(100))) ) self.async_write_ha_state() @callback def _update_priorities(self, _: dict[str, Any] | None = None) -> None: """Update Hyperion priorities.""" priority = self._get_priority_entry_that_dictates_state() if priority: component_id = priority.get(const.KEY_COMPONENTID) if component_id == 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=priority[const.KEY_OWNER] ) elif component_id == const.KEY_COMPONENTID_COLOR: self._set_internal_state( rgb_color=priority[const.KEY_VALUE][const.KEY_RGB], effect=KEY_EFFECT_SOLID, ) self.async_write_ha_state() @callback def _update_effect_list(self, _: dict[str, Any] | None = None) -> None: """Update Hyperion effects.""" if not self._client.effects: return effect_list: list[str] = [] hide_effects = self._get_option(CONF_EFFECT_HIDE_LIST) for effect in self._client.effects or []: if const.KEY_NAME in effect: effect_name = effect[const.KEY_NAME] if effect_name not in hide_effects: effect_list.append(effect_name) self._effect_list = [ effect for effect in self._static_effect_list if effect not in hide_effects ] + effect_list self.async_write_ha_state() @callback def _update_full_state(self) -> None: """Update full Hyperion state.""" self._update_adjustment() self._update_priorities() self._update_effect_list() _LOGGER.debug( ( "Hyperion full state update: On=%s,Brightness=%i,Effect=%s " "(%i effects total),Color=%s" ), self.is_on, self._brightness, self._effect, len(self._effect_list), self._rgb_color, ) @callback def _update_client(self, _: dict[str, Any] | None = None) -> None: """Update client connection state.""" self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Register callbacks when entity added to hass.""" self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_ENTITY_REMOVE.format(self.unique_id), functools.partial(self.async_remove, force_remove=True), ) ) self._client.add_callbacks(self._client_callbacks) # Load initial state. self._update_full_state() async def async_will_remove_from_hass(self) -> None: """Cleanup prior to hass removal.""" self._client.remove_callbacks(self._client_callbacks) def _get_priority_entry_that_dictates_state(self) -> dict[str, Any] | None: """Get the relevant Hyperion priority entry to consider.""" # Return whether or not the HA priority is among the active priorities. for priority in self._client.priorities or []: if priority.get(const.KEY_PRIORITY) == self._get_option(CONF_PRIORITY): return priority return None