Skip to content

Commit

Permalink
LIFX light effects (#7145)
Browse files Browse the repository at this point in the history
* Refactor into find_hsbk

This will be useful for new methods that also have to find passed in colors.

* Add AwaitAioLIFX

This encapsulates the callback and Event that aiolifx needs and thus avoids an
explosion of those when new calls are added.

The refresh_state is now generally useful, so move it into its own method.

* Initial effects support for LIFX

These effects are useful as notifications. They mimic the breathe and pulse
effects from the LIFX HTTP API:

    https://api.developer.lifx.com/docs/breathe-effect
    https://api.developer.lifx.com/docs/pulse-effect

However, this implementation runs locally with the LIFX LAN protocol.

* Saturate LIFX no color value

Now the color is "full saturation, no brightness". This avoids a lot of
temporary white when fading from the "no color" value and into a real color.

* Organize LIFX effects in classes

This is to move the setup/restore away from the actual effect, making it quite
simple to add additional effects.

* Stop running LIFX effects on conflicting service calls

Turning the light on/off or starting a new effect will now stop the running
effect.

* Present default LIFX effects as light.turn_on effects

This makes the effects (with default parameters) easily accessible from
the UI.

* Add LIFX colorloop effect

This cycles the HSV colors, so that is added as an internal way to set a
color.

* Move lifx to its own package and split effects into a separate file

* Always show LIFX light name in logs

The name is actually the easiest way to identify a bulb so just using it
as a fallback was a bit odd.

* Compact effect getter

* Always use full brightness for random flash color

This is a stopgap. When a bit more infrastructure is in place, the intention
is to turn the current hue some degrees. This will guarantee a flash color
that is both unlike the current color and unlike white.

* Clear effects concurrently

We have to wait for the bulbs, so let us wait for all of them at once.

* Add lifx_effect_stop

The colorloop effect is most impressive if run on many lights. Testing
this has revealed the need for an easy way to stop effects on all lights
and return to the initial state of each bulb. This new call does just that.

Calling turn_on/turn_off could also stop the effect but that would not
restore the initial state.

* Always calculate the initial effect color

To fade nicely from power off, the breathe effect needs to keep an
unchanging hue. So give up on using a static start color and just find the
correct hue from the target color.

The colorloop effect can start from anything but we use a random color
just to keep things a little interesting during power on.

* Fix lint

* Update .coveragerc
  • Loading branch information
amelchio authored and balloob committed Apr 21, 2017
1 parent dbb0525 commit e3f682c
Show file tree
Hide file tree
Showing 4 changed files with 559 additions and 46 deletions.
2 changes: 1 addition & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ omit =
homeassistant/components/light/flux_led.py
homeassistant/components/light/hue.py
homeassistant/components/light/hyperion.py
homeassistant/components/light/lifx.py
homeassistant/components/light/lifx/*.py
homeassistant/components/light/lifx_legacy.py
homeassistant/components/light/limitlessled.py
homeassistant/components/light/mystrom.py
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,24 @@
import sys
from functools import partial
from datetime import timedelta
import async_timeout

import voluptuous as vol

from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_TRANSITION,
Light, PLATFORM_SCHEMA, ATTR_BRIGHTNESS, ATTR_COLOR_NAME, ATTR_RGB_COLOR,
ATTR_COLOR_TEMP, ATTR_TRANSITION, ATTR_EFFECT,
SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR,
SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA)
SUPPORT_TRANSITION, SUPPORT_EFFECT)
from homeassistant.util.color import (
color_temperature_mired_to_kelvin, color_temperature_kelvin_to_mired)
from homeassistant import util
from homeassistant.core import callback
from homeassistant.helpers.event import async_track_point_in_utc_time
import homeassistant.helpers.config_validation as cv
import homeassistant.util.color as color_util

from . import effects as lifx_effects

_LOGGER = logging.getLogger(__name__)

Expand All @@ -35,18 +40,19 @@

CONF_SERVER = 'server'

ATTR_HSBK = 'hsbk'

BYTE_MAX = 255
SHORT_MAX = 65535

SUPPORT_LIFX = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR |
SUPPORT_TRANSITION)
SUPPORT_TRANSITION | SUPPORT_EFFECT)

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Optional(CONF_SERVER, default='0.0.0.0'): cv.string,
})


# pylint: disable=unused-argument
@asyncio.coroutine
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
"""Setup the LIFX platform."""
Expand All @@ -65,6 +71,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
local_addr=(server_addr, UDP_BROADCAST_PORT))

hass.async_add_job(coro)

lifx_effects.setup(hass, lifx_manager)

return True


Expand Down Expand Up @@ -104,10 +113,42 @@ def unregister(self, device):
entity = self.entities[device.mac_addr]
_LOGGER.debug("%s unregister", entity.who)
entity.device = None
entity.updated_event.set()
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):
"""Callback that aiolifx invokes when the response is received."""
self.device = device
self.message = message
self.event.set()

@asyncio.coroutine
def wait(self, method):
"""Call an aiolifx method and wait for its response or a timeout."""
self.event.clear()
method(self.callback)

while self.light.available and not self.event.is_set():
try:
with async_timeout.timeout(1.0, loop=self.light.hass.loop):
yield from self.event.wait()
except asyncio.TimeoutError:
pass

return self.message


def convert_rgb_to_hsv(rgb):
"""Convert Home Assistant RGB values to HSV values."""
red, green, blue = [_ / BYTE_MAX for _ in rgb]
Expand All @@ -125,8 +166,8 @@ class LIFXLight(Light):
def __init__(self, device):
"""Initialize the light."""
self.device = device
self.updated_event = asyncio.Event()
self.blocker = None
self.effect_data = None
self.postponed_update = None
self._name = device.label
self.set_power(device.power_level)
Expand All @@ -145,10 +186,10 @@ def name(self):
@property
def who(self):
"""Return a string identifying the device."""
ip_addr = '-'
if self.device:
return self.device.ip_addr[0]
else:
return "(%s)" % self.name
ip_addr = self.device.ip_addr[0]
return "%s (%s)" % (ip_addr, self.name)

@property
def rgb_color(self):
Expand Down Expand Up @@ -178,11 +219,21 @@ def is_on(self):
_LOGGER.debug("is_on: %d", self._power)
return self._power != 0

@property
def effect(self):
"""Return the currently running effect."""
return self.effect_data.effect.name if self.effect_data else None

@property
def supported_features(self):
"""Flag supported features."""
return SUPPORT_LIFX

@property
def effect_list(self):
"""Return the list of supported effects."""
return lifx_effects.effect_list()

@callback
def update_after_transition(self, now):
"""Request new status after completion of the last transition."""
Expand Down Expand Up @@ -213,36 +264,18 @@ def update_later(self, when):
@asyncio.coroutine
def async_turn_on(self, **kwargs):
"""Turn the device on."""
yield from self.stop_effect()

if ATTR_EFFECT in kwargs:
yield from lifx_effects.default_effect(self, **kwargs)
return

if ATTR_TRANSITION in kwargs:
fade = int(kwargs[ATTR_TRANSITION] * 1000)
else:
fade = 0

changed_color = False

if ATTR_RGB_COLOR in kwargs:
hue, saturation, brightness = \
convert_rgb_to_hsv(kwargs[ATTR_RGB_COLOR])
changed_color = True
else:
hue = self._hue
saturation = self._sat
brightness = self._bri

if ATTR_BRIGHTNESS in kwargs:
brightness = kwargs[ATTR_BRIGHTNESS] * (BYTE_MAX + 1)
changed_color = True
else:
brightness = self._bri

if ATTR_COLOR_TEMP in kwargs:
kelvin = int(color_temperature_mired_to_kelvin(
kwargs[ATTR_COLOR_TEMP]))
changed_color = True
else:
kelvin = self._kel

hsbk = [hue, saturation, brightness, kelvin]
hsbk, changed_color = self.find_hsbk(**kwargs)
_LOGGER.debug("turn_on: %s (%d) %d %d %d %d %d",
self.who, self._power, fade, *hsbk)

Expand All @@ -263,6 +296,8 @@ def async_turn_on(self, **kwargs):
@asyncio.coroutine
def async_turn_off(self, **kwargs):
"""Turn the device off."""
yield from self.stop_effect()

if ATTR_TRANSITION in kwargs:
fade = int(kwargs[ATTR_TRANSITION] * 1000)
else:
Expand All @@ -274,22 +309,63 @@ def async_turn_off(self, **kwargs):
if fade < BULB_LATENCY:
self.set_power(0)

@callback
def got_color(self, device, msg):
"""Callback that gets current power/color status."""
self.set_power(device.power_level)
self.set_color(*device.color)
self._name = device.label
self.updated_event.set()

@asyncio.coroutine
def async_update(self):
"""Update bulb status (if it is available)."""
_LOGGER.debug("%s async_update", self.who)
if self.available and self.blocker is None:
self.updated_event.clear()
self.device.get_color(self.got_color)
yield from self.updated_event.wait()
yield from self.refresh_state()

@asyncio.coroutine
def stop_effect(self):
"""Stop the currently running effect (if any)."""
if self.effect_data:
yield from self.effect_data.effect.async_restore(self)

@asyncio.coroutine
def refresh_state(self):
"""Ask the device about its current state and update our copy."""
msg = yield from AwaitAioLIFX(self).wait(self.device.get_color)
if msg is not None:
self.set_power(self.device.power_level)
self.set_color(*self.device.color)
self._name = self.device.label

def find_hsbk(self, **kwargs):
"""Find the desired color from a number of possible inputs."""
changed_color = False

hsbk = kwargs.pop(ATTR_HSBK, None)
if hsbk is not None:
return [hsbk, True]

color_name = kwargs.pop(ATTR_COLOR_NAME, None)
if color_name is not None:
kwargs[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name)

if ATTR_RGB_COLOR in kwargs:
hue, saturation, brightness = \
convert_rgb_to_hsv(kwargs[ATTR_RGB_COLOR])
changed_color = True
else:
hue = self._hue
saturation = self._sat
brightness = self._bri

if ATTR_BRIGHTNESS in kwargs:
brightness = kwargs[ATTR_BRIGHTNESS] * (BYTE_MAX + 1)
changed_color = True
else:
brightness = self._bri

if ATTR_COLOR_TEMP in kwargs:
kelvin = int(color_temperature_mired_to_kelvin(
kwargs[ATTR_COLOR_TEMP]))
changed_color = True
else:
kelvin = self._kel

return [[hue, saturation, brightness, kelvin], changed_color]

def set_power(self, power):
"""Set power state value."""
Expand Down
Loading

0 comments on commit e3f682c

Please sign in to comment.