diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 8b89a71783aa..67e582c358c7 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -1,11 +1,10 @@ """Provides functionality to interact with lights.""" -from collections.abc import Iterable import csv import dataclasses import logging import os -from typing import TYPE_CHECKING, Any, Self, cast, final, override +from typing import Any, Self, cast, final, override import probatio from propcache.api import cached_property @@ -19,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.frame import ReportBehavior, report_usage @@ -27,6 +26,28 @@ from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.util import color as color_util from .const import ( # noqa: F401 + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, + ATTR_COLOR_MODE, + ATTR_COLOR_NAME, + ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, + ATTR_EFFECT_LIST, + ATTR_FLASH, + ATTR_HS_COLOR, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_PROFILE, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, + ATTR_SUPPORTED_COLOR_MODES, + ATTR_TRANSITION, + ATTR_WHITE, + ATTR_XY_COLOR, + COLOR_GROUP, COLOR_MODES_BRIGHTNESS, COLOR_MODES_COLOR, DATA_COMPONENT, @@ -34,201 +55,50 @@ from .const import ( # noqa: F401 DEFAULT_MAX_KELVIN, DEFAULT_MIN_KELVIN, DOMAIN, + EFFECT_COLORLOOP, + EFFECT_OFF, + EFFECT_RANDOM, + EFFECT_WHITE, + FLASH_LONG, + FLASH_SHORT, + LIGHT_PROFILES_FILE, + LIGHT_TURN_OFF_SCHEMA, + LIGHT_TURN_ON_SCHEMA, SCAN_INTERVAL, + VALID_BRIGHTNESS, + VALID_BRIGHTNESS_PCT, + VALID_BRIGHTNESS_STEP, + VALID_BRIGHTNESS_STEP_PCT, VALID_COLOR_MODES, + VALID_FLASH, + VALID_TRANSITION, ColorMode, LightEntityCapabilityAttribute, LightEntityFeature, LightEntityStateAttribute, ) +from .helper import ( # noqa: F401 + brightness_supported, + color_supported, + color_temp_supported, + filter_supported_color_modes, + filter_turn_off_params, + filter_turn_on_params, + get_supported_color_modes, + preprocess_turn_on_alternatives, + process_turn_off_params, + process_turn_on_params, + valid_supported_color_modes, +) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE -# Color mode of the light -ATTR_COLOR_MODE = "color_mode" -# List of color modes supported by the light -ATTR_SUPPORTED_COLOR_MODES = "supported_color_modes" - # mypy: disallow-any-generics -def filter_supported_color_modes(color_modes: Iterable[ColorMode]) -> set[ColorMode]: - """Filter the given color modes.""" - color_modes = set(color_modes) - if ( - not color_modes - or ColorMode.UNKNOWN in color_modes - or (ColorMode.WHITE in color_modes and not color_supported(color_modes)) - ): - raise HomeAssistantError - - if ColorMode.ONOFF in color_modes and len(color_modes) > 1: - color_modes.remove(ColorMode.ONOFF) - if ColorMode.BRIGHTNESS in color_modes and len(color_modes) > 1: - color_modes.remove(ColorMode.BRIGHTNESS) - return color_modes - - -def valid_supported_color_modes( - color_modes: Iterable[ColorMode], -) -> set[ColorMode]: - """Validate the given color modes.""" - color_modes = set(color_modes) - if ( - not color_modes - or ColorMode.UNKNOWN in color_modes - or (ColorMode.BRIGHTNESS in color_modes and len(color_modes) > 1) - or (ColorMode.ONOFF in color_modes and len(color_modes) > 1) - or (ColorMode.WHITE in color_modes and not color_supported(color_modes)) - ): - raise probatio.Error(f"Invalid supported_color_modes {sorted(color_modes)}") - return color_modes - - -def brightness_supported(color_modes: Iterable[ColorMode | str] | None) -> bool: - """Test if brightness is supported.""" - if not color_modes: - return False - return not COLOR_MODES_BRIGHTNESS.isdisjoint(color_modes) - - -def color_supported(color_modes: Iterable[ColorMode | str] | None) -> bool: - """Test if color is supported.""" - if not color_modes: - return False - return not COLOR_MODES_COLOR.isdisjoint(color_modes) - - -def color_temp_supported(color_modes: Iterable[ColorMode | str] | None) -> bool: - """Test if color temperature is supported.""" - if not color_modes: - return False - return ColorMode.COLOR_TEMP in color_modes - - -def get_supported_color_modes(hass: HomeAssistant, entity_id: str) -> set[str] | None: - """Get supported color modes for a light entity. - - First try the statemachine, then entity registry. - This is the equivalent of entity helper get_supported_features. - """ - if state := hass.states.get(entity_id): - return state.attributes.get( - LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES - ) - - entity_registry = er.async_get(hass) - if not (entry := entity_registry.async_get(entity_id)): - raise HomeAssistantError(f"Unknown entity {entity_id}") - if not entry.capabilities: - return None - - return entry.capabilities.get(LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES) - - -# Float that represents transition time in seconds to make change. -ATTR_TRANSITION = "transition" - -# Lists holding color values -ATTR_RGB_COLOR = "rgb_color" -ATTR_RGBW_COLOR = "rgbw_color" -ATTR_RGBWW_COLOR = "rgbww_color" -ATTR_XY_COLOR = "xy_color" -ATTR_HS_COLOR = "hs_color" -ATTR_COLOR_TEMP_KELVIN = "color_temp_kelvin" -ATTR_MIN_COLOR_TEMP_KELVIN = "min_color_temp_kelvin" -ATTR_MAX_COLOR_TEMP_KELVIN = "max_color_temp_kelvin" -ATTR_COLOR_NAME = "color_name" -ATTR_WHITE = "white" - -# Brightness of the light, 0..255 or percentage -ATTR_BRIGHTNESS = "brightness" -ATTR_BRIGHTNESS_PCT = "brightness_pct" -ATTR_BRIGHTNESS_STEP = "brightness_step" -ATTR_BRIGHTNESS_STEP_PCT = "brightness_step_pct" - -# String representing a profile (built-in ones or external defined). -ATTR_PROFILE = "profile" - -# If the light should flash, can be FLASH_SHORT or FLASH_LONG. -ATTR_FLASH = "flash" -FLASH_SHORT = "short" -FLASH_LONG = "long" - -# List of possible effects -ATTR_EFFECT_LIST = "effect_list" - -# Apply an effect to the light, can be EFFECT_COLORLOOP. -ATTR_EFFECT = "effect" -EFFECT_COLORLOOP = "colorloop" -EFFECT_OFF = "off" -EFFECT_RANDOM = "random" -EFFECT_WHITE = "white" - -COLOR_GROUP = "Color descriptors" - -LIGHT_PROFILES_FILE = "light_profiles.csv" - -# Service call validation schemas -VALID_TRANSITION = probatio.All(probatio.Coerce(float), probatio.Clamp(min=0, max=6553)) -VALID_BRIGHTNESS = probatio.All(probatio.Coerce(int), probatio.Clamp(min=0, max=255)) -VALID_BRIGHTNESS_PCT = probatio.All( - probatio.Coerce(float), probatio.Range(min=0, max=100) -) -VALID_BRIGHTNESS_STEP = probatio.All( - probatio.Coerce(int), probatio.Clamp(min=-255, max=255) -) -VALID_BRIGHTNESS_STEP_PCT = probatio.All( - probatio.Coerce(float), probatio.Clamp(min=-100, max=100) -) -VALID_FLASH = probatio.In([FLASH_SHORT, FLASH_LONG]) - -LIGHT_TURN_ON_SCHEMA: VolDictType = { - probatio.Exclusive(ATTR_PROFILE, COLOR_GROUP): cv.string, - ATTR_TRANSITION: VALID_TRANSITION, - probatio.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS, - probatio.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT, - probatio.Exclusive(ATTR_BRIGHTNESS_STEP, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP, - probatio.Exclusive( - ATTR_BRIGHTNESS_STEP_PCT, ATTR_BRIGHTNESS - ): VALID_BRIGHTNESS_STEP_PCT, - probatio.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string, - probatio.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): cv.positive_int, - probatio.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): probatio.All( - probatio.Coerce(tuple), - probatio.ExactSequence( - ( - probatio.All(probatio.Coerce(float), probatio.Range(min=0, max=360)), - probatio.All(probatio.Coerce(float), probatio.Range(min=0, max=100)), - ) - ), - ), - probatio.Exclusive(ATTR_RGB_COLOR, COLOR_GROUP): probatio.All( - probatio.Coerce(tuple), probatio.ExactSequence((cv.byte,) * 3) - ), - probatio.Exclusive(ATTR_RGBW_COLOR, COLOR_GROUP): probatio.All( - probatio.Coerce(tuple), probatio.ExactSequence((cv.byte,) * 4) - ), - probatio.Exclusive(ATTR_RGBWW_COLOR, COLOR_GROUP): probatio.All( - probatio.Coerce(tuple), probatio.ExactSequence((cv.byte,) * 5) - ), - probatio.Exclusive(ATTR_XY_COLOR, COLOR_GROUP): probatio.All( - probatio.Coerce(tuple), probatio.ExactSequence((cv.small_float, cv.small_float)) - ), - probatio.Exclusive(ATTR_WHITE, COLOR_GROUP): probatio.Any(True, VALID_BRIGHTNESS), - ATTR_FLASH: VALID_FLASH, - ATTR_EFFECT: cv.string, -} - -LIGHT_TURN_OFF_SCHEMA: VolDictType = { - ATTR_TRANSITION: VALID_TRANSITION, - ATTR_FLASH: VALID_FLASH, -} - - _LOGGER = logging.getLogger(__name__) @@ -237,259 +107,6 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: return hass.states.is_state(entity_id, STATE_ON) -def preprocess_turn_on_alternatives( - hass: HomeAssistant, params: dict[str, Any] -) -> None: - """Process extra data for turn light on request. - - Async friendly. - """ - # Bail out, we process this later. - if ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params: - return - - if ATTR_PROFILE in params: - hass.data[DATA_PROFILES].apply_profile(params.pop(ATTR_PROFILE), params) - - if (color_name := params.pop(ATTR_COLOR_NAME, None)) is not None: - try: - params[ATTR_RGB_COLOR] = tuple(color_util.color_name_to_rgb(color_name)) - except ValueError: - _LOGGER.warning("Got unknown color %s, falling back to white", color_name) - params[ATTR_RGB_COLOR] = (255, 255, 255) - - brightness_pct = params.pop(ATTR_BRIGHTNESS_PCT, None) - if brightness_pct is not None: - params[ATTR_BRIGHTNESS] = round(255 * brightness_pct / 100) - - -def filter_turn_off_params( - light: LightEntity, params: dict[str, Any] -) -> dict[str, Any]: - """Filter out params not used in turn off or not supported by the light.""" - if not params: - return params - - supported_features = light.supported_features - - if LightEntityFeature.FLASH not in supported_features: - params.pop(ATTR_FLASH, None) - if LightEntityFeature.TRANSITION not in supported_features: - params.pop(ATTR_TRANSITION, None) - - return {k: v for k, v in params.items() if k in (ATTR_TRANSITION, ATTR_FLASH)} - - -def process_turn_off_params( - hass: HomeAssistant, light: LightEntity, params: dict[str, Any] -) -> dict[str, Any]: - """Process light turn off params.""" - params = dict(params) - - if ATTR_TRANSITION not in params: - hass.data[DATA_PROFILES].apply_default(light.entity_id, True, params) - - return params - - -def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]: - """Filter out params not supported by the light.""" - supported_features = light.supported_features - - if LightEntityFeature.EFFECT not in supported_features: - params.pop(ATTR_EFFECT, None) - if LightEntityFeature.FLASH not in supported_features: - params.pop(ATTR_FLASH, None) - if LightEntityFeature.TRANSITION not in supported_features: - params.pop(ATTR_TRANSITION, None) - - supported_color_modes = ( - light._light_internal_supported_color_modes # noqa: SLF001 - ) - if not brightness_supported(supported_color_modes): - params.pop(ATTR_BRIGHTNESS, None) - if ColorMode.COLOR_TEMP not in supported_color_modes: - params.pop(ATTR_COLOR_TEMP_KELVIN, None) - if ColorMode.HS not in supported_color_modes: - params.pop(ATTR_HS_COLOR, None) - if ColorMode.RGB not in supported_color_modes: - params.pop(ATTR_RGB_COLOR, None) - if ColorMode.RGBW not in supported_color_modes: - params.pop(ATTR_RGBW_COLOR, None) - if ColorMode.RGBWW not in supported_color_modes: - params.pop(ATTR_RGBWW_COLOR, None) - if ColorMode.WHITE not in supported_color_modes: - params.pop(ATTR_WHITE, None) - if ColorMode.XY not in supported_color_modes: - params.pop(ATTR_XY_COLOR, None) - - return params - - -def process_turn_on_params( # noqa: C901 - hass: HomeAssistant, light: LightEntity, params: dict[str, Any] -) -> dict[str, Any]: - """Process light turn on params.""" - params = dict(params) - - # Only process params once we processed brightness step - if params and ( - ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params - ): - brightness = light.brightness if light.is_on and light.brightness else 0 - - if ATTR_BRIGHTNESS_STEP in params: - brightness += params.pop(ATTR_BRIGHTNESS_STEP) - - else: - brightness_pct = round(brightness / 255 * 100) - brightness = round( - (brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255 - ) - - params[ATTR_BRIGHTNESS] = max(0, min(255, brightness)) - - preprocess_turn_on_alternatives(hass, params) - - if (not params or not light.is_on) or (params and ATTR_TRANSITION not in params): - hass.data[DATA_PROFILES].apply_default(light.entity_id, light.is_on, params) - - supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001 - - # If a color temperature is specified, emulate it if not supported by the light - if ATTR_COLOR_TEMP_KELVIN in params: - if ( - ColorMode.COLOR_TEMP not in supported_color_modes - and ColorMode.RGBWW in supported_color_modes - ): - color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) - brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness)) - params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww( - color_temp, - brightness, - light.min_color_temp_kelvin, - light.max_color_temp_kelvin, - ) - elif ColorMode.COLOR_TEMP not in supported_color_modes: - color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) - if color_supported(supported_color_modes): - params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(color_temp) - - # If a color is specified, convert to the color space supported by the light - rgb_color: tuple[int, int, int] | None - rgbww_color: tuple[int, int, int, int, int] | None - if ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes: - hs_color = params.pop(ATTR_HS_COLOR) - if ColorMode.RGB in supported_color_modes: - params[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color) - elif ColorMode.RGBW in supported_color_modes: - rgb_color = color_util.color_hs_to_RGB(*hs_color) - params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) - elif ColorMode.RGBWW in supported_color_modes: - rgb_color = color_util.color_hs_to_RGB(*hs_color) - params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( - *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin - ) - elif ColorMode.XY in supported_color_modes: - params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) - elif ColorMode.COLOR_TEMP in supported_color_modes: - xy_color = color_util.color_hs_to_xy(*hs_color) - params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( - *xy_color - ) - elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes: - rgb_color = params.pop(ATTR_RGB_COLOR) - assert rgb_color is not None - if TYPE_CHECKING: - rgb_color = cast(tuple[int, int, int], rgb_color) - if ColorMode.RGBW in supported_color_modes: - params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) - elif ColorMode.RGBWW in supported_color_modes: - params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( - *rgb_color, - light.min_color_temp_kelvin, - light.max_color_temp_kelvin, - ) - elif ColorMode.HS in supported_color_modes: - params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) - elif ColorMode.XY in supported_color_modes: - params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) - elif ColorMode.COLOR_TEMP in supported_color_modes: - xy_color = color_util.color_RGB_to_xy(*rgb_color) - params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( - *xy_color - ) - elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes: - xy_color = params.pop(ATTR_XY_COLOR) - if ColorMode.HS in supported_color_modes: - params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color) - elif ColorMode.RGB in supported_color_modes: - params[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color) - elif ColorMode.RGBW in supported_color_modes: - rgb_color = color_util.color_xy_to_RGB(*xy_color) - params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) - elif ColorMode.RGBWW in supported_color_modes: - rgb_color = color_util.color_xy_to_RGB(*xy_color) - params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( - *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin - ) - elif ColorMode.COLOR_TEMP in supported_color_modes: - params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( - *xy_color - ) - elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes: - rgbw_color = params.pop(ATTR_RGBW_COLOR) - rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color) - if ColorMode.RGB in supported_color_modes: - params[ATTR_RGB_COLOR] = rgb_color - elif ColorMode.RGBWW in supported_color_modes: - params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( - *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin - ) - elif ColorMode.HS in supported_color_modes: - params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) - elif ColorMode.XY in supported_color_modes: - params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) - elif ColorMode.COLOR_TEMP in supported_color_modes: - xy_color = color_util.color_RGB_to_xy(*rgb_color) - params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( - *xy_color - ) - elif ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes: - rgbww_color = params.pop(ATTR_RGBWW_COLOR) - assert rgbww_color is not None - if TYPE_CHECKING: - rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color) - rgb_color = color_util.color_rgbww_to_rgb( - *rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin - ) - if ColorMode.RGB in supported_color_modes: - params[ATTR_RGB_COLOR] = rgb_color - elif ColorMode.RGBW in supported_color_modes: - params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) - elif ColorMode.HS in supported_color_modes: - params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) - elif ColorMode.XY in supported_color_modes: - params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) - elif ColorMode.COLOR_TEMP in supported_color_modes: - xy_color = color_util.color_RGB_to_xy(*rgb_color) - params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( - *xy_color - ) - - # If white is set to True, set it to the light's brightness - # Add a warning in Home Assistant Core 2024.3 if the brightness is set to an - # integer. - if params.get(ATTR_WHITE) is True: - params[ATTR_WHITE] = light.brightness - - # If both white and brightness are specified, override white - if ATTR_WHITE in params and ColorMode.WHITE in supported_color_modes: - params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE]) - - return params - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Expose light control via state machine and services.""" component = hass.data[DATA_COMPONENT] = EntityComponent[LightEntity]( diff --git a/homeassistant/components/light/const.py b/homeassistant/components/light/const.py index b0e6a8b8c737..2ca86c794b58 100644 --- a/homeassistant/components/light/const.py +++ b/homeassistant/components/light/const.py @@ -4,6 +4,10 @@ from datetime import timedelta from enum import IntFlag, StrEnum from typing import TYPE_CHECKING, Final +import probatio + +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import VolDictType from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: @@ -92,3 +96,108 @@ COLOR_MODES_COLOR = { # https://developers.meethue.com/documentation/core-concepts DEFAULT_MIN_KELVIN = 2000 # 500 mireds DEFAULT_MAX_KELVIN = 6535 # 153 mireds + + +# Color mode of the light +ATTR_COLOR_MODE = "color_mode" +# List of color modes supported by the light +ATTR_SUPPORTED_COLOR_MODES = "supported_color_modes" + +# Float that represents transition time in seconds to make change. +ATTR_TRANSITION = "transition" + +# Lists holding color values +ATTR_RGB_COLOR = "rgb_color" +ATTR_RGBW_COLOR = "rgbw_color" +ATTR_RGBWW_COLOR = "rgbww_color" +ATTR_XY_COLOR = "xy_color" +ATTR_HS_COLOR = "hs_color" +ATTR_COLOR_TEMP_KELVIN = "color_temp_kelvin" +ATTR_MIN_COLOR_TEMP_KELVIN = "min_color_temp_kelvin" +ATTR_MAX_COLOR_TEMP_KELVIN = "max_color_temp_kelvin" +ATTR_COLOR_NAME = "color_name" +ATTR_WHITE = "white" + +# Brightness of the light, 0..255 or percentage +ATTR_BRIGHTNESS = "brightness" +ATTR_BRIGHTNESS_PCT = "brightness_pct" +ATTR_BRIGHTNESS_STEP = "brightness_step" +ATTR_BRIGHTNESS_STEP_PCT = "brightness_step_pct" + +# String representing a profile (built-in ones or external defined). +ATTR_PROFILE = "profile" + +# If the light should flash, can be FLASH_SHORT or FLASH_LONG. +ATTR_FLASH = "flash" +FLASH_SHORT = "short" +FLASH_LONG = "long" + +# List of possible effects +ATTR_EFFECT_LIST = "effect_list" + +# Apply an effect to the light, can be EFFECT_COLORLOOP. +ATTR_EFFECT = "effect" +EFFECT_COLORLOOP = "colorloop" +EFFECT_OFF = "off" +EFFECT_RANDOM = "random" +EFFECT_WHITE = "white" + +COLOR_GROUP = "Color descriptors" + +LIGHT_PROFILES_FILE = "light_profiles.csv" + +# Service call validation schemas +VALID_TRANSITION = probatio.All(probatio.Coerce(float), probatio.Clamp(min=0, max=6553)) +VALID_BRIGHTNESS = probatio.All(probatio.Coerce(int), probatio.Clamp(min=0, max=255)) +VALID_BRIGHTNESS_PCT = probatio.All( + probatio.Coerce(float), probatio.Range(min=0, max=100) +) +VALID_BRIGHTNESS_STEP = probatio.All( + probatio.Coerce(int), probatio.Clamp(min=-255, max=255) +) +VALID_BRIGHTNESS_STEP_PCT = probatio.All( + probatio.Coerce(float), probatio.Clamp(min=-100, max=100) +) +VALID_FLASH = probatio.In([FLASH_SHORT, FLASH_LONG]) + +LIGHT_TURN_ON_SCHEMA: VolDictType = { + probatio.Exclusive(ATTR_PROFILE, COLOR_GROUP): cv.string, + ATTR_TRANSITION: VALID_TRANSITION, + probatio.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS, + probatio.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT, + probatio.Exclusive(ATTR_BRIGHTNESS_STEP, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP, + probatio.Exclusive( + ATTR_BRIGHTNESS_STEP_PCT, ATTR_BRIGHTNESS + ): VALID_BRIGHTNESS_STEP_PCT, + probatio.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string, + probatio.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): cv.positive_int, + probatio.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): probatio.All( + probatio.Coerce(tuple), + probatio.ExactSequence( + ( + probatio.All(probatio.Coerce(float), probatio.Range(min=0, max=360)), + probatio.All(probatio.Coerce(float), probatio.Range(min=0, max=100)), + ) + ), + ), + probatio.Exclusive(ATTR_RGB_COLOR, COLOR_GROUP): probatio.All( + probatio.Coerce(tuple), probatio.ExactSequence((cv.byte,) * 3) + ), + probatio.Exclusive(ATTR_RGBW_COLOR, COLOR_GROUP): probatio.All( + probatio.Coerce(tuple), probatio.ExactSequence((cv.byte,) * 4) + ), + probatio.Exclusive(ATTR_RGBWW_COLOR, COLOR_GROUP): probatio.All( + probatio.Coerce(tuple), probatio.ExactSequence((cv.byte,) * 5) + ), + probatio.Exclusive(ATTR_XY_COLOR, COLOR_GROUP): probatio.All( + probatio.Coerce(tuple), probatio.ExactSequence((cv.small_float, cv.small_float)) + ), + probatio.Exclusive(ATTR_WHITE, COLOR_GROUP): probatio.Any(True, VALID_BRIGHTNESS), + ATTR_FLASH: VALID_FLASH, + ATTR_EFFECT: cv.string, +} + +LIGHT_TURN_OFF_SCHEMA: VolDictType = { + ATTR_TRANSITION: VALID_TRANSITION, + ATTR_FLASH: VALID_FLASH, +} diff --git a/homeassistant/components/light/helper.py b/homeassistant/components/light/helper.py new file mode 100644 index 000000000000..5da0ff6edf8c --- /dev/null +++ b/homeassistant/components/light/helper.py @@ -0,0 +1,369 @@ +"""Helper functions for the light integration.""" + +from collections.abc import Iterable +import logging +from typing import TYPE_CHECKING, Any, cast + +import probatio + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.util import color as color_util + +from .const import ( + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, + ATTR_COLOR_NAME, + ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, + ATTR_FLASH, + ATTR_HS_COLOR, + ATTR_PROFILE, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, + ATTR_TRANSITION, + ATTR_WHITE, + ATTR_XY_COLOR, + COLOR_MODES_BRIGHTNESS, + COLOR_MODES_COLOR, + DATA_PROFILES, + ColorMode, + LightEntityCapabilityAttribute, + LightEntityFeature, +) + +if TYPE_CHECKING: + from . import LightEntity + +_LOGGER = logging.getLogger(__name__) + + +def filter_supported_color_modes(color_modes: Iterable[ColorMode]) -> set[ColorMode]: + """Filter the given color modes.""" + color_modes = set(color_modes) + if ( + not color_modes + or ColorMode.UNKNOWN in color_modes + or (ColorMode.WHITE in color_modes and not color_supported(color_modes)) + ): + raise HomeAssistantError + + if ColorMode.ONOFF in color_modes and len(color_modes) > 1: + color_modes.remove(ColorMode.ONOFF) + if ColorMode.BRIGHTNESS in color_modes and len(color_modes) > 1: + color_modes.remove(ColorMode.BRIGHTNESS) + return color_modes + + +def valid_supported_color_modes( + color_modes: Iterable[ColorMode], +) -> set[ColorMode]: + """Validate the given color modes.""" + color_modes = set(color_modes) + if ( + not color_modes + or ColorMode.UNKNOWN in color_modes + or (ColorMode.BRIGHTNESS in color_modes and len(color_modes) > 1) + or (ColorMode.ONOFF in color_modes and len(color_modes) > 1) + or (ColorMode.WHITE in color_modes and not color_supported(color_modes)) + ): + raise probatio.Error(f"Invalid supported_color_modes {sorted(color_modes)}") + return color_modes + + +def brightness_supported(color_modes: Iterable[ColorMode | str] | None) -> bool: + """Test if brightness is supported.""" + if not color_modes: + return False + return not COLOR_MODES_BRIGHTNESS.isdisjoint(color_modes) + + +def color_supported(color_modes: Iterable[ColorMode | str] | None) -> bool: + """Test if color is supported.""" + if not color_modes: + return False + return not COLOR_MODES_COLOR.isdisjoint(color_modes) + + +def color_temp_supported(color_modes: Iterable[ColorMode | str] | None) -> bool: + """Test if color temperature is supported.""" + if not color_modes: + return False + return ColorMode.COLOR_TEMP in color_modes + + +def get_supported_color_modes(hass: HomeAssistant, entity_id: str) -> set[str] | None: + """Get supported color modes for a light entity. + + First try the statemachine, then entity registry. + This is the equivalent of entity helper get_supported_features. + """ + if state := hass.states.get(entity_id): + return state.attributes.get( + LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES + ) + + entity_registry = er.async_get(hass) + if not (entry := entity_registry.async_get(entity_id)): + raise HomeAssistantError(f"Unknown entity {entity_id}") + if not entry.capabilities: + return None + + return entry.capabilities.get(LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES) + + +def preprocess_turn_on_alternatives( + hass: HomeAssistant, params: dict[str, Any] +) -> None: + """Process extra data for turn light on request. + + Async friendly. + """ + # Bail out, we process this later. + if ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params: + return + + if ATTR_PROFILE in params: + hass.data[DATA_PROFILES].apply_profile(params.pop(ATTR_PROFILE), params) + + if (color_name := params.pop(ATTR_COLOR_NAME, None)) is not None: + try: + params[ATTR_RGB_COLOR] = tuple(color_util.color_name_to_rgb(color_name)) + except ValueError: + _LOGGER.warning("Got unknown color %s, falling back to white", color_name) + params[ATTR_RGB_COLOR] = (255, 255, 255) + + brightness_pct = params.pop(ATTR_BRIGHTNESS_PCT, None) + if brightness_pct is not None: + params[ATTR_BRIGHTNESS] = round(255 * brightness_pct / 100) + + +def filter_turn_off_params( + light: LightEntity, params: dict[str, Any] +) -> dict[str, Any]: + """Filter out params not used in turn off or not supported by the light.""" + if not params: + return params + + supported_features = light.supported_features + + if LightEntityFeature.FLASH not in supported_features: + params.pop(ATTR_FLASH, None) + if LightEntityFeature.TRANSITION not in supported_features: + params.pop(ATTR_TRANSITION, None) + + return {k: v for k, v in params.items() if k in (ATTR_TRANSITION, ATTR_FLASH)} + + +def process_turn_off_params( + hass: HomeAssistant, light: LightEntity, params: dict[str, Any] +) -> dict[str, Any]: + """Process light turn off params.""" + params = dict(params) + + if ATTR_TRANSITION not in params: + hass.data[DATA_PROFILES].apply_default(light.entity_id, True, params) + + return params + + +def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]: + """Filter out params not supported by the light.""" + supported_features = light.supported_features + + if LightEntityFeature.EFFECT not in supported_features: + params.pop(ATTR_EFFECT, None) + if LightEntityFeature.FLASH not in supported_features: + params.pop(ATTR_FLASH, None) + if LightEntityFeature.TRANSITION not in supported_features: + params.pop(ATTR_TRANSITION, None) + + supported_color_modes = ( + light._light_internal_supported_color_modes # noqa: SLF001 + ) + if not brightness_supported(supported_color_modes): + params.pop(ATTR_BRIGHTNESS, None) + if ColorMode.COLOR_TEMP not in supported_color_modes: + params.pop(ATTR_COLOR_TEMP_KELVIN, None) + if ColorMode.HS not in supported_color_modes: + params.pop(ATTR_HS_COLOR, None) + if ColorMode.RGB not in supported_color_modes: + params.pop(ATTR_RGB_COLOR, None) + if ColorMode.RGBW not in supported_color_modes: + params.pop(ATTR_RGBW_COLOR, None) + if ColorMode.RGBWW not in supported_color_modes: + params.pop(ATTR_RGBWW_COLOR, None) + if ColorMode.WHITE not in supported_color_modes: + params.pop(ATTR_WHITE, None) + if ColorMode.XY not in supported_color_modes: + params.pop(ATTR_XY_COLOR, None) + + return params + + +def process_turn_on_params( # noqa: C901 + hass: HomeAssistant, light: LightEntity, params: dict[str, Any] +) -> dict[str, Any]: + """Process light turn on params.""" + params = dict(params) + + # Only process params once we processed brightness step + if params and ( + ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params + ): + brightness = light.brightness if light.is_on and light.brightness else 0 + + if ATTR_BRIGHTNESS_STEP in params: + brightness += params.pop(ATTR_BRIGHTNESS_STEP) + + else: + brightness_pct = round(brightness / 255 * 100) + brightness = round( + (brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255 + ) + + params[ATTR_BRIGHTNESS] = max(0, min(255, brightness)) + + preprocess_turn_on_alternatives(hass, params) + + if (not params or not light.is_on) or (params and ATTR_TRANSITION not in params): + hass.data[DATA_PROFILES].apply_default(light.entity_id, light.is_on, params) + + supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001 + + # If a color temperature is specified, emulate it if not supported by the light + if ATTR_COLOR_TEMP_KELVIN in params: + if ( + ColorMode.COLOR_TEMP not in supported_color_modes + and ColorMode.RGBWW in supported_color_modes + ): + color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) + brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness)) + params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww( + color_temp, + brightness, + light.min_color_temp_kelvin, + light.max_color_temp_kelvin, + ) + elif ColorMode.COLOR_TEMP not in supported_color_modes: + color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) + if color_supported(supported_color_modes): + params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(color_temp) + + # If a color is specified, convert to the color space supported by the light + rgb_color: tuple[int, int, int] | None + rgbww_color: tuple[int, int, int, int, int] | None + if ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes: + hs_color = params.pop(ATTR_HS_COLOR) + if ColorMode.RGB in supported_color_modes: + params[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color) + elif ColorMode.RGBW in supported_color_modes: + rgb_color = color_util.color_hs_to_RGB(*hs_color) + params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) + elif ColorMode.RGBWW in supported_color_modes: + rgb_color = color_util.color_hs_to_RGB(*hs_color) + params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( + *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin + ) + elif ColorMode.XY in supported_color_modes: + params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_hs_to_xy(*hs_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes: + rgb_color = params.pop(ATTR_RGB_COLOR) + assert rgb_color is not None + if TYPE_CHECKING: + rgb_color = cast(tuple[int, int, int], rgb_color) + if ColorMode.RGBW in supported_color_modes: + params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) + elif ColorMode.RGBWW in supported_color_modes: + params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( + *rgb_color, + light.min_color_temp_kelvin, + light.max_color_temp_kelvin, + ) + elif ColorMode.HS in supported_color_modes: + params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) + elif ColorMode.XY in supported_color_modes: + params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes: + xy_color = params.pop(ATTR_XY_COLOR) + if ColorMode.HS in supported_color_modes: + params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color) + elif ColorMode.RGB in supported_color_modes: + params[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color) + elif ColorMode.RGBW in supported_color_modes: + rgb_color = color_util.color_xy_to_RGB(*xy_color) + params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) + elif ColorMode.RGBWW in supported_color_modes: + rgb_color = color_util.color_xy_to_RGB(*xy_color) + params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( + *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin + ) + elif ColorMode.COLOR_TEMP in supported_color_modes: + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes: + rgbw_color = params.pop(ATTR_RGBW_COLOR) + rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color) + if ColorMode.RGB in supported_color_modes: + params[ATTR_RGB_COLOR] = rgb_color + elif ColorMode.RGBWW in supported_color_modes: + params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( + *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin + ) + elif ColorMode.HS in supported_color_modes: + params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) + elif ColorMode.XY in supported_color_modes: + params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + elif ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes: + rgbww_color = params.pop(ATTR_RGBWW_COLOR) + assert rgbww_color is not None + if TYPE_CHECKING: + rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color) + rgb_color = color_util.color_rgbww_to_rgb( + *rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin + ) + if ColorMode.RGB in supported_color_modes: + params[ATTR_RGB_COLOR] = rgb_color + elif ColorMode.RGBW in supported_color_modes: + params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) + elif ColorMode.HS in supported_color_modes: + params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) + elif ColorMode.XY in supported_color_modes: + params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + + # If white is set to True, set it to the light's brightness + # Add a warning in Home Assistant Core 2024.3 if the brightness is set to an + # integer. + if params.get(ATTR_WHITE) is True: + params[ATTR_WHITE] = light.brightness + + # If both white and brightness are specified, override white + if ATTR_WHITE in params and ColorMode.WHITE in supported_color_modes: + params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE]) + + return params