460 lines
15 KiB
Python
460 lines
15 KiB
Python
"""Support for FluxLED/MagicHome lights."""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from functools import partial
|
|
import logging
|
|
import random
|
|
from typing import Any, Final, cast
|
|
|
|
from flux_led import WifiLedBulb
|
|
import voluptuous as vol
|
|
|
|
from homeassistant import config_entries
|
|
from homeassistant.components.light import (
|
|
ATTR_BRIGHTNESS,
|
|
ATTR_COLOR_TEMP,
|
|
ATTR_EFFECT,
|
|
ATTR_HS_COLOR,
|
|
ATTR_WHITE_VALUE,
|
|
EFFECT_COLORLOOP,
|
|
EFFECT_RANDOM,
|
|
PLATFORM_SCHEMA,
|
|
SUPPORT_BRIGHTNESS,
|
|
SUPPORT_COLOR,
|
|
SUPPORT_COLOR_TEMP,
|
|
SUPPORT_EFFECT,
|
|
SUPPORT_WHITE_VALUE,
|
|
LightEntity,
|
|
)
|
|
from homeassistant.const import (
|
|
ATTR_MANUFACTURER,
|
|
ATTR_MODE,
|
|
ATTR_MODEL,
|
|
ATTR_NAME,
|
|
ATTR_SW_VERSION,
|
|
CONF_DEVICES,
|
|
CONF_HOST,
|
|
CONF_MAC,
|
|
CONF_MODE,
|
|
CONF_NAME,
|
|
CONF_PROTOCOL,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers import device_registry as dr, entity_platform
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|
import homeassistant.util.color as color_util
|
|
|
|
from . import FluxLedUpdateCoordinator
|
|
from .const import (
|
|
CONF_AUTOMATIC_ADD,
|
|
CONF_COLORS,
|
|
CONF_CUSTOM_EFFECT,
|
|
CONF_CUSTOM_EFFECT_COLORS,
|
|
CONF_CUSTOM_EFFECT_SPEED_PCT,
|
|
CONF_CUSTOM_EFFECT_TRANSITION,
|
|
CONF_SPEED_PCT,
|
|
CONF_TRANSITION,
|
|
DEFAULT_EFFECT_SPEED,
|
|
DOMAIN,
|
|
FLUX_HOST,
|
|
FLUX_LED_DISCOVERY,
|
|
FLUX_MAC,
|
|
MODE_AUTO,
|
|
MODE_RGB,
|
|
MODE_RGBW,
|
|
MODE_WHITE,
|
|
TRANSITION_GRADUAL,
|
|
TRANSITION_JUMP,
|
|
TRANSITION_STROBE,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
SUPPORT_FLUX_LED: Final = SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | SUPPORT_COLOR
|
|
|
|
|
|
# Constant color temp values for 2 flux_led special modes
|
|
# Warm-white and Cool-white modes
|
|
COLOR_TEMP_WARM_VS_COLD_WHITE_CUT_OFF: Final = 285
|
|
|
|
# List of supported effects which aren't already declared in LIGHT
|
|
EFFECT_RED_FADE: Final = "red_fade"
|
|
EFFECT_GREEN_FADE: Final = "green_fade"
|
|
EFFECT_BLUE_FADE: Final = "blue_fade"
|
|
EFFECT_YELLOW_FADE: Final = "yellow_fade"
|
|
EFFECT_CYAN_FADE: Final = "cyan_fade"
|
|
EFFECT_PURPLE_FADE: Final = "purple_fade"
|
|
EFFECT_WHITE_FADE: Final = "white_fade"
|
|
EFFECT_RED_GREEN_CROSS_FADE: Final = "rg_cross_fade"
|
|
EFFECT_RED_BLUE_CROSS_FADE: Final = "rb_cross_fade"
|
|
EFFECT_GREEN_BLUE_CROSS_FADE: Final = "gb_cross_fade"
|
|
EFFECT_COLORSTROBE: Final = "colorstrobe"
|
|
EFFECT_RED_STROBE: Final = "red_strobe"
|
|
EFFECT_GREEN_STROBE: Final = "green_strobe"
|
|
EFFECT_BLUE_STROBE: Final = "blue_strobe"
|
|
EFFECT_YELLOW_STROBE: Final = "yellow_strobe"
|
|
EFFECT_CYAN_STROBE: Final = "cyan_strobe"
|
|
EFFECT_PURPLE_STROBE: Final = "purple_strobe"
|
|
EFFECT_WHITE_STROBE: Final = "white_strobe"
|
|
EFFECT_COLORJUMP: Final = "colorjump"
|
|
EFFECT_CUSTOM: Final = "custom"
|
|
|
|
EFFECT_MAP: Final = {
|
|
EFFECT_COLORLOOP: 0x25,
|
|
EFFECT_RED_FADE: 0x26,
|
|
EFFECT_GREEN_FADE: 0x27,
|
|
EFFECT_BLUE_FADE: 0x28,
|
|
EFFECT_YELLOW_FADE: 0x29,
|
|
EFFECT_CYAN_FADE: 0x2A,
|
|
EFFECT_PURPLE_FADE: 0x2B,
|
|
EFFECT_WHITE_FADE: 0x2C,
|
|
EFFECT_RED_GREEN_CROSS_FADE: 0x2D,
|
|
EFFECT_RED_BLUE_CROSS_FADE: 0x2E,
|
|
EFFECT_GREEN_BLUE_CROSS_FADE: 0x2F,
|
|
EFFECT_COLORSTROBE: 0x30,
|
|
EFFECT_RED_STROBE: 0x31,
|
|
EFFECT_GREEN_STROBE: 0x32,
|
|
EFFECT_BLUE_STROBE: 0x33,
|
|
EFFECT_YELLOW_STROBE: 0x34,
|
|
EFFECT_CYAN_STROBE: 0x35,
|
|
EFFECT_PURPLE_STROBE: 0x36,
|
|
EFFECT_WHITE_STROBE: 0x37,
|
|
EFFECT_COLORJUMP: 0x38,
|
|
}
|
|
EFFECT_ID_NAME: Final = {v: k for k, v in EFFECT_MAP.items()}
|
|
EFFECT_CUSTOM_CODE: Final = 0x60
|
|
|
|
WHITE_MODES: Final = {MODE_RGBW}
|
|
|
|
FLUX_EFFECT_LIST: Final = sorted(EFFECT_MAP) + [EFFECT_RANDOM]
|
|
|
|
SERVICE_CUSTOM_EFFECT: Final = "set_custom_effect"
|
|
|
|
CUSTOM_EFFECT_DICT: Final = {
|
|
vol.Required(CONF_COLORS): vol.All(
|
|
cv.ensure_list,
|
|
vol.Length(min=1, max=16),
|
|
[vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)), vol.Coerce(tuple))],
|
|
),
|
|
vol.Optional(CONF_SPEED_PCT, default=50): vol.All(
|
|
vol.Range(min=0, max=100), vol.Coerce(int)
|
|
),
|
|
vol.Optional(CONF_TRANSITION, default=TRANSITION_GRADUAL): vol.All(
|
|
cv.string, vol.In([TRANSITION_GRADUAL, TRANSITION_JUMP, TRANSITION_STROBE])
|
|
),
|
|
}
|
|
|
|
CUSTOM_EFFECT_SCHEMA: Final = vol.Schema(CUSTOM_EFFECT_DICT)
|
|
|
|
DEVICE_SCHEMA: Final = vol.Schema(
|
|
{
|
|
vol.Optional(CONF_NAME): cv.string,
|
|
vol.Optional(ATTR_MODE, default=MODE_AUTO): vol.All(
|
|
cv.string, vol.In([MODE_AUTO, MODE_RGBW, MODE_RGB, MODE_WHITE])
|
|
),
|
|
vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(["ledenet"])),
|
|
vol.Optional(CONF_CUSTOM_EFFECT): CUSTOM_EFFECT_SCHEMA,
|
|
}
|
|
)
|
|
|
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
|
{
|
|
vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA},
|
|
vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean,
|
|
}
|
|
)
|
|
|
|
|
|
async def async_setup_platform(
|
|
hass: HomeAssistant,
|
|
config: ConfigType,
|
|
async_add_entities: AddEntitiesCallback,
|
|
discovery_info: DiscoveryInfoType | None = None,
|
|
) -> bool:
|
|
"""Set up the flux led platform."""
|
|
domain_data = hass.data[DOMAIN]
|
|
discovered_mac_by_host = {
|
|
device[FLUX_HOST]: device[FLUX_MAC]
|
|
for device in domain_data[FLUX_LED_DISCOVERY]
|
|
}
|
|
for host, device_config in config.get(CONF_DEVICES, {}).items():
|
|
_LOGGER.warning(
|
|
"Configuring flux_led via yaml is deprecated; the configuration for"
|
|
" %s has been migrated to a config entry and can be safely removed",
|
|
host,
|
|
)
|
|
custom_effects = device_config.get(CONF_CUSTOM_EFFECT, {})
|
|
hass.async_create_task(
|
|
hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": config_entries.SOURCE_IMPORT},
|
|
data={
|
|
CONF_HOST: host,
|
|
CONF_MAC: discovered_mac_by_host.get(host),
|
|
CONF_NAME: device_config[CONF_NAME],
|
|
CONF_PROTOCOL: device_config.get(CONF_PROTOCOL),
|
|
CONF_MODE: device_config.get(ATTR_MODE, MODE_AUTO),
|
|
CONF_CUSTOM_EFFECT_COLORS: str(custom_effects.get(CONF_COLORS)),
|
|
CONF_CUSTOM_EFFECT_SPEED_PCT: custom_effects.get(
|
|
CONF_SPEED_PCT, DEFAULT_EFFECT_SPEED
|
|
),
|
|
CONF_CUSTOM_EFFECT_TRANSITION: custom_effects.get(
|
|
CONF_TRANSITION, TRANSITION_GRADUAL
|
|
),
|
|
},
|
|
)
|
|
)
|
|
return True
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: config_entries.ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
"""Set up the Flux lights."""
|
|
coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
|
|
|
platform = entity_platform.async_get_current_platform()
|
|
platform.async_register_entity_service(
|
|
SERVICE_CUSTOM_EFFECT,
|
|
CUSTOM_EFFECT_DICT,
|
|
"set_custom_effect",
|
|
)
|
|
options = entry.options
|
|
|
|
try:
|
|
custom_effect_colors = ast.literal_eval(
|
|
options.get(CONF_CUSTOM_EFFECT_COLORS) or "[]"
|
|
)
|
|
except (ValueError, TypeError, SyntaxError, MemoryError) as ex:
|
|
_LOGGER.warning(
|
|
"Could not parse custom effect colors for %s: %s", entry.unique_id, ex
|
|
)
|
|
custom_effect_colors = []
|
|
|
|
async_add_entities(
|
|
[
|
|
FluxLight(
|
|
coordinator,
|
|
entry.unique_id,
|
|
entry.data[CONF_NAME],
|
|
options.get(CONF_MODE) or MODE_AUTO,
|
|
list(custom_effect_colors),
|
|
options.get(CONF_CUSTOM_EFFECT_SPEED_PCT, DEFAULT_EFFECT_SPEED),
|
|
options.get(CONF_CUSTOM_EFFECT_TRANSITION, TRANSITION_GRADUAL),
|
|
)
|
|
]
|
|
)
|
|
|
|
|
|
class FluxLight(CoordinatorEntity, LightEntity):
|
|
"""Representation of a Flux light."""
|
|
|
|
coordinator: FluxLedUpdateCoordinator
|
|
|
|
def __init__(
|
|
self,
|
|
coordinator: FluxLedUpdateCoordinator,
|
|
unique_id: str | None,
|
|
name: str,
|
|
mode: str,
|
|
custom_effect_colors: list[tuple[int, int, int]],
|
|
custom_effect_speed_pct: int,
|
|
custom_effect_transition: str,
|
|
) -> None:
|
|
"""Initialize the light."""
|
|
super().__init__(coordinator)
|
|
self._bulb: WifiLedBulb = coordinator.device
|
|
self._attr_name = name
|
|
self._attr_unique_id = unique_id
|
|
self._ip_address = coordinator.host
|
|
self._mode = mode
|
|
self._custom_effect_colors = custom_effect_colors
|
|
self._custom_effect_speed_pct = custom_effect_speed_pct
|
|
self._custom_effect_transition = custom_effect_transition
|
|
old_protocol = self._bulb.protocol == "LEDENET_ORIGINAL"
|
|
if self.unique_id:
|
|
self._attr_device_info = {
|
|
"connections": {(dr.CONNECTION_NETWORK_MAC, self.unique_id)},
|
|
ATTR_MODEL: f"0x{self._bulb.raw_state[1]:02X}",
|
|
ATTR_SW_VERSION: "1" if old_protocol else str(self._bulb.raw_state[10]),
|
|
ATTR_NAME: self.name,
|
|
ATTR_MANUFACTURER: "FluxLED/Magic Home",
|
|
}
|
|
|
|
@property
|
|
def is_on(self) -> bool:
|
|
"""Return true if device is on."""
|
|
return cast(bool, self._bulb.is_on)
|
|
|
|
@property
|
|
def brightness(self) -> int:
|
|
"""Return the brightness of this light between 0..255."""
|
|
if self._mode == MODE_WHITE:
|
|
return self.white_value
|
|
return cast(int, self._bulb.brightness)
|
|
|
|
@property
|
|
def hs_color(self) -> tuple[float, float] | None:
|
|
"""Return the color property."""
|
|
return color_util.color_RGB_to_hs(*self._bulb.getRgb())
|
|
|
|
@property
|
|
def supported_features(self) -> int:
|
|
"""Flag supported features."""
|
|
if self._mode == MODE_WHITE:
|
|
return SUPPORT_BRIGHTNESS
|
|
if self._mode in WHITE_MODES:
|
|
return SUPPORT_FLUX_LED | SUPPORT_WHITE_VALUE | SUPPORT_COLOR_TEMP
|
|
return SUPPORT_FLUX_LED
|
|
|
|
@property
|
|
def white_value(self) -> int:
|
|
"""Return the white value of this light between 0..255."""
|
|
return cast(int, self._bulb.getRgbw()[3])
|
|
|
|
@property
|
|
def effect_list(self) -> list[str]:
|
|
"""Return the list of supported effects."""
|
|
if self._custom_effect_colors:
|
|
return FLUX_EFFECT_LIST + [EFFECT_CUSTOM]
|
|
return FLUX_EFFECT_LIST
|
|
|
|
@property
|
|
def effect(self) -> str | None:
|
|
"""Return the current effect."""
|
|
if (current_mode := self._bulb.raw_state[3]) == EFFECT_CUSTOM_CODE:
|
|
return EFFECT_CUSTOM
|
|
return EFFECT_ID_NAME.get(current_mode)
|
|
|
|
@property
|
|
def extra_state_attributes(self) -> dict[str, str]:
|
|
"""Return the attributes."""
|
|
return {
|
|
"ip_address": self._ip_address,
|
|
}
|
|
|
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
|
"""Turn the specified or all lights on."""
|
|
await self.hass.async_add_executor_job(partial(self._turn_on, **kwargs))
|
|
self.async_write_ha_state()
|
|
await self.coordinator.async_request_refresh()
|
|
|
|
def _turn_on(self, **kwargs: Any) -> None:
|
|
"""Turn the specified or all lights on."""
|
|
if not self.is_on:
|
|
self._bulb.turnOn()
|
|
|
|
if hs_color := kwargs.get(ATTR_HS_COLOR):
|
|
rgb: tuple[int, int, int] | None = color_util.color_hs_to_RGB(*hs_color)
|
|
else:
|
|
rgb = None
|
|
|
|
brightness = kwargs.get(ATTR_BRIGHTNESS)
|
|
# handle special modes
|
|
if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None:
|
|
if brightness is None:
|
|
brightness = self.brightness
|
|
if color_temp > COLOR_TEMP_WARM_VS_COLD_WHITE_CUT_OFF:
|
|
self._bulb.setRgbw(w=brightness)
|
|
else:
|
|
self._bulb.setRgbw(w2=brightness)
|
|
return
|
|
|
|
white = kwargs.get(ATTR_WHITE_VALUE)
|
|
effect = kwargs.get(ATTR_EFFECT)
|
|
# Show warning if effect set with rgb, brightness, or white level
|
|
if effect and (brightness or white or rgb):
|
|
_LOGGER.warning(
|
|
"RGB, brightness and white level are ignored when"
|
|
" an effect is specified for a flux bulb"
|
|
)
|
|
|
|
# Random color effect
|
|
if effect == EFFECT_RANDOM:
|
|
self._bulb.setRgb(
|
|
random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)
|
|
)
|
|
return
|
|
|
|
# Custom effect
|
|
if effect == EFFECT_CUSTOM:
|
|
if self._custom_effect_colors:
|
|
self._bulb.setCustomPattern(
|
|
self._custom_effect_colors,
|
|
self._custom_effect_speed_pct,
|
|
self._custom_effect_transition,
|
|
)
|
|
return
|
|
|
|
# Effect selection
|
|
if effect in EFFECT_MAP:
|
|
self._bulb.setPresetPattern(EFFECT_MAP[effect], 50)
|
|
return
|
|
|
|
# Preserve current brightness on color/white level change
|
|
if brightness is None:
|
|
brightness = self.brightness
|
|
|
|
# handle W only mode (use brightness instead of white value)
|
|
if self._mode == MODE_WHITE:
|
|
self._bulb.setRgbw(0, 0, 0, w=brightness)
|
|
return
|
|
|
|
if white is None and self._mode in WHITE_MODES:
|
|
white = self.white_value
|
|
|
|
# Preserve color on brightness/white level change
|
|
if rgb is None:
|
|
rgb = self._bulb.getRgb()
|
|
|
|
# handle RGBW mode
|
|
if self._mode == MODE_RGBW:
|
|
self._bulb.setRgbw(*tuple(rgb), w=white, brightness=brightness)
|
|
return
|
|
|
|
# handle RGB mode
|
|
self._bulb.setRgb(*tuple(rgb), brightness=brightness)
|
|
|
|
def set_custom_effect(
|
|
self, colors: list[tuple[int, int, int]], speed_pct: int, transition: str
|
|
) -> None:
|
|
"""Set a custom effect on the bulb."""
|
|
self._bulb.setCustomPattern(
|
|
colors,
|
|
speed_pct,
|
|
transition,
|
|
)
|
|
|
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
|
"""Turn the specified or all lights off."""
|
|
await self.hass.async_add_executor_job(self._bulb.turnOff)
|
|
self.async_write_ha_state()
|
|
await self.coordinator.async_request_refresh()
|
|
|
|
async def async_added_to_hass(self) -> None:
|
|
"""When entity is added to hass."""
|
|
await super().async_added_to_hass()
|
|
if self._mode and self._mode != MODE_AUTO:
|
|
return
|
|
|
|
if self._bulb.mode == "ww":
|
|
self._mode = MODE_WHITE
|
|
elif self._bulb.rgbwcapable:
|
|
self._mode = MODE_RGBW
|
|
else:
|
|
self._mode = MODE_RGB
|
|
_LOGGER.debug(
|
|
"Detected mode for %s (%s) with raw_state=%s rgbwcapable=%s is %s",
|
|
self.name,
|
|
self.unique_id,
|
|
self._bulb.raw_state,
|
|
self._bulb.rgbwcapable,
|
|
self._mode,
|
|
)
|