core/homeassistant/components/hue/v2/light.py

331 lines
14 KiB
Python

"""Support for Hue lights."""
from __future__ import annotations
from functools import partial
from typing import Any
from aiohue import HueBridgeV2
from aiohue.v2.controllers.events import EventType
from aiohue.v2.controllers.lights import LightsController
from aiohue.v2.models.feature import EffectStatus, TimedEffectStatus
from aiohue.v2.models.light import Light
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_FLASH,
ATTR_TRANSITION,
ATTR_XY_COLOR,
EFFECT_OFF,
FLASH_SHORT,
ColorMode,
LightEntity,
LightEntityDescription,
LightEntityFeature,
filter_supported_color_modes,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.util import color as color_util
from ..bridge import HueBridge, HueConfigEntry
from ..const import DOMAIN
from .entity import HueBaseEntity
from .helpers import (
normalize_hue_brightness,
normalize_hue_colortemp,
normalize_hue_transition,
)
FALLBACK_MIN_MIREDS = 153 # hue default for most lights
FALLBACK_MAX_MIREDS = 500 # hue default for most lights
FALLBACK_KELVIN = 5800 # halfway
# HA 2025.4 replaced the deprecated effect "None" with HA default "off"
DEPRECATED_EFFECT_NONE = "None"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HueConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hue Light from Config Entry."""
bridge = config_entry.runtime_data
api: HueBridgeV2 = bridge.api
controller: LightsController = api.lights
make_light_entity = partial(HueLight, bridge, controller)
@callback
def async_add_light(event_type: EventType, resource: Light) -> None:
"""Add Hue Light."""
async_add_entities([make_light_entity(resource)])
# add all current items in controller
async_add_entities(make_light_entity(light) for light in controller)
# register listener for new lights
config_entry.async_on_unload(
controller.subscribe(async_add_light, event_filter=EventType.RESOURCE_ADDED)
)
# pylint: disable-next=hass-enforce-class-module
class HueLight(HueBaseEntity, LightEntity):
"""Representation of a Hue light."""
_fixed_color_mode: ColorMode | None = None
entity_description = LightEntityDescription(
key="hue_light", translation_key="hue_light", has_entity_name=True, name=None
)
def __init__(
self, bridge: HueBridge, controller: LightsController, resource: Light
) -> None:
"""Initialize the light."""
super().__init__(bridge, controller, resource)
if self.resource.alert and self.resource.alert.action_values:
self._attr_supported_features |= LightEntityFeature.FLASH
self.resource = resource
self.controller = controller
supported_color_modes = {ColorMode.ONOFF}
if self.resource.supports_color:
supported_color_modes.add(ColorMode.XY)
if self.resource.supports_color_temperature:
supported_color_modes.add(ColorMode.COLOR_TEMP)
if self.resource.supports_dimming:
supported_color_modes.add(ColorMode.BRIGHTNESS)
# support transition if brightness control
self._attr_supported_features |= LightEntityFeature.TRANSITION
supported_color_modes = filter_supported_color_modes(supported_color_modes)
self._attr_supported_color_modes = supported_color_modes
if len(self._attr_supported_color_modes) == 1:
# If the light supports only a single color mode, set it now
self._fixed_color_mode = next(iter(self._attr_supported_color_modes))
self._last_brightness: float | None = None
self._color_temp_active: bool = False
# get list of supported effects (combine effects and timed_effects)
self._attr_effect_list = []
if effects := resource.effects:
self._attr_effect_list = [
x.value
for x in effects.status_values
if x not in (EffectStatus.NO_EFFECT, EffectStatus.UNKNOWN)
]
if timed_effects := resource.timed_effects:
self._attr_effect_list += [
x.value
for x in timed_effects.status_values
if x != TimedEffectStatus.NO_EFFECT
]
if len(self._attr_effect_list) > 0:
self._attr_effect_list.insert(0, EFFECT_OFF)
self._attr_supported_features |= LightEntityFeature.EFFECT
@property
def brightness(self) -> int | None:
"""Return the brightness of this light between 0..255."""
if dimming := self.resource.dimming:
# Hue uses a range of [0, 100] to control brightness.
return round((dimming.brightness / 100) * 255)
return None
@property
def is_on(self) -> bool:
"""Return true if device is on (brightness above 0)."""
return self.resource.on.on
@property
def color_mode(self) -> ColorMode:
"""Return the color mode of the light."""
if self._fixed_color_mode:
# The light supports only a single color mode, return it
return self._fixed_color_mode
# The light supports both color temperature and XY, determine which
# mode the light is in
if self.color_temp_active:
return ColorMode.COLOR_TEMP
return ColorMode.XY
@property
def color_temp_active(self) -> bool:
"""Return if the light is in Color Temperature mode."""
color_temp = self.resource.color_temperature
if color_temp is None or color_temp.mirek is None:
return False
# Official Hue lights return `mirek_valid` to indicate CT is active
# while non-official lights do not.
if self.device.product_data.certified:
return self.resource.color_temperature.mirek_valid
return self._color_temp_active
@property
def xy_color(self) -> tuple[float, float] | None:
"""Return the xy color."""
if color := self.resource.color:
return (color.xy.x, color.xy.y)
return None
@property
def color_temp_kelvin(self) -> int | None:
"""Return the color temperature value in Kelvin."""
if color_temp := self.resource.color_temperature:
return color_util.color_temperature_mired_to_kelvin(color_temp.mirek)
# return a fallback value to prevent issues with mired->kelvin conversions
return FALLBACK_KELVIN
@property
def max_color_temp_mireds(self) -> int:
"""Return the warmest color_temp in mireds (so highest number) that this light supports."""
if color_temp := self.resource.color_temperature:
return color_temp.mirek_schema.mirek_maximum
# return a fallback value if the light doesn't provide limits
return FALLBACK_MAX_MIREDS
@property
def min_color_temp_mireds(self) -> int:
"""Return the coldest color_temp in mireds (so lowest number) that this light supports."""
if color_temp := self.resource.color_temperature:
return color_temp.mirek_schema.mirek_minimum
# return a fallback value if the light doesn't provide limits
return FALLBACK_MIN_MIREDS
@property
def max_color_temp_kelvin(self) -> int:
"""Return the coldest color_temp_kelvin that this light supports."""
return color_util.color_temperature_mired_to_kelvin(self.min_color_temp_mireds)
@property
def min_color_temp_kelvin(self) -> int:
"""Return the warmest color_temp_kelvin that this light supports."""
return color_util.color_temperature_mired_to_kelvin(self.max_color_temp_mireds)
@property
def extra_state_attributes(self) -> dict[str, str] | None:
"""Return the optional state attributes."""
return {
"mode": self.resource.mode.value,
"dynamics": self.resource.dynamics.status.value,
}
@property
def effect(self) -> str | None:
"""Return the current effect."""
if effects := self.resource.effects:
if effects.status != EffectStatus.NO_EFFECT:
return effects.status.value
if timed_effects := self.resource.timed_effects:
if timed_effects.status != TimedEffectStatus.NO_EFFECT:
return timed_effects.status.value
return EFFECT_OFF
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
xy_color = kwargs.get(ATTR_XY_COLOR)
color_temp = normalize_hue_colortemp(
kwargs.get(ATTR_COLOR_TEMP_KELVIN),
self.min_color_temp_mireds,
self.max_color_temp_mireds,
)
brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS))
if self._last_brightness and brightness is None:
# The Hue bridge sets the brightness to 1% when turning on a bulb
# when a transition was used to turn off the bulb.
# This issue has been reported on the Hue forum several times:
# https://developers.meethue.com/forum/t/brightness-turns-down-to-1-automatically-shortly-after-sending-off-signal-hue-bug/5692
# https://developers.meethue.com/forum/t/lights-turn-on-with-lowest-brightness-via-siri-if-turned-off-via-api/6700
# https://developers.meethue.com/forum/t/using-transitiontime-with-on-false-resets-bri-to-1/4585
# https://developers.meethue.com/forum/t/bri-value-changing-in-switching-lights-on-off/6323
# https://developers.meethue.com/forum/t/fade-in-fade-out/6673
brightness = self._last_brightness
self._last_brightness = None
self._color_temp_active = color_temp is not None
flash = kwargs.get(ATTR_FLASH)
effect = effect_str = kwargs.get(ATTR_EFFECT)
if effect_str == DEPRECATED_EFFECT_NONE:
# deprecated effect "None" is now "off"
effect_str = EFFECT_OFF
async_create_issue(
self.hass,
DOMAIN,
"deprecated_effect_none",
breaks_in_ha_version="2025.10.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_effect_none",
)
self.logger.warning(
"Detected deprecated effect 'None' in %s, use 'off' instead. "
"This will stop working in HA 2025.10",
self.entity_id,
)
if effect_str == EFFECT_OFF:
# ignore effect if set to "off" and we have no effect active
# the special effect "off" is only used to stop an active effect
# but sending it while no effect is active can actually result in issues
# https://github.com/home-assistant/core/issues/122165
effect = None if self.effect == EFFECT_OFF else EffectStatus.NO_EFFECT
elif effect_str is not None:
# work out if we got a regular effect or timed effect
effect = EffectStatus(effect_str)
if effect == EffectStatus.UNKNOWN:
effect = TimedEffectStatus(effect_str)
if transition is None:
# a transition is required for timed effect, default to 10 minutes
transition = 600000
# we need to clear color values if an effect is applied
color_temp = None
xy_color = None
if flash is not None:
await self.async_set_flash(flash)
# flash cannot be sent with other commands at the same time or result will be flaky
# Hue's default behavior is that a light returns to its previous state for short
# flash (identify) and the light is kept turned on for long flash (breathe effect)
# Why is this flash alert/effect hidden in the turn_on/off commands ?
return
await self.bridge.async_request_call(
self.controller.set_state,
id=self.resource.id,
on=True,
brightness=brightness,
color_xy=xy_color,
color_temp=color_temp,
transition_time=transition,
effect=effect,
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
if transition is not None and self.resource.dimming:
self._last_brightness = self.resource.dimming.brightness
flash = kwargs.get(ATTR_FLASH)
if flash is not None:
await self.async_set_flash(flash)
# flash cannot be sent with other commands at the same time or result will be flaky
# Hue's default behavior is that a light returns to its previous state for short
# flash (identify) and the light is kept turned on for long flash (breathe effect)
return
await self.bridge.async_request_call(
self.controller.set_state,
id=self.resource.id,
on=False,
transition_time=transition,
)
async def async_set_flash(self, flash: str) -> None:
"""Send flash command to light."""
await self.bridge.async_request_call(
self.controller.set_flash,
id=self.resource.id,
short=flash == FLASH_SHORT,
)