core/homeassistant/components/yeelight/light.py

601 lines
20 KiB
Python

"""Light platform support for yeelight."""
import logging
import voluptuous as vol
from yeelight import (RGBTransition, SleepTransition, Flow, BulbException)
from yeelight.enums import PowerMode, LightType, BulbType
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.service import extract_entity_ids
from homeassistant.util.color import (
color_temperature_mired_to_kelvin as mired_to_kelvin,
color_temperature_kelvin_to_mired as kelvin_to_mired)
from homeassistant.const import CONF_HOST, ATTR_ENTITY_ID, CONF_NAME
from homeassistant.core import callback
from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_COLOR_TEMP,
ATTR_FLASH, FLASH_SHORT, FLASH_LONG, ATTR_EFFECT, SUPPORT_BRIGHTNESS,
SUPPORT_COLOR, SUPPORT_TRANSITION, SUPPORT_COLOR_TEMP, SUPPORT_FLASH,
SUPPORT_EFFECT, Light)
import homeassistant.util.color as color_util
from . import (
CONF_TRANSITION, DATA_YEELIGHT, CONF_MODE_MUSIC,
CONF_SAVE_ON_CHANGE, CONF_CUSTOM_EFFECTS, DATA_UPDATED,
YEELIGHT_SERVICE_SCHEMA, DOMAIN, ATTR_TRANSITIONS,
YEELIGHT_FLOW_TRANSITION_SCHEMA, ACTION_RECOVER, CONF_FLOW_PARAMS,
ATTR_ACTION, ATTR_COUNT)
_LOGGER = logging.getLogger(__name__)
SUPPORT_YEELIGHT = (SUPPORT_BRIGHTNESS |
SUPPORT_TRANSITION |
SUPPORT_FLASH)
SUPPORT_YEELIGHT_WHITE_TEMP = (SUPPORT_YEELIGHT |
SUPPORT_COLOR_TEMP)
SUPPORT_YEELIGHT_RGB = (SUPPORT_YEELIGHT |
SUPPORT_COLOR |
SUPPORT_EFFECT |
SUPPORT_COLOR_TEMP)
ATTR_MODE = 'mode'
SERVICE_SET_MODE = 'set_mode'
SERVICE_START_FLOW = 'start_flow'
EFFECT_DISCO = "Disco"
EFFECT_TEMP = "Slow Temp"
EFFECT_STROBE = "Strobe epilepsy!"
EFFECT_STROBE_COLOR = "Strobe color"
EFFECT_ALARM = "Alarm"
EFFECT_POLICE = "Police"
EFFECT_POLICE2 = "Police2"
EFFECT_CHRISTMAS = "Christmas"
EFFECT_RGB = "RGB"
EFFECT_RANDOM_LOOP = "Random Loop"
EFFECT_FAST_RANDOM_LOOP = "Fast Random Loop"
EFFECT_LSD = "LSD"
EFFECT_SLOWDOWN = "Slowdown"
EFFECT_WHATSAPP = "WhatsApp"
EFFECT_FACEBOOK = "Facebook"
EFFECT_TWITTER = "Twitter"
EFFECT_STOP = "Stop"
YEELIGHT_EFFECT_LIST = [
EFFECT_DISCO,
EFFECT_TEMP,
EFFECT_STROBE,
EFFECT_STROBE_COLOR,
EFFECT_ALARM,
EFFECT_POLICE,
EFFECT_POLICE2,
EFFECT_CHRISTMAS,
EFFECT_RGB,
EFFECT_RANDOM_LOOP,
EFFECT_FAST_RANDOM_LOOP,
EFFECT_LSD,
EFFECT_SLOWDOWN,
EFFECT_WHATSAPP,
EFFECT_FACEBOOK,
EFFECT_TWITTER,
EFFECT_STOP]
def _transitions_config_parser(transitions):
"""Parse transitions config into initialized objects."""
import yeelight
transition_objects = []
for transition_config in transitions:
transition, params = list(transition_config.items())[0]
transition_objects.append(getattr(yeelight, transition)(*params))
return transition_objects
def _parse_custom_effects(effects_config):
effects = {}
for config in effects_config:
params = config[CONF_FLOW_PARAMS]
action = Flow.actions[params[ATTR_ACTION]]
transitions = _transitions_config_parser(
params[ATTR_TRANSITIONS])
effects[config[CONF_NAME]] = {
ATTR_COUNT: params[ATTR_COUNT],
ATTR_ACTION: action,
ATTR_TRANSITIONS: transitions
}
return effects
def _cmd(func):
"""Define a wrapper to catch exceptions from the bulb."""
def _wrap(self, *args, **kwargs):
try:
_LOGGER.debug("Calling %s with %s %s", func, args, kwargs)
return func(self, *args, **kwargs)
except BulbException as ex:
_LOGGER.error("Error when calling %s: %s", func, ex)
return _wrap
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Yeelight bulbs."""
data_key = '{}_lights'.format(DATA_YEELIGHT)
if not discovery_info:
return
if data_key not in hass.data:
hass.data[data_key] = []
device = hass.data[DATA_YEELIGHT][discovery_info[CONF_HOST]]
_LOGGER.debug("Adding %s", device.name)
custom_effects = _parse_custom_effects(discovery_info[CONF_CUSTOM_EFFECTS])
lights = [YeelightLight(device, custom_effects=custom_effects)]
if device.is_ambilight_supported:
lights.append(
YeelightAmbientLight(device, custom_effects=custom_effects))
hass.data[data_key] += lights
add_entities(lights, True)
def service_handler(service):
"""Dispatch service calls to target entities."""
params = {key: value for key, value in service.data.items()
if key != ATTR_ENTITY_ID}
entity_ids = extract_entity_ids(hass, service)
target_devices = [light for light in hass.data[data_key]
if light.entity_id in entity_ids]
for target_device in target_devices:
if service.service == SERVICE_SET_MODE:
target_device.set_mode(**params)
elif service.service == SERVICE_START_FLOW:
params[ATTR_TRANSITIONS] = \
_transitions_config_parser(params[ATTR_TRANSITIONS])
target_device.start_flow(**params)
service_schema_set_mode = YEELIGHT_SERVICE_SCHEMA.extend({
vol.Required(ATTR_MODE):
vol.In([mode.name.lower() for mode in PowerMode])
})
hass.services.register(
DOMAIN, SERVICE_SET_MODE, service_handler,
schema=service_schema_set_mode)
service_schema_start_flow = YEELIGHT_SERVICE_SCHEMA.extend(
YEELIGHT_FLOW_TRANSITION_SCHEMA
)
hass.services.register(
DOMAIN, SERVICE_START_FLOW, service_handler,
schema=service_schema_start_flow)
class YeelightLight(Light):
"""Representation of a Yeelight light."""
def __init__(self, device, custom_effects=None):
"""Initialize the Yeelight light."""
self.config = device.config
self._device = device
self._supported_features = SUPPORT_YEELIGHT
self._brightness = None
self._color_temp = None
self._is_on = None
self._hs = None
self._min_mireds = None
self._max_mireds = None
self._light_type = LightType.Main
if custom_effects:
self._custom_effects = custom_effects
else:
self._custom_effects = {}
@callback
def _schedule_immediate_update(self):
self.async_schedule_update_ha_state(True)
async def async_added_to_hass(self):
"""Handle entity which will be added."""
async_dispatcher_connect(
self.hass,
DATA_UPDATED.format(self._device.ipaddr),
self._schedule_immediate_update
)
@property
def should_poll(self):
"""No polling needed."""
return False
@property
def available(self) -> bool:
"""Return if bulb is available."""
return self.device.available
@property
def supported_features(self) -> int:
"""Flag supported features."""
return self._supported_features
@property
def effect_list(self):
"""Return the list of supported effects."""
return YEELIGHT_EFFECT_LIST + self.custom_effects_names
@property
def color_temp(self) -> int:
"""Return the color temperature."""
return self._color_temp
@property
def name(self) -> str:
"""Return the name of the device if any."""
return self.device.name
@property
def is_on(self) -> bool:
"""Return true if device is on."""
return self._is_on
@property
def brightness(self) -> int:
"""Return the brightness of this light between 1..255."""
return self._brightness
@property
def min_mireds(self):
"""Return minimum supported color temperature."""
return self._min_mireds
@property
def max_mireds(self):
"""Return maximum supported color temperature."""
return self._max_mireds
@property
def custom_effects(self):
"""Return dict with custom effects."""
return self._custom_effects
@property
def custom_effects_names(self):
"""Return list with custom effects names."""
return list(self.custom_effects.keys())
@property
def light_type(self):
"""Return light type."""
return self._light_type
def _get_hs_from_properties(self):
rgb = self._get_property('rgb')
color_mode = self._get_property('color_mode')
if not rgb or not color_mode:
return None
color_mode = int(color_mode)
if color_mode == 2: # color temperature
temp_in_k = mired_to_kelvin(self._color_temp)
return color_util.color_temperature_to_hs(temp_in_k)
if color_mode == 3: # hsv
hue = int(self._get_property('hue'))
sat = int(self._get_property('sat'))
return (hue / 360 * 65536, sat / 100 * 255)
rgb = int(rgb)
blue = rgb & 0xff
green = (rgb >> 8) & 0xff
red = (rgb >> 16) & 0xff
return color_util.color_RGB_to_hs(red, green, blue)
@property
def hs_color(self) -> tuple:
"""Return the color property."""
return self._hs
@property
def _properties(self) -> dict:
if self._bulb is None:
return {}
return self._bulb.last_properties
def _get_property(self, prop, default=None):
return self._properties.get(prop, default)
@property
def device(self):
"""Return yeelight device."""
return self._device
@property
def _is_nightlight_enabled(self):
return self.device.is_nightlight_enabled
# F821: https://github.com/PyCQA/pyflakes/issues/373
@property
def _bulb(self) -> 'yeelight.Bulb': # noqa: F821
return self.device.bulb
def set_music_mode(self, mode) -> None:
"""Set the music mode on or off."""
if mode:
self._bulb.start_music()
else:
self._bulb.stop_music()
def update(self) -> None:
"""Update properties from the bulb."""
bulb_type = self._bulb.bulb_type
if bulb_type == BulbType.Color:
self._supported_features = SUPPORT_YEELIGHT_RGB
elif self.light_type == LightType.Ambient:
self._supported_features = SUPPORT_YEELIGHT_RGB
elif bulb_type in (BulbType.WhiteTemp, BulbType.WhiteTempMood):
if self._is_nightlight_enabled:
self._supported_features = SUPPORT_YEELIGHT
else:
self._supported_features = SUPPORT_YEELIGHT_WHITE_TEMP
if self.min_mireds is None:
model_specs = self._bulb.get_model_specs()
self._min_mireds = \
kelvin_to_mired(model_specs['color_temp']['max'])
self._max_mireds = \
kelvin_to_mired(model_specs['color_temp']['min'])
if bulb_type == BulbType.WhiteTempMood:
self._is_on = self._get_property('main_power') == 'on'
else:
self._is_on = self._get_property('power') == 'on'
if self._is_nightlight_enabled:
bright = self._get_property('nl_br')
else:
bright = self._get_property('bright')
if bright:
self._brightness = round(255 * (int(bright) / 100))
temp_in_k = self._get_property('ct')
if temp_in_k:
self._color_temp = kelvin_to_mired(int(temp_in_k))
self._hs = self._get_hs_from_properties()
@_cmd
def set_brightness(self, brightness, duration) -> None:
"""Set bulb brightness."""
if brightness:
_LOGGER.debug("Setting brightness: %s", brightness)
self._bulb.set_brightness(brightness / 255 * 100,
duration=duration,
light_type=self.light_type)
@_cmd
def set_rgb(self, rgb, duration) -> None:
"""Set bulb's color."""
if rgb and self.supported_features & SUPPORT_COLOR:
_LOGGER.debug("Setting RGB: %s", rgb)
self._bulb.set_rgb(rgb[0], rgb[1], rgb[2], duration=duration,
light_type=self.light_type)
@_cmd
def set_colortemp(self, colortemp, duration) -> None:
"""Set bulb's color temperature."""
if colortemp and self.supported_features & SUPPORT_COLOR_TEMP:
temp_in_k = mired_to_kelvin(colortemp)
_LOGGER.debug("Setting color temp: %s K", temp_in_k)
self._bulb.set_color_temp(temp_in_k, duration=duration,
light_type=self.light_type)
@_cmd
def set_default(self) -> None:
"""Set current options as default."""
self._bulb.set_default()
@_cmd
def set_flash(self, flash) -> None:
"""Activate flash."""
if flash:
if self._bulb.last_properties["color_mode"] != 1:
_LOGGER.error("Flash supported currently only in RGB mode.")
return
transition = int(self.config[CONF_TRANSITION])
if flash == FLASH_LONG:
count = 1
duration = transition * 5
if flash == FLASH_SHORT:
count = 1
duration = transition * 2
red, green, blue = color_util.color_hs_to_RGB(*self._hs)
transitions = list()
transitions.append(
RGBTransition(255, 0, 0, brightness=10, duration=duration))
transitions.append(SleepTransition(
duration=transition))
transitions.append(
RGBTransition(red, green, blue, brightness=self.brightness,
duration=duration))
flow = Flow(count=count, transitions=transitions)
try:
self._bulb.start_flow(flow, light_type=self.light_type)
except BulbException as ex:
_LOGGER.error("Unable to set flash: %s", ex)
@_cmd
def set_effect(self, effect) -> None:
"""Activate effect."""
if effect:
from yeelight.transitions import (disco, temp, strobe, pulse,
strobe_color, alarm, police,
police2, christmas, rgb,
randomloop, lsd, slowdown)
if effect == EFFECT_STOP:
self._bulb.stop_flow(light_type=self.light_type)
return
effects_map = {
EFFECT_DISCO: disco,
EFFECT_TEMP: temp,
EFFECT_STROBE: strobe,
EFFECT_STROBE_COLOR: strobe_color,
EFFECT_ALARM: alarm,
EFFECT_POLICE: police,
EFFECT_POLICE2: police2,
EFFECT_CHRISTMAS: christmas,
EFFECT_RGB: rgb,
EFFECT_RANDOM_LOOP: randomloop,
EFFECT_LSD: lsd,
EFFECT_SLOWDOWN: slowdown,
}
if effect in self.custom_effects_names:
flow = Flow(**self.custom_effects[effect])
elif effect in effects_map:
flow = Flow(count=0, transitions=effects_map[effect]())
elif effect == EFFECT_FAST_RANDOM_LOOP:
flow = Flow(count=0, transitions=randomloop(duration=250))
elif effect == EFFECT_WHATSAPP:
flow = Flow(count=2, transitions=pulse(37, 211, 102))
elif effect == EFFECT_FACEBOOK:
flow = Flow(count=2, transitions=pulse(59, 89, 152))
elif effect == EFFECT_TWITTER:
flow = Flow(count=2, transitions=pulse(0, 172, 237))
try:
self._bulb.start_flow(flow, light_type=self.light_type)
except BulbException as ex:
_LOGGER.error("Unable to set effect: %s", ex)
def turn_on(self, **kwargs) -> None:
"""Turn the bulb on."""
brightness = kwargs.get(ATTR_BRIGHTNESS)
colortemp = kwargs.get(ATTR_COLOR_TEMP)
hs_color = kwargs.get(ATTR_HS_COLOR)
rgb = color_util.color_hs_to_RGB(*hs_color) if hs_color else None
flash = kwargs.get(ATTR_FLASH)
effect = kwargs.get(ATTR_EFFECT)
duration = int(self.config[CONF_TRANSITION]) # in ms
if ATTR_TRANSITION in kwargs: # passed kwarg overrides config
duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s
self.device.turn_on(duration=duration, light_type=self.light_type)
if self.config[CONF_MODE_MUSIC] and not self._bulb.music_mode:
try:
self.set_music_mode(self.config[CONF_MODE_MUSIC])
except BulbException as ex:
_LOGGER.error("Unable to turn on music mode,"
"consider disabling it: %s", ex)
try:
# values checked for none in methods
self.set_rgb(rgb, duration)
self.set_colortemp(colortemp, duration)
self.set_brightness(brightness, duration)
self.set_flash(flash)
self.set_effect(effect)
except BulbException as ex:
_LOGGER.error("Unable to set bulb properties: %s", ex)
return
# save the current state if we had a manual change.
if self.config[CONF_SAVE_ON_CHANGE] and (brightness
or colortemp
or rgb):
try:
self.set_default()
except BulbException as ex:
_LOGGER.error("Unable to set the defaults: %s", ex)
return
self.device.update()
def turn_off(self, **kwargs) -> None:
"""Turn off."""
duration = int(self.config[CONF_TRANSITION]) # in ms
if ATTR_TRANSITION in kwargs: # passed kwarg overrides config
duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s
self.device.turn_off(duration=duration, light_type=self.light_type)
self.device.update()
def set_mode(self, mode: str):
"""Set a power mode."""
try:
self._bulb.set_power_mode(PowerMode[mode.upper()])
self.device.update()
except BulbException as ex:
_LOGGER.error("Unable to set the power mode: %s", ex)
def start_flow(self, transitions, count=0, action=ACTION_RECOVER):
"""Start flow."""
try:
flow = Flow(
count=count,
action=Flow.actions[action],
transitions=transitions)
self._bulb.start_flow(flow, light_type=self.light_type)
self.device.update()
except BulbException as ex:
_LOGGER.error("Unable to set effect: %s", ex)
class YeelightAmbientLight(YeelightLight):
"""Representation of a Yeelight ambient light."""
PROPERTIES_MAPPING = {
"color_mode": "bg_lmode",
"main_power": "bg_power",
}
def __init__(self, *args, **kwargs):
"""Initialize the Yeelight Ambient light."""
super().__init__(*args, **kwargs)
self._min_mireds = kelvin_to_mired(6500)
self._max_mireds = kelvin_to_mired(1700)
self._light_type = LightType.Ambient
@property
def name(self) -> str:
"""Return the name of the device if any."""
return "{} ambilight".format(self.device.name)
@property
def _is_nightlight_enabled(self):
return False
def _get_property(self, prop, default=None):
bg_prop = self.PROPERTIES_MAPPING.get(prop)
if not bg_prop:
bg_prop = "bg_" + prop
return self._properties.get(bg_prop, default)