hass-core/homeassistant/components/light/lifx.py
Anders Melchiorsen d10f017441 LIFX: improve light availability (#8451)
The default aiolifx timers are tuned for a network with few lost packets.
This means that lights can become "unavailable" from just a two second
dropout. An unavailable light is completely useless for HA until it is
rediscovered so this is an undesirable state to be in.

These tweaks make aiolifx try harder to get its messages through to the
bulbs, at the cost of some latency in detecting lights that actually are
unavailable.
2017-07-12 19:24:24 +02:00

570 lines
19 KiB
Python

"""
Support for the LIFX platform that implements lights.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/light.lifx/
"""
import logging
import asyncio
import sys
import math
from os import path
from functools import partial
from datetime import timedelta
import voluptuous as vol
from homeassistant.components.light import (
Light, DOMAIN, PLATFORM_SCHEMA, LIGHT_TURN_ON_SCHEMA,
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, ATTR_RGB_COLOR,
ATTR_XY_COLOR, ATTR_COLOR_TEMP, ATTR_KELVIN, ATTR_TRANSITION, ATTR_EFFECT,
SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR,
SUPPORT_XY_COLOR, SUPPORT_TRANSITION, SUPPORT_EFFECT,
VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT,
preprocess_turn_on_alternatives)
from homeassistant.config import load_yaml_config_file
from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP
from homeassistant import util
from homeassistant.core import callback
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.service import extract_entity_ids
import homeassistant.helpers.config_validation as cv
import homeassistant.util.color as color_util
_LOGGER = logging.getLogger(__name__)
REQUIREMENTS = ['aiolifx==0.5.2', 'aiolifx_effects==0.1.0']
UDP_BROADCAST_PORT = 56700
DISCOVERY_INTERVAL = 60
MESSAGE_TIMEOUT = 1.0
MESSAGE_RETRIES = 8
UNAVAILABLE_GRACE = 90
CONF_SERVER = 'server'
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Optional(CONF_SERVER, default='0.0.0.0'): cv.string,
})
SERVICE_LIFX_SET_STATE = 'lifx_set_state'
ATTR_INFRARED = 'infrared'
ATTR_POWER = 'power'
LIFX_SET_STATE_SCHEMA = LIGHT_TURN_ON_SCHEMA.extend({
ATTR_INFRARED: vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255)),
ATTR_POWER: cv.boolean,
})
SERVICE_EFFECT_PULSE = 'lifx_effect_pulse'
SERVICE_EFFECT_COLORLOOP = 'lifx_effect_colorloop'
SERVICE_EFFECT_STOP = 'lifx_effect_stop'
ATTR_POWER_ON = 'power_on'
ATTR_MODE = 'mode'
ATTR_PERIOD = 'period'
ATTR_CYCLES = 'cycles'
ATTR_SPREAD = 'spread'
ATTR_CHANGE = 'change'
PULSE_MODE_BLINK = 'blink'
PULSE_MODE_BREATHE = 'breathe'
PULSE_MODE_PING = 'ping'
PULSE_MODE_STROBE = 'strobe'
PULSE_MODE_SOLID = 'solid'
PULSE_MODES = [PULSE_MODE_BLINK, PULSE_MODE_BREATHE, PULSE_MODE_PING,
PULSE_MODE_STROBE, PULSE_MODE_SOLID]
LIFX_EFFECT_SCHEMA = vol.Schema({
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
vol.Optional(ATTR_POWER_ON, default=True): cv.boolean,
})
LIFX_EFFECT_PULSE_SCHEMA = LIFX_EFFECT_SCHEMA.extend({
ATTR_BRIGHTNESS: VALID_BRIGHTNESS,
ATTR_BRIGHTNESS_PCT: VALID_BRIGHTNESS_PCT,
ATTR_COLOR_NAME: cv.string,
ATTR_RGB_COLOR: vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)),
vol.Coerce(tuple)),
ATTR_COLOR_TEMP: vol.All(vol.Coerce(int), vol.Range(min=1)),
ATTR_KELVIN: vol.All(vol.Coerce(int), vol.Range(min=0)),
ATTR_PERIOD: vol.All(vol.Coerce(float), vol.Range(min=0.05)),
ATTR_CYCLES: vol.All(vol.Coerce(float), vol.Range(min=1)),
ATTR_MODE: vol.In(PULSE_MODES),
})
LIFX_EFFECT_COLORLOOP_SCHEMA = LIFX_EFFECT_SCHEMA.extend({
ATTR_BRIGHTNESS: VALID_BRIGHTNESS,
ATTR_BRIGHTNESS_PCT: VALID_BRIGHTNESS_PCT,
ATTR_PERIOD: vol.All(vol.Coerce(float), vol.Clamp(min=0.05)),
ATTR_CHANGE: vol.All(vol.Coerce(float), vol.Clamp(min=0, max=360)),
ATTR_SPREAD: vol.All(vol.Coerce(float), vol.Clamp(min=0, max=360)),
ATTR_TRANSITION: vol.All(vol.Coerce(float), vol.Range(min=0)),
})
LIFX_EFFECT_STOP_SCHEMA = vol.Schema({
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
})
@asyncio.coroutine
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
"""Set up the LIFX platform."""
import aiolifx
if sys.platform == 'win32':
_LOGGER.warning("The lifx platform is known to not work on Windows. "
"Consider using the lifx_legacy platform instead")
server_addr = config.get(CONF_SERVER)
lifx_manager = LIFXManager(hass, async_add_devices)
lifx_discovery = aiolifx.LifxDiscovery(
hass.loop,
lifx_manager,
discovery_interval=DISCOVERY_INTERVAL)
coro = hass.loop.create_datagram_endpoint(
lambda: lifx_discovery, local_addr=(server_addr, UDP_BROADCAST_PORT))
hass.async_add_job(coro)
@callback
def cleanup(event):
"""Clean up resources."""
lifx_discovery.cleanup()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup)
return True
def find_hsbk(**kwargs):
"""Find the desired color from a number of possible inputs."""
hue, saturation, brightness, kelvin = [None]*4
preprocess_turn_on_alternatives(kwargs)
if ATTR_RGB_COLOR in kwargs:
hue, saturation, brightness = \
color_util.color_RGB_to_hsv(*kwargs[ATTR_RGB_COLOR])
saturation = convert_8_to_16(saturation)
brightness = convert_8_to_16(brightness)
kelvin = 3500
if ATTR_XY_COLOR in kwargs:
hue, saturation = color_util.color_xy_to_hs(*kwargs[ATTR_XY_COLOR])
saturation = convert_8_to_16(saturation)
kelvin = 3500
if ATTR_COLOR_TEMP in kwargs:
kelvin = int(color_util.color_temperature_mired_to_kelvin(
kwargs[ATTR_COLOR_TEMP]))
saturation = 0
if ATTR_BRIGHTNESS in kwargs:
brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS])
hsbk = [hue, saturation, brightness, kelvin]
return None if hsbk == [None]*4 else hsbk
def merge_hsbk(base, change):
"""Copy change on top of base, except when None."""
if change is None:
return None
return list(map(lambda x, y: y if y is not None else x, base, change))
class LIFXManager(object):
"""Representation of all known LIFX entities."""
def __init__(self, hass, async_add_devices):
"""Initialize the light."""
import aiolifx_effects
self.entities = {}
self.hass = hass
self.async_add_devices = async_add_devices
self.effects_conductor = aiolifx_effects.Conductor(loop=hass.loop)
descriptions = load_yaml_config_file(
path.join(path.dirname(__file__), 'services.yaml'))
self.register_set_state(descriptions)
self.register_effects(descriptions)
def register_set_state(self, descriptions):
"""Register the LIFX set_state service call."""
@asyncio.coroutine
def async_service_handle(service):
"""Apply a service."""
tasks = []
for light in self.service_to_entities(service):
if service.service == SERVICE_LIFX_SET_STATE:
task = light.async_set_state(**service.data)
tasks.append(self.hass.async_add_job(task))
if tasks:
yield from asyncio.wait(tasks, loop=self.hass.loop)
self.hass.services.async_register(
DOMAIN, SERVICE_LIFX_SET_STATE, async_service_handle,
descriptions.get(SERVICE_LIFX_SET_STATE),
schema=LIFX_SET_STATE_SCHEMA)
def register_effects(self, descriptions):
"""Register the LIFX effects as hass service calls."""
@asyncio.coroutine
def async_service_handle(service):
"""Apply a service, i.e. start an effect."""
entities = self.service_to_entities(service)
if entities:
yield from self.start_effect(
entities, service.service, **service.data)
self.hass.services.async_register(
DOMAIN, SERVICE_EFFECT_PULSE, async_service_handle,
descriptions.get(SERVICE_EFFECT_PULSE),
schema=LIFX_EFFECT_PULSE_SCHEMA)
self.hass.services.async_register(
DOMAIN, SERVICE_EFFECT_COLORLOOP, async_service_handle,
descriptions.get(SERVICE_EFFECT_COLORLOOP),
schema=LIFX_EFFECT_COLORLOOP_SCHEMA)
self.hass.services.async_register(
DOMAIN, SERVICE_EFFECT_STOP, async_service_handle,
descriptions.get(SERVICE_EFFECT_STOP),
schema=LIFX_EFFECT_STOP_SCHEMA)
@asyncio.coroutine
def start_effect(self, entities, service, **kwargs):
"""Start a light effect on entities."""
import aiolifx_effects
devices = list(map(lambda l: l.device, entities))
if service == SERVICE_EFFECT_PULSE:
effect = aiolifx_effects.EffectPulse(
power_on=kwargs.get(ATTR_POWER_ON),
period=kwargs.get(ATTR_PERIOD),
cycles=kwargs.get(ATTR_CYCLES),
mode=kwargs.get(ATTR_MODE),
hsbk=find_hsbk(**kwargs),
)
yield from self.effects_conductor.start(effect, devices)
elif service == SERVICE_EFFECT_COLORLOOP:
preprocess_turn_on_alternatives(kwargs)
brightness = None
if ATTR_BRIGHTNESS in kwargs:
brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS])
effect = aiolifx_effects.EffectColorloop(
power_on=kwargs.get(ATTR_POWER_ON),
period=kwargs.get(ATTR_PERIOD),
change=kwargs.get(ATTR_CHANGE),
spread=kwargs.get(ATTR_SPREAD),
transition=kwargs.get(ATTR_TRANSITION),
brightness=brightness,
)
yield from self.effects_conductor.start(effect, devices)
elif service == SERVICE_EFFECT_STOP:
yield from self.effects_conductor.stop(devices)
def service_to_entities(self, service):
"""Return the known devices that a service call mentions."""
entity_ids = extract_entity_ids(self.hass, service)
if entity_ids:
entities = [entity for entity in self.entities.values()
if entity.entity_id in entity_ids]
else:
entities = list(self.entities.values())
return entities
@callback
def register(self, device):
"""Handle for newly detected bulb."""
if device.mac_addr in self.entities:
entity = self.entities[device.mac_addr]
entity.device = device
entity.registered = True
_LOGGER.debug("%s register AGAIN", entity.who)
self.hass.async_add_job(entity.async_update_ha_state())
else:
_LOGGER.debug("%s register NEW", device.ip_addr)
device.timeout = MESSAGE_TIMEOUT
device.retry_count = MESSAGE_RETRIES
device.unregister_timeout = UNAVAILABLE_GRACE
device.get_version(self.got_version)
@callback
def got_version(self, device, msg):
"""Request current color setting once we have the product version."""
device.get_color(self.ready)
@callback
def ready(self, device, msg):
"""Handle the device once all data is retrieved."""
entity = LIFXLight(device, self.effects_conductor)
_LOGGER.debug("%s register READY", entity.who)
self.entities[device.mac_addr] = entity
self.async_add_devices([entity])
@callback
def unregister(self, device):
"""Handle disappearing bulbs."""
if device.mac_addr in self.entities:
entity = self.entities[device.mac_addr]
_LOGGER.debug("%s unregister", entity.who)
entity.registered = False
self.hass.async_add_job(entity.async_update_ha_state())
class AwaitAioLIFX:
"""Wait for an aiolifx callback and return the message."""
def __init__(self, light):
"""Initialize the wrapper."""
self.light = light
self.device = None
self.message = None
self.event = asyncio.Event()
@callback
def callback(self, device, message):
"""Handle responses."""
self.device = device
self.message = message
self.event.set()
@asyncio.coroutine
def wait(self, method):
"""Call an aiolifx method and wait for its response."""
self.device = None
self.message = None
self.event.clear()
method(callb=self.callback)
yield from self.event.wait()
return self.message
def convert_8_to_16(value):
"""Scale an 8 bit level into 16 bits."""
return (value << 8) | value
def convert_16_to_8(value):
"""Scale a 16 bit level into 8 bits."""
return value >> 8
class LIFXLight(Light):
"""Representation of a LIFX light."""
def __init__(self, device, effects_conductor):
"""Initialize the light."""
self.device = device
self.effects_conductor = effects_conductor
self.registered = True
self.product = device.product
self.postponed_update = None
@property
def lifxwhite(self):
"""Return whether this is a white-only bulb."""
# https://lan.developer.lifx.com/docs/lifx-products
return self.product in [10, 11, 18]
@property
def available(self):
"""Return the availability of the device."""
return self.registered
@property
def name(self):
"""Return the name of the device."""
return self.device.label
@property
def who(self):
"""Return a string identifying the device."""
return "%s (%s)" % (self.device.ip_addr, self.name)
@property
def rgb_color(self):
"""Return the RGB value."""
hue, sat, bri, _ = self.device.color
return color_util.color_hsv_to_RGB(
hue, convert_16_to_8(sat), convert_16_to_8(bri))
@property
def brightness(self):
"""Return the brightness of this light between 0..255."""
brightness = convert_16_to_8(self.device.color[2])
_LOGGER.debug("brightness: %d", brightness)
return brightness
@property
def color_temp(self):
"""Return the color temperature."""
kelvin = self.device.color[3]
temperature = color_util.color_temperature_kelvin_to_mired(kelvin)
_LOGGER.debug("color_temp: %d", temperature)
return temperature
@property
def min_mireds(self):
"""Return the coldest color_temp that this light supports."""
# The 3 LIFX "White" products supported a limited temperature range
if self.lifxwhite:
kelvin = 6500
else:
kelvin = 9000
return math.floor(color_util.color_temperature_kelvin_to_mired(kelvin))
@property
def max_mireds(self):
"""Return the warmest color_temp that this light supports."""
# The 3 LIFX "White" products supported a limited temperature range
if self.lifxwhite:
kelvin = 2700
else:
kelvin = 2500
return math.ceil(color_util.color_temperature_kelvin_to_mired(kelvin))
@property
def is_on(self):
"""Return true if device is on."""
return self.device.power_level != 0
@property
def effect(self):
"""Return the name of the currently running effect."""
effect = self.effects_conductor.effect(self.device)
if effect:
return 'lifx_effect_' + effect.name
return None
@property
def supported_features(self):
"""Flag supported features."""
features = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP |
SUPPORT_TRANSITION | SUPPORT_EFFECT)
if not self.lifxwhite:
features |= SUPPORT_RGB_COLOR | SUPPORT_XY_COLOR
return features
@property
def effect_list(self):
"""Return the list of supported effects for this light."""
if self.lifxwhite:
return [
SERVICE_EFFECT_PULSE,
SERVICE_EFFECT_STOP,
]
return [
SERVICE_EFFECT_COLORLOOP,
SERVICE_EFFECT_PULSE,
SERVICE_EFFECT_STOP,
]
@asyncio.coroutine
def update_after_transition(self, now):
"""Request new status after completion of the last transition."""
self.postponed_update = None
yield from self.async_update()
yield from self.async_update_ha_state()
def update_later(self, when):
"""Schedule an update requests when a transition is over."""
if self.postponed_update:
self.postponed_update()
self.postponed_update = None
if when > 0:
self.postponed_update = async_track_point_in_utc_time(
self.hass, self.update_after_transition,
util.dt.utcnow() + timedelta(milliseconds=when))
@asyncio.coroutine
def async_turn_on(self, **kwargs):
"""Turn the device on."""
kwargs[ATTR_POWER] = True
yield from self.async_set_state(**kwargs)
@asyncio.coroutine
def async_turn_off(self, **kwargs):
"""Turn the device off."""
kwargs[ATTR_POWER] = False
yield from self.async_set_state(**kwargs)
@asyncio.coroutine
def async_set_state(self, **kwargs):
"""Set a color on the light and turn it on/off."""
yield from self.effects_conductor.stop([self.device])
if ATTR_EFFECT in kwargs:
yield from self.default_effect(**kwargs)
return
if ATTR_INFRARED in kwargs:
self.device.set_infrared(convert_8_to_16(kwargs[ATTR_INFRARED]))
if ATTR_TRANSITION in kwargs:
fade = int(kwargs[ATTR_TRANSITION] * 1000)
else:
fade = 0
# These are both False if ATTR_POWER is not set
power_on = kwargs.get(ATTR_POWER, False)
power_off = not kwargs.get(ATTR_POWER, True)
hsbk = merge_hsbk(self.device.color, find_hsbk(**kwargs))
# Send messages, waiting for ACK each time
ack = AwaitAioLIFX(self).wait
bulb = self.device
if not self.is_on:
if power_off:
yield from ack(partial(bulb.set_power, False))
if hsbk:
yield from ack(partial(bulb.set_color, hsbk))
if power_on:
yield from ack(partial(bulb.set_power, True, duration=fade))
else:
if power_on:
yield from ack(partial(bulb.set_power, True))
if hsbk:
yield from ack(partial(bulb.set_color, hsbk, duration=fade))
if power_off:
yield from ack(partial(bulb.set_power, False, duration=fade))
# Schedule an update when the transition is complete
self.update_later(fade)
@asyncio.coroutine
def default_effect(self, **kwargs):
"""Start an effect with default parameters."""
service = kwargs[ATTR_EFFECT]
data = {
ATTR_ENTITY_ID: self.entity_id,
}
yield from self.hass.services.async_call(DOMAIN, service, data)
@asyncio.coroutine
def async_update(self):
"""Update bulb status."""
_LOGGER.debug("%s async_update", self.who)
if self.available:
# Avoid state ping-pong by holding off updates as the state settles
yield from asyncio.sleep(0.25)
yield from AwaitAioLIFX(self).wait(self.device.get_color)